diff --git a/backend/custom_addons/encoach_ai/data/agents_defaults.xml b/backend/custom_addons/encoach_ai/data/agents_defaults.xml index 94ddb202..bc042dfb 100644 --- a/backend/custom_addons/encoach_ai/data/agents_defaults.xml +++ b/backend/custom_addons/encoach_ai/data/agents_defaults.xml @@ -99,6 +99,37 @@ 90 + + + media.synthesize_audio + Synthesize narration audio + media + + Render an MP3 narration of a material's text using AWS Polly (default) or ElevenLabs. Used for listening_script and speaking_prompt materials. Returns the media id, status and a /web/content URL when ready. + {"type":"object","properties":{"material_id":{"type":"integer"},"voice":{"type":"string"},"language":{"type":"string","default":"en-GB"},"gender":{"type":"string","enum":["female","male"]},"provider":{"type":"string","enum":["polly","elevenlabs"]}},"required":["material_id"]} + 120 + + + + media.generate_image + Generate illustration (DALL-E) + media + + Generate a single PNG illustration with OpenAI DALL-E 3 for a course-plan material. Used for reading hero images and vocabulary flashcards. Honours the per-plan image budget (encoach_ai_course.image_budget_per_plan). + {"type":"object","properties":{"material_id":{"type":"integer"},"prompt":{"type":"string"},"size":{"type":"string","enum":["1024x1024","1024x1792","1792x1024"]},"style":{"type":"string","enum":["natural","vivid"]},"quality":{"type":"string","enum":["standard","hd"]}},"required":["material_id"]} + 130 + + + + media.compose_video + Compose slideshow video (ffmpeg) + media + + Combine an existing audio narration with a still image into an MP4 (1280x720) using a local ffmpeg subprocess. Auto-creates audio and/or image first if the material lacks them. Falls back gracefully when ffmpeg is missing on the server. + {"type":"object","properties":{"material_id":{"type":"integer"}},"required":["material_id"]} + 140 + + scoring.grade_writing @@ -118,92 +149,6 @@ 110 - - - deliverables.detect - Detect deliverables from outline - reference - Parse a course outline (like GE1 PDF) and extract structured learning outcomes/deliverables week by week. Returns deliverable codes, skills, descriptions, and resource hints. - {"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"]} - 120 - - - - deliverables.fetch - Fetch plan deliverables - reference - Fetch deliverables for a course plan so AI can reference them when generating materials. - {"type":"object","properties":{"plan_id":{"type":"integer"},"week_number":{"type":"integer"},"skill":{"type":"string"}}} - 121 - - - - resources.fetch - Fetch resource dependencies - reference - Fetch resource dependencies for a course plan (textbooks, videos, etc.) so AI knows what's available to reference. - {"type":"object","properties":{"plan_id":{"type":"integer"},"resource_type":{"type":"string"},"is_available":{"type":"boolean"}}} - 122 - - - - resources.save - Save resource dependency - persistence - - Save a resource dependency for a course plan. - {"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"]} - 123 - - - - - media.suggest_visuals - Suggest visual aids - custom - Suggest what images, diagrams, or visuals would enhance a teaching material. Returns descriptions and prompts for image generation. - {"type":"object","properties":{"content_description":{"type":"string"},"material_type":{"type":"string"},"target_audience":{"type":"string"}}} - 130 - - - - media.generate_image - Generate educational image - custom - Generate an educational image using AI (DALL-E/Stable Diffusion) for teaching materials. - {"type":"object","properties":{"prompt":{"type":"string"},"material_id":{"type":"integer"},"style":{"type":"string"}}} - 131 - - - - media.generate_audio - Generate audio (TTS) - custom - Generate audio using TTS (ElevenLabs, AWS Polly) for listening scripts or pronunciation guides. - {"type":"object","properties":{"text":{"type":"string"},"voice":{"type":"string"},"material_id":{"type":"integer"},"purpose":{"type":"string"}}} - 132 - - - - - assignment.create - Create course assignment - persistence - - Assign a course plan to a class or student and create deliverable tracking rows. - {"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"]} - 140 - - - - assignment.progress - Get assignment progress - reference - Get progress summary for a course plan assignment. - {"type":"object","properties":{"assignment_id":{"type":"integer"}},"required":["assignment_id"]} - 141 - - @@ -233,37 +178,6 @@ 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'), - ])]"/> - - - - - deliverable_detector - Course Outline Deliverable Detector - Parses course outlines (like GE1 PDF) and extracts structured deliverables (learning outcomes by week). Creates deliverable records with skill codes, descriptions, and resource dependencies. - gpt-4o - gpt-4o-mini - 0.3 - 6000 - json - simple - 0 - - 15 - 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{} - @@ -290,49 +204,14 @@ 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. - -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 +- Output valid JSON only; no prose or markdown around it. - - - - - media_generator - Rich Media Generator - Generates images, audio, and video suggestions for teaching materials. Uses DALL-E for images, TTS for audio, and suggests video content. - gpt-4o - gpt-4o-mini - 0.6 - 2000 - json - simple - 0 - - 25 - 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 - @@ -482,6 +361,46 @@ Rules: ])]"/> + + + course_media_director + Course Media Director + Given a generated week of teaching materials, decides which media (audio narration, illustrations, slideshow video) each material needs and orchestrates their generation through the media tools. + gpt-4o-mini + gpt-4o + 0.3 + 2000 + text + react + 0 + + 80 + You are the multimedia director for an English language course. Given the materials of one week, you decide what media each material needs, then call the matching tools. + +Default policy: +- listening_script -> media.synthesize_audio (mandatory) + media.generate_image (optional, scene illustration) + media.compose_video (optional, slideshow) +- speaking_prompt -> media.synthesize_audio for the model answer (optional) +- reading_text -> media.generate_image for a hero illustration (optional) +- vocabulary_list -> media.generate_image for the first 1-3 terms (use vocab term in the prompt) +- writing_prompt / grammar_lesson -> usually no media + +Rules: +- Always check if a material already has ready media of a given kind before regenerating; if so, skip. +- Stop after issuing the planned tool calls; never invent material ids. +- When done, emit a short text summary listing what was generated (media_id, status, error if any). + + + + + + encoach_ai_course.image_budget_per_plan + 60 + + diff --git a/backend/custom_addons/encoach_ai/models/ai_agent.py b/backend/custom_addons/encoach_ai/models/ai_agent.py index 310609e3..df925b62 100644 --- a/backend/custom_addons/encoach_ai/models/ai_agent.py +++ b/backend/custom_addons/encoach_ai/models/ai_agent.py @@ -66,6 +66,7 @@ TOOL_CATEGORIES = [ ("quality", "Quality & gating"), ("scoring", "Scoring & grading"), ("reference", "Reference lookup"), + ("media", "Media generation"), ("other", "Other"), ] diff --git a/backend/custom_addons/encoach_ai/services/agent_tools.py b/backend/custom_addons/encoach_ai/services/agent_tools.py index 5a591d68..fbaf8665 100644 --- a/backend/custom_addons/encoach_ai/services/agent_tools.py +++ b/backend/custom_addons/encoach_ai/services/agent_tools.py @@ -88,20 +88,31 @@ def invoke(env, key: str, params: dict | None = None) -> dict: # --- Retrieval ---------------------------------------------------------------- @register("resources.search") def _search_resources(env, query: str = "", skill: str = "", cefr_level: str = "", - limit: int = 5, **_: Any) -> dict: + limit: int = 5, plan_id: int | None = None, + **_: Any) -> dict: """Semantic search over the LMS resource library. + When ``plan_id`` is provided, retrieval is scoped to that plan's + indexed reference sources only (uploaded PDFs, URLs, inline text). + Without ``plan_id`` we search the global resource library. + Returns titles + short snippets so the agent can cite existing materials instead of inventing new ones every run. """ from odoo.addons.encoach_vector.services.embedding_service import ( - EmbeddingService, # noqa: F401 + EmbeddingService, ) try: svc = EmbeddingService(env) - # EmbeddingService.search is expected to filter by content_type; - # we accept a skill filter from the agent but don't require it. - results = svc.search(query or "", limit=int(limit or 5)) + if plan_id: + results = svc.search( + query or "", + content_type="course_plan_source", + entity_id=int(plan_id), + limit=int(limit or 5), + ) + else: + results = svc.search(query or "", limit=int(limit or 5)) except Exception as exc: _logger.debug("resource vector search unavailable: %s", exc) results = [] @@ -114,7 +125,7 @@ def _search_resources(env, query: str = "", skill: str = "", cefr_level: str = " "snippet": (r.get("text") or "")[:400], "similarity": r.get("similarity"), }) - return {"query": query, "count": len(out), "items": out} + return {"query": query, "plan_id": plan_id, "count": len(out), "items": out} @register("rubric.fetch") @@ -326,433 +337,82 @@ def _grade_speaking(env, rubric: str = "", transcript: str = "", **_: Any) -> di return {"error": str(exc)} -# --- Deliverable Detection & Resource Management (GE1-style course planning) --- +# --- Media generation tools --------------------------------------------------- +# +# These bridge an agent decision (e.g. "this listening lesson needs an MP3") +# into the MediaService implementation. They are mutating tools — the seed +# row in agents_defaults.xml has ``mutates=True`` so the runtime wraps them +# in a savepoint. -@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. +@register("media.synthesize_audio") +def _media_synthesize_audio(env, material_id: int, voice: str = "", + language: str = "en-GB", + gender: str = "female", + provider: str = "polly", **_: Any) -> dict: + Material = env["encoach.course.plan.material"].sudo() \ + if "encoach.course.plan.material" in env else None + if Material is None: + return {"error": "course_plan_material_model_missing"} + rec = Material.browse(int(material_id)) + if not rec.exists(): + return {"error": "material_not_found"} + from odoo.addons.encoach_ai_course.services.media_service import MediaService + svc = MediaService(env) + media = svc.synthesize_audio( + rec, voice=voice or None, language=language or "en-GB", + gender=gender or "female", provider=provider or "polly", + ) + return { + "media_id": media.id, + "status": media.status, + "download_url": media.download_url, + "error": media.error or None, + } - 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)} +def _media_generate_image(env, material_id: int, prompt: str = "", + size: str = "1024x1024", + style: str = "natural", + quality: str = "standard", **_: Any) -> dict: + Material = env["encoach.course.plan.material"].sudo() \ + if "encoach.course.plan.material" in env else None + if Material is None: + return {"error": "course_plan_material_model_missing"} + rec = Material.browse(int(material_id)) + if not rec.exists(): + return {"error": "material_not_found"} + from odoo.addons.encoach_ai_course.services.media_service import MediaService + svc = MediaService(env) + media = svc.generate_image( + rec, custom_prompt=prompt or None, + size=size or "1024x1024", + style=style or "natural", + quality=quality or "standard", + ) + return { + "media_id": media.id, + "status": media.status, + "download_url": media.download_url, + "error": media.error or None, + } -@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)} +@register("media.compose_video") +def _media_compose_video(env, material_id: int, **_: Any) -> dict: + Material = env["encoach.course.plan.material"].sudo() \ + if "encoach.course.plan.material" in env else None + if Material is None: + return {"error": "course_plan_material_model_missing"} + rec = Material.browse(int(material_id)) + if not rec.exists(): + return {"error": "material_not_found"} + from odoo.addons.encoach_ai_course.services.media_service import MediaService + svc = MediaService(env) + media = svc.compose_video(rec) + return { + "media_id": media.id, + "status": media.status, + "download_url": media.download_url, + "error": media.error or None, + } diff --git a/backend/custom_addons/encoach_ai/services/openai_service.py b/backend/custom_addons/encoach_ai/services/openai_service.py index 2570fff9..3ab5fe89 100644 --- a/backend/custom_addons/encoach_ai/services/openai_service.py +++ b/backend/custom_addons/encoach_ai/services/openai_service.py @@ -213,6 +213,60 @@ class OpenAIService: """Use the fast/cheap model for classification, tagging, simple tasks.""" return self.chat(messages, model=self.fast_model, **kwargs) + def generate_image(self, prompt, *, size="1024x1024", style="natural", + quality="standard", model=None): + """Generate an image with DALL-E 3 and return raw PNG bytes. + + Returns: + dict {"image": bytes, "revised_prompt": str, "model": str, + "size": str, "style": str}. + + Raises ``RuntimeError`` if OpenAI is not configured. Network / + moderation failures bubble up as the SDK's exceptions; callers + should catch and record them. + """ + self._check_enabled() + if not self.client: + raise RuntimeError("OpenAI not configured — set API key in AI Settings") + image_model = model or self._get_param( + "encoach_ai.openai_image_model", "dall-e-3", + ) + t0 = time.time() + try: + resp = self.client.images.generate( + model=image_model, + prompt=prompt or "", + n=1, + size=size, + style=style, + quality=quality, + response_format="b64_json", + timeout=self.request_timeout, + ) + data = resp.data[0] + import base64 as _b64 + img_bytes = _b64.b64decode(data.b64_json) + self._log( + "generate_image", image_model, None, + int((time.time() - t0) * 1000), + inp=(prompt or "")[:500], + out=f"{len(img_bytes)}B {size} {style}", + ) + return { + "image": img_bytes, + "revised_prompt": getattr(data, "revised_prompt", "") or "", + "model": image_model, + "size": size, + "style": style, + } + except Exception as exc: + self._log( + "generate_image", image_model, None, + int((time.time() - t0) * 1000), + status="error", error=str(exc), + ) + raise + def grade_writing(self, rubric, task_text, response_text): """Grade a writing response using GPT with a rubric.""" messages = [ diff --git a/backend/custom_addons/encoach_ai_course/controllers/course_plan.py b/backend/custom_addons/encoach_ai_course/controllers/course_plan.py index c29f009d..5877badb 100644 --- a/backend/custom_addons/encoach_ai_course/controllers/course_plan.py +++ b/backend/custom_addons/encoach_ai_course/controllers/course_plan.py @@ -6,10 +6,10 @@ endpoints. Every route is JWT-guarded via the shared ``@jwt_required`` decorator and returns JSON. """ -import json +import base64 import logging -from odoo import http, fields +from odoo import http from odoo.http import request from odoo.addons.encoach_api.controllers.base import ( jwt_required, @@ -21,6 +21,10 @@ from odoo.addons.encoach_api.controllers.base import ( from odoo.addons.encoach_ai_course.services.course_plan_pipeline import ( CoursePlanPipeline, ) +from odoo.addons.encoach_ai_course.services.deliverables import ( + compute_deliverables, +) +from odoo.addons.encoach_ai_course.services.media_service import MediaService _logger = logging.getLogger(__name__) @@ -172,313 +176,421 @@ class CoursePlanController(http.Controller): return _error_response(str(exc), 500) # ================================================================== - # NEW: Deliverable Detection & Management + # PHASE A — Reference sources (RAG grounding) # ================================================================== - # POST /api/ai/course-plan//deliverables/detect - @http.route('/api/ai/course-plan//deliverables/detect', - type='http', auth='none', methods=['POST'], csrf=False) + @http.route('/api/ai/course-plan//sources', + type='http', auth='none', methods=['GET'], csrf=False) @jwt_required - def detect_deliverables(self, plan_id, **kw): - """Parse course outline text and extract structured deliverables.""" + def list_sources(self, plan_id, **kw): 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) + plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) + if not plan.exists(): + return _error_response('Plan not found', 404) + return _json_response({ + 'items': [s.to_api_dict() for s in plan.source_ids], + 'count': len(plan.source_ids), + }) except Exception as exc: - _logger.exception('course-plan.detect_deliverables failed') + _logger.exception('course-plan.list_sources failed') return _error_response(str(exc), 500) - # GET /api/ai/course-plan//deliverables + @http.route('/api/ai/course-plan//sources', + type='http', auth='none', methods=['POST'], csrf=False) + @jwt_required + def create_source(self, plan_id, **kw): + try: + plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) + if not plan.exists(): + return _error_response('Plan not found', 404) + + ct = request.httprequest.content_type or '' + files = request.httprequest.files + uploaded = files.get('file') + if 'application/json' in ct: + body = _get_json_body() or {} + else: + body = {**kw} + + kind = (body.get('kind') or '').strip() or ( + 'file' if uploaded else + ('url' if (body.get('url') or '').strip() else 'text') + ) + name = (body.get('name') or '').strip() + auto_index = body.get('auto_index') + if isinstance(auto_index, str): + auto_index = auto_index.lower() not in ('0', 'false', 'no', 'off') + elif auto_index is None: + auto_index = True + vals = { + 'plan_id': plan.id, + 'kind': kind, + 'auto_index': bool(auto_index), + } + if kind == 'file': + if not uploaded: + return _error_response('No file uploaded', 400) + payload = uploaded.read() + vals['file'] = base64.b64encode(payload) + vals['file_name'] = uploaded.filename or 'source' + vals['mime_type'] = uploaded.mimetype or '' + vals['name'] = name or uploaded.filename or 'source' + elif kind == 'url': + url = (body.get('url') or '').strip() + if not url: + return _error_response('URL is required', 400) + vals['url'] = url + vals['name'] = name or url + else: + text = (body.get('inline_text') or body.get('text') or '').strip() + if not text: + return _error_response('Inline text is required', 400) + vals['inline_text'] = text + vals['name'] = name or 'Inline text' + + rec = request.env['encoach.course.plan.source'].sudo().create(vals) + return _json_response({'data': rec.to_api_dict()}) + except Exception as exc: + _logger.exception('course-plan.create_source failed') + return _error_response(str(exc), 500) + + @http.route('/api/ai/course-plan//sources//index', + type='http', auth='none', methods=['POST'], csrf=False) + @jwt_required + def reindex_source(self, plan_id, source_id, **kw): + try: + rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id)) + if not rec.exists() or rec.plan_id.id != int(plan_id): + return _error_response('Source not found', 404) + rec.action_index() + return _json_response({'data': rec.to_api_dict()}) + except Exception as exc: + _logger.exception('course-plan.reindex_source failed') + return _error_response(str(exc), 500) + + @http.route('/api/ai/course-plan//sources/', + type='http', auth='none', methods=['DELETE'], csrf=False) + @jwt_required + def delete_source(self, plan_id, source_id, **kw): + try: + rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id)) + if not rec.exists() or rec.plan_id.id != int(plan_id): + return _error_response('Source not found', 404) + rec.unlink() + return _json_response({'success': True}) + except Exception as exc: + _logger.exception('course-plan.delete_source failed') + return _error_response(str(exc), 500) + + # ================================================================== + # PHASE B — Deliverables preview / progress + # ================================================================== + @http.route('/api/ai/course-plan//deliverables', type='http', auth='none', methods=['GET'], csrf=False) @jwt_required - def list_deliverables(self, plan_id, **kw): - """List deliverables for a course plan.""" + def get_deliverables(self, plan_id, **kw): 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), - }) + plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) + if not plan.exists(): + return _error_response('Plan not found', 404) + return _json_response(compute_deliverables(plan)) except Exception as exc: - _logger.exception('course-plan.list_deliverables failed') + _logger.exception('course-plan.deliverables failed') return _error_response(str(exc), 500) - # PUT /api/ai/course-plan/deliverables/ - @http.route('/api/ai/course-plan/deliverables/', - type='http', auth='none', methods=['PUT'], csrf=False) + # ================================================================== + # PHASE C — Multimedia generation per material + # ================================================================== + + def _resolve_material(self, material_id): + rec = request.env['encoach.course.plan.material'].sudo().browse(int(material_id)) + if not rec.exists(): + return None + return rec + + @http.route('/api/ai/course-plan/material//media/audio', + type='http', auth='none', methods=['POST'], csrf=False) @jwt_required - def update_deliverable(self, deliverable_id, **kw): - """Update deliverable status or details.""" + def gen_audio(self, material_id, **kw): 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()}) + material = self._resolve_material(material_id) + if not material: + return _error_response('Material not found', 404) + body = _get_json_body() or {} + svc = MediaService(request.env) + media = svc.synthesize_audio( + material, + voice=body.get('voice'), + language=body.get('language') or 'en-GB', + gender=body.get('gender') or 'female', + provider=body.get('provider') or 'polly', + ) + return _json_response({'data': media.to_api_dict()}) except Exception as exc: - _logger.exception('course-plan.update_deliverable failed') + _logger.exception('course-plan.gen_audio failed') return _error_response(str(exc), 500) - # ================================================================== - # NEW: Resource Dependencies - # ================================================================== + @http.route('/api/ai/course-plan/material//media/image', + type='http', auth='none', methods=['POST'], csrf=False) + @jwt_required + def gen_image(self, material_id, **kw): + try: + material = self._resolve_material(material_id) + if not material: + return _error_response('Material not found', 404) + body = _get_json_body() or {} + svc = MediaService(request.env) + media = svc.generate_image( + material, + custom_prompt=body.get('prompt'), + size=body.get('size') or '1024x1024', + style=body.get('style') or 'natural', + quality=body.get('quality') or 'standard', + ) + return _json_response({'data': media.to_api_dict()}) + except Exception as exc: + _logger.exception('course-plan.gen_image failed') + return _error_response(str(exc), 500) - # GET /api/ai/course-plan//resources - @http.route('/api/ai/course-plan//resources', + @http.route('/api/ai/course-plan/material//media/video', + type='http', auth='none', methods=['POST'], csrf=False) + @jwt_required + def gen_video(self, material_id, **kw): + try: + material = self._resolve_material(material_id) + if not material: + return _error_response('Material not found', 404) + svc = MediaService(request.env) + media = svc.compose_video(material) + return _json_response({'data': media.to_api_dict()}) + except Exception as exc: + _logger.exception('course-plan.gen_video failed') + return _error_response(str(exc), 500) + + @http.route('/api/ai/course-plan/material//media', type='http', auth='none', methods=['GET'], csrf=False) @jwt_required - def list_resources(self, plan_id, **kw): - """List resource dependencies for a course plan.""" + def list_material_media(self, material_id, **kw): 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) + material = self._resolve_material(material_id) + if not material: + return _error_response('Material not found', 404) return _json_response({ - 'items': [r.to_api_dict() for r in records], - 'count': len(records), + 'items': [m.to_api_dict() for m in material.media_ids], + 'count': len(material.media_ids), }) except Exception as exc: - _logger.exception('course-plan.list_resources failed') + _logger.exception('course-plan.list_material_media failed') return _error_response(str(exc), 500) - # POST /api/ai/course-plan//resources - @http.route('/api/ai/course-plan//resources', - type='http', auth='none', methods=['POST'], csrf=False) + @http.route('/api/ai/course-plan/media/', + type='http', auth='none', methods=['DELETE'], csrf=False) @jwt_required - def add_resource(self, plan_id, **kw): - """Add a resource dependency to a course plan.""" + def delete_media(self, media_id, **kw): 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()}) + rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id)) + if not rec.exists(): + return _error_response('Media not found', 404) + if rec.attachment_id: + rec.attachment_id.unlink() + rec.unlink() + return _json_response({'success': True}) except Exception as exc: - _logger.exception('course-plan.add_resource failed') - return _json_response(str(exc), 500) + _logger.exception('course-plan.delete_media failed') + return _error_response(str(exc), 500) - # ================================================================== - # NEW: Rich Media Generation - # ================================================================== - - # POST /api/ai/course-plan/materials//media/suggest - @http.route('/api/ai/course-plan/materials//media/suggest', + @http.route('/api/ai/course-plan//weeks//media', 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.""" + def gen_week_media(self, plan_id, week_number, **kw): + """Generate every applicable modality for every material in a week. + + Body shape: ``{ kinds: ['audio', 'image', 'video'] }``. + Default is ``['audio', 'image']`` — video is opt-in because it + depends on ffmpeg + the audio + image steps and is slower. + """ try: - pipeline = CoursePlanPipeline( - request.env, language=_request_language(), + plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) + if not plan.exists(): + return _error_response('Plan not found', 404) + week = plan.week_ids.filtered( + lambda w: w.week_number == int(week_number), ) - 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) + if not week: + return _error_response('Week not found', 404) + week = week[0] + body = _get_json_body() or {} + kinds = body.get('kinds') or ['audio', 'image'] + kinds = [str(k).lower() for k in kinds] - # POST /api/ai/course-plan/materials//media/generate - @http.route('/api/ai/course-plan/materials//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) + svc = MediaService(request.env) + results = [] + for material in week.material_ids: + if 'audio' in kinds and material.material_type in ( + 'listening_script', 'speaking_prompt', + ): + results.append(svc.synthesize_audio(material).to_api_dict()) + if 'image' in kinds and material.material_type in ( + 'reading_text', 'listening_script', 'vocabulary_list', + ): + try: + results.append(svc.generate_image(material).to_api_dict()) + except Exception as exc: + results.append({'error': str(exc), 'material_id': material.id}) + if 'video' in kinds and material.material_type == 'listening_script': + results.append(svc.compose_video(material).to_api_dict()) + return _json_response({'items': results, 'count': len(results)}) except Exception as exc: - _logger.exception('course-plan.generate_media failed') + _logger.exception('course-plan.gen_week_media failed') return _error_response(str(exc), 500) # ================================================================== - # NEW: Course Plan Assignment to Classes/Students + # PHASE D — Plan assignments # ================================================================== - # POST /api/ai/course-plan//assignments - @http.route('/api/ai/course-plan//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//assignments @http.route('/api/ai/course-plan//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))]) + plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) + if not plan.exists(): + return _error_response('Plan not found', 404) return _json_response({ - 'items': [a.to_api_dict() for a in records], - 'count': len(records), + 'items': [a.to_api_dict() for a in plan.assignment_ids], + 'count': len(plan.assignment_ids), }) except Exception as exc: _logger.exception('course-plan.list_assignments failed') return _error_response(str(exc), 500) - # GET /api/ai/course-plan/assignments/ - @http.route('/api/ai/course-plan/assignments/', - type='http', auth='none', methods=['GET'], csrf=False) + @http.route('/api/ai/course-plan//assignments', + type='http', auth='none', methods=['POST'], csrf=False) @jwt_required - def get_assignment(self, assignment_id, **kw): - """Get assignment details with progress.""" + def create_assignment(self, plan_id, **kw): 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//deliverables/ - @http.route('/api/ai/course-plan/assignments//deliverables/', - 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) + plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) + if not plan.exists(): + return _error_response('Plan not found', 404) + body = _get_json_body() or {} + mode = (body.get('mode') or 'batch').strip() + vals = { + 'plan_id': plan.id, + 'mode': mode, + 'message': (body.get('message') or '').strip(), + 'due_date': body.get('due_date') or False, + 'state': 'active', + } + if mode == 'batch': + if not body.get('batch_id'): + return _error_response('batch_id is required', 400) + vals['batch_id'] = int(body['batch_id']) + elif mode == 'students': + ids = body.get('student_user_ids') or [] + if not isinstance(ids, list) or not ids: + return _error_response('student_user_ids is required', 400) + vals['student_user_ids'] = [(6, 0, [int(i) for i in ids])] + else: + return _error_response('Invalid mode', 400) + rec = request.env['encoach.course.plan.assignment'].sudo().create(vals) return _json_response({'data': rec.to_api_dict()}) except Exception as exc: - _logger.exception('course-plan.update_assignment_deliverable failed') + _logger.exception('course-plan.create_assignment failed') + return _error_response(str(exc), 500) + + @http.route('/api/ai/course-plan//assignments/', + type='http', auth='none', methods=['DELETE'], csrf=False) + @jwt_required + def delete_assignment(self, plan_id, assignment_id, **kw): + try: + rec = request.env['encoach.course.plan.assignment'].sudo().browse( + int(assignment_id), + ) + if not rec.exists() or rec.plan_id.id != int(plan_id): + return _error_response('Assignment not found', 404) + rec.unlink() + return _json_response({'success': True}) + except Exception as exc: + _logger.exception('course-plan.delete_assignment failed') + return _error_response(str(exc), 500) + + # ================================================================== + # PHASE E — Student-side endpoints + # ================================================================== + + @http.route('/api/student/course-plans', + type='http', auth='none', methods=['GET'], csrf=False) + @jwt_required + def student_list_plans(self, **kw): + """Return the course plans assigned to the authenticated user. + + Visibility rules (OR): + 1. There exists an assignment with ``mode='students'`` listing + the current user. + 2. There exists an assignment with ``mode='batch'`` whose + batch contains an ``op.student.course`` whose student's + ``user_id`` matches the current user. + """ + try: + user = request.env.user + Assignment = request.env['encoach.course.plan.assignment'].sudo() + assignments = Assignment.search([ + ('state', '=', 'active'), + '|', + ('student_user_ids', 'in', [user.id]), + '&', ('mode', '=', 'batch'), ('batch_id', '!=', False), + ]) + visible = [] + for a in assignments: + if a.mode == 'students' and user.id in a.student_user_ids.ids: + visible.append(a) + continue + if a.mode == 'batch' and user.id in a.expand_user_ids(): + visible.append(a) + + seen = set() + out = [] + for a in visible: + if a.plan_id.id in seen: + continue + seen.add(a.plan_id.id) + plan_data = a.plan_id.to_api_dict( + include_weeks=False, include_materials=False, + ) + plan_data['assignment'] = a.to_api_dict() + out.append(plan_data) + return _json_response({'items': out, 'count': len(out)}) + except Exception as exc: + _logger.exception('course-plan.student_list_plans failed') + return _error_response(str(exc), 500) + + @http.route('/api/student/course-plans/', + type='http', auth='none', methods=['GET'], csrf=False) + @jwt_required + def student_get_plan(self, plan_id, **kw): + try: + user = request.env.user + plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) + if not plan.exists(): + return _error_response('Plan not found', 404) + allowed = False + for a in plan.assignment_ids.filtered(lambda x: x.state == 'active'): + if a.mode == 'students' and user.id in a.student_user_ids.ids: + allowed = True + break + if a.mode == 'batch' and user.id in a.expand_user_ids(): + allowed = True + break + if not allowed: + return _error_response('Plan not assigned to you', 403) + return _json_response({ + 'data': plan.to_api_dict( + include_weeks=True, + include_materials=True, + include_media=True, + ), + }) + except Exception as exc: + _logger.exception('course-plan.student_get_plan failed') return _error_response(str(exc), 500) diff --git a/backend/custom_addons/encoach_ai_course/models/__init__.py b/backend/custom_addons/encoach_ai_course/models/__init__.py index e5c0cecf..5bc4cfe9 100644 --- a/backend/custom_addons/encoach_ai_course/models/__init__.py +++ b/backend/custom_addons/encoach_ai_course/models/__init__.py @@ -1,3 +1,6 @@ from . import ai_generation_log from . import ai_ielts_generation_log from . import course_plan +from . import course_plan_source +from . import course_plan_media +from . import course_plan_assignment diff --git a/backend/custom_addons/encoach_ai_course/models/course_plan.py b/backend/custom_addons/encoach_ai_course/models/course_plan.py index 974f803c..5dfaaf79 100644 --- a/backend/custom_addons/encoach_ai_course/models/course_plan.py +++ b/backend/custom_addons/encoach_ai_course/models/course_plan.py @@ -44,28 +44,10 @@ 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' @@ -135,15 +117,30 @@ class CoursePlan(models.Model): material_ids = fields.One2many( 'encoach.course.plan.material', 'plan_id', string='Materials', ) + source_ids = fields.One2many( + 'encoach.course.plan.source', 'plan_id', string='Reference sources', + ) + media_ids = fields.One2many( + 'encoach.course.plan.media', 'plan_id', string='Generated media', + ) + assignment_ids = fields.One2many( + 'encoach.course.plan.assignment', 'plan_id', string='Assignments', + ) week_count = fields.Integer(compute='_compute_counts', store=False) material_count = fields.Integer(compute='_compute_counts', store=False) + source_count = fields.Integer(compute='_compute_counts', store=False) + media_count = fields.Integer(compute='_compute_counts', store=False) + assignment_count = fields.Integer(compute='_compute_counts', store=False) - @api.depends('week_ids', 'material_ids') + @api.depends('week_ids', 'material_ids', 'source_ids', 'media_ids', 'assignment_ids') def _compute_counts(self): for rec in self: rec.week_count = len(rec.week_ids) rec.material_count = len(rec.material_ids) + rec.source_count = len(rec.source_ids) + rec.media_count = len(rec.media_ids) + rec.assignment_count = len(rec.assignment_ids) # ------------------------------------------------------------------ # Serialisation helpers — used by the REST controller so payload @@ -157,7 +154,9 @@ class CoursePlan(models.Model): except (TypeError, ValueError): return default - def to_api_dict(self, *, include_weeks=True, include_materials=False): + def to_api_dict(self, *, include_weeks=True, include_materials=False, + include_sources=False, include_media=False, + include_assignments=False): self.ensure_one() data = { 'id': self.id, @@ -177,12 +176,21 @@ class CoursePlan(models.Model): 'resources': self._loads(self.resources_json, []), 'week_count': len(self.week_ids), 'material_count': len(self.material_ids), + 'source_count': len(self.source_ids), + 'media_count': len(self.media_ids), + 'assignment_count': len(self.assignment_ids), 'created_at': self.create_date.isoformat() if self.create_date else None, } if include_weeks: data['weeks'] = [w.to_api_dict() for w in self.week_ids.sorted('week_number')] if include_materials: data['materials'] = [m.to_api_dict() for m in self.material_ids] + if include_sources: + data['sources'] = [s.to_api_dict() for s in self.source_ids] + if include_media: + data['media'] = [m.to_api_dict() for m in self.media_ids] + if include_assignments: + data['assignments'] = [a.to_api_dict() for a in self.assignment_ids] return data @@ -270,48 +278,8 @@ class CoursePlanMaterial(models.Model): 'structured body is not needed.', ) - def _loads(self, raw, default): - if not raw: - return default - try: - return json.loads(raw) - except (TypeError, ValueError): - return default - - # 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' + media_ids = fields.One2many( + 'encoach.course.plan.media', 'material_id', string='Generated media', ) def _loads(self, raw, default): @@ -322,9 +290,9 @@ class CoursePlanMaterial(models.Model): except (TypeError, ValueError): return default - def to_api_dict(self, include_media=False): + def to_api_dict(self, include_media=True): self.ensure_one() - data = { + out = { 'id': self.id, 'plan_id': self.plan_id.id, 'week_id': self.week_id.id if self.week_id else None, @@ -335,315 +303,7 @@ 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, - } + out['media'] = [m.to_api_dict() for m in self.media_ids] + return out diff --git a/backend/custom_addons/encoach_ai_course/models/course_plan_assignment.py b/backend/custom_addons/encoach_ai_course/models/course_plan_assignment.py new file mode 100644 index 00000000..fd8bde3c --- /dev/null +++ b/backend/custom_addons/encoach_ai_course/models/course_plan_assignment.py @@ -0,0 +1,128 @@ +"""Course-plan assignments: link a generated plan to a class or students. + +Two flavours of assignment: + +* ``mode='batch'`` — all current and future students of an ``op.batch`` + see the plan in their student dashboard. +* ``mode='students'`` — only the chosen ``res.users`` (linked through + the standard ``op.student`` portal user) see it. + +The assignment is the visibility unit; it does NOT mutate +enrollment, grades, or schedules. Teachers can attach a due date and a +short message that appears on the student card. +""" + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +ASSIGNMENT_MODE_SELECTION = [ + ('batch', 'Class / Batch'), + ('students', 'Specific students'), +] + +ASSIGNMENT_STATE_SELECTION = [ + ('active', 'Active'), + ('archived', 'Archived'), +] + + +class CoursePlanAssignment(models.Model): + _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, + ) + mode = fields.Selection(ASSIGNMENT_MODE_SELECTION, required=True, default='batch') + + batch_id = fields.Many2one( + 'op.batch', ondelete='set null', string='Class / batch', + ) + student_user_ids = fields.Many2many( + 'res.users', 'course_plan_assignment_student_rel', + 'assignment_id', 'user_id', string='Specific students', + ) + + assigned_by_id = fields.Many2one( + 'res.users', default=lambda self: self.env.user, string='Assigned by', + ) + due_date = fields.Date() + message = fields.Text(help='Optional note shown to the student.') + state = fields.Selection(ASSIGNMENT_STATE_SELECTION, default='active') + + student_count = fields.Integer(compute='_compute_student_count', store=False) + + @api.depends('mode', 'batch_id', 'student_user_ids') + def _compute_student_count(self): + Enroll = self.env['op.student.course'].sudo() + Batch = self.env['op.batch'].sudo() + for rec in self: + if rec.mode == 'students': + rec.student_count = len(rec.student_user_ids) + continue + if not rec.batch_id: + rec.student_count = 0 + continue + try: + course_id = Batch.browse(rec.batch_id.id).course_id.id + if course_id: + rec.student_count = Enroll.search_count([ + ('course_id', '=', course_id), + ('batch_id', '=', rec.batch_id.id), + ]) + else: + rec.student_count = 0 + except Exception: + rec.student_count = 0 + + def expand_user_ids(self): + """Return the ``res.users`` ids that should see this plan.""" + self.ensure_one() + if self.mode == 'students': + return self.student_user_ids.ids + if self.mode == 'batch' and self.batch_id: + try: + Enroll = self.env['op.student.course'].sudo() + course_id = self.batch_id.course_id.id + rows = Enroll.search([ + ('course_id', '=', course_id), + ('batch_id', '=', self.batch_id.id), + ]) + user_ids = [] + for row in rows: + student = getattr(row, 'student_id', None) + if not student: + continue + user = getattr(student, 'user_id', None) + if user and user.id: + user_ids.append(user.id) + return list(set(user_ids)) + except Exception: + _logger.exception('expand_user_ids failed for assignment %s', self.id) + return [] + return [] + + def to_api_dict(self): + self.ensure_one() + return { + 'id': self.id, + 'plan_id': self.plan_id.id, + 'plan_name': self.plan_id.name or '', + 'mode': self.mode or 'batch', + 'batch_id': self.batch_id.id if self.batch_id else None, + 'batch_name': self.batch_id.name if self.batch_id else '', + 'student_user_ids': self.student_user_ids.ids, + 'student_user_names': [u.name for u in self.student_user_ids], + 'student_count': self.student_count or 0, + 'assigned_by_id': self.assigned_by_id.id if self.assigned_by_id else None, + 'assigned_by_name': self.assigned_by_id.name if self.assigned_by_id else '', + 'due_date': self.due_date.isoformat() if self.due_date else None, + 'message': self.message or '', + 'state': self.state or 'active', + 'created_at': self.create_date.isoformat() if self.create_date else None, + } diff --git a/backend/custom_addons/encoach_ai_course/models/course_plan_media.py b/backend/custom_addons/encoach_ai_course/models/course_plan_media.py new file mode 100644 index 00000000..6b90dfe5 --- /dev/null +++ b/backend/custom_addons/encoach_ai_course/models/course_plan_media.py @@ -0,0 +1,124 @@ +"""Multimedia training assets generated for a course-plan material. + +Each material (a reading text, listening script, vocab list, etc.) can +have one or more linked media rows: an MP3 narration of a listening +script, a DALL-E illustration for a vocab card, an MP4 slideshow video +combining the two, and so on. Bytes live on ``ir.attachment`` so the +existing filestore + access controls + URL serving (``/web/content``) +all work for free. + +Media generation is triggered explicitly through REST endpoints — we +never generate inline during plan creation because each call has a +non-trivial latency and cost. +""" + +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +MEDIA_KIND_SELECTION = [ + ('audio', 'Audio'), + ('image', 'Image'), + ('video', 'Video'), +] + +MEDIA_PROVIDER_SELECTION = [ + ('polly', 'AWS Polly'), + ('elevenlabs', 'ElevenLabs'), + ('openai_image', 'OpenAI (DALL-E)'), + ('ffmpeg', 'ffmpeg (slideshow)'), + ('elai', 'Elai.io'), + ('manual', 'Manual upload'), +] + +MEDIA_STATUS_SELECTION = [ + ('queued', 'Queued'), + ('generating', 'Generating'), + ('ready', 'Ready'), + ('failed', 'Failed'), +] + + +class CoursePlanMedia(models.Model): + _name = 'encoach.course.plan.media' + _description = 'Course Plan Multimedia Asset' + _order = 'kind, id desc' + + 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, + ) + material_id = fields.Many2one( + 'encoach.course.plan.material', ondelete='cascade', index=True, + ) + + kind = fields.Selection(MEDIA_KIND_SELECTION, required=True) + provider = fields.Selection(MEDIA_PROVIDER_SELECTION, required=True) + title = fields.Char() + source_text = fields.Text( + help='The text fed to the generator (TTS script, image prompt, etc.).', + ) + voice = fields.Char() + language = fields.Char(default='en-GB') + style = fields.Char(help='DALL-E style or video template label.') + + attachment_id = fields.Many2one( + 'ir.attachment', ondelete='set null', string='Binary', + ) + mime_type = fields.Char() + size_bytes = fields.Integer() + duration_seconds = fields.Float() + width = fields.Integer() + height = fields.Integer() + download_url = fields.Char( + compute='_compute_download_url', store=False, + help='Web-accessible URL served by Odoo (/web/content/).', + ) + + status = fields.Selection(MEDIA_STATUS_SELECTION, default='queued') + error = fields.Char() + cost_cents = fields.Integer( + default=0, + help='Approximate provider cost in USD cents at generation time, ' + 'best-effort accounting for budget caps.', + ) + + @api.depends('attachment_id') + def _compute_download_url(self): + for rec in self: + rec.download_url = ( + f'/web/content/{rec.attachment_id.id}?download=true&filename=' + f'{rec.attachment_id.name or "media"}' + if rec.attachment_id else '' + ) + + def to_api_dict(self): + self.ensure_one() + return { + 'id': self.id, + 'plan_id': self.plan_id.id, + 'week_id': self.week_id.id if self.week_id else None, + 'material_id': self.material_id.id if self.material_id else None, + 'kind': self.kind or '', + 'provider': self.provider or '', + 'title': self.title or '', + 'voice': self.voice or '', + 'language': self.language or '', + 'style': self.style or '', + 'mime_type': self.mime_type or '', + 'size_bytes': self.size_bytes or 0, + 'duration_seconds': self.duration_seconds or 0.0, + 'width': self.width or 0, + 'height': self.height or 0, + 'attachment_id': self.attachment_id.id if self.attachment_id else None, + 'download_url': self.download_url, + 'status': self.status or 'queued', + 'error': self.error or '', + 'cost_cents': self.cost_cents or 0, + 'created_at': self.create_date.isoformat() if self.create_date else None, + } diff --git a/backend/custom_addons/encoach_ai_course/models/course_plan_source.py b/backend/custom_addons/encoach_ai_course/models/course_plan_source.py new file mode 100644 index 00000000..6fcb6f25 --- /dev/null +++ b/backend/custom_addons/encoach_ai_course/models/course_plan_source.py @@ -0,0 +1,130 @@ +"""Reference source attached to a course plan. + +Each source is one of: an uploaded file (PDF, DOCX, TXT), a URL the +agent should crawl, or a chunk of inline text. Indexing extracts the +text, chunks it, and pushes the chunks into ``encoach.embedding`` so +the LangGraph ``course_planner`` and ``course_week_materials`` agents +can ground their generation on these sources via the existing +``resources.search`` tool, scoped to the plan. + +We deliberately use the existing pgvector store rather than a new one: +that way the same retrieval pipeline (embedding model, chunker, cosine +similarity ranker) is shared, and admins can look up "what did the AI +read" with the same dashboards. +""" + +import base64 +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +SOURCE_KIND_SELECTION = [ + ('file', 'File'), + ('url', 'URL'), + ('text', 'Inline text'), +] + +SOURCE_STATUS_SELECTION = [ + ('pending', 'Pending'), + ('indexing', 'Indexing'), + ('indexed', 'Indexed'), + ('failed', 'Failed'), +] + + +class CoursePlanSource(models.Model): + _name = 'encoach.course.plan.source' + _description = 'Course Plan Reference Source' + _order = 'sequence asc, id asc' + + plan_id = fields.Many2one( + 'encoach.course.plan', required=True, ondelete='cascade', index=True, + ) + sequence = fields.Integer(default=10) + name = fields.Char(required=True, help='Human-readable label.') + kind = fields.Selection(SOURCE_KIND_SELECTION, required=True, default='file') + + # Filled when ``kind == 'file'`` — base64 payload mirrors how + # encoach.resource and ir.attachment already do it. + file = fields.Binary(string='Uploaded file', attachment=True) + file_name = fields.Char(string='File name') + mime_type = fields.Char(string='MIME type') + + url = fields.Char(string='Source URL') + inline_text = fields.Text(string='Inline text') + + auto_index = fields.Boolean( + default=True, + help='If true, indexing runs automatically on create. ' + 'Disable to upload first, review, then index manually.', + ) + status = fields.Selection(SOURCE_STATUS_SELECTION, default='pending') + error = fields.Char() + chunks_count = fields.Integer(default=0, string='Chunks') + extracted_chars = fields.Integer(default=0, string='Extracted chars') + indexed_at = fields.Datetime() + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for rec in records: + if rec.auto_index: + try: + rec.action_index() + except Exception: + _logger.exception('Auto-index failed for source %s', rec.id) + return records + + def action_index(self): + """(Re-)extract text and push chunks to the vector store.""" + from odoo.addons.encoach_ai_course.services.source_indexer import ( + SourceIndexer, + ) + for rec in self: + indexer = SourceIndexer(self.env) + indexer.index(rec) + return True + + def unlink(self): + try: + from odoo.addons.encoach_vector.services.embedding_service import ( + EmbeddingService, + ) + svc = EmbeddingService(self.env) + for rec in self: + svc.delete('course_plan_source', rec.id) + except Exception: + _logger.exception('Failed to delete embeddings for sources') + return super().unlink() + + def to_api_dict(self): + self.ensure_one() + return { + 'id': self.id, + 'plan_id': self.plan_id.id, + 'name': self.name or '', + 'kind': self.kind or 'file', + 'file_name': self.file_name or '', + 'mime_type': self.mime_type or '', + 'url': self.url or '', + 'has_inline_text': bool(self.inline_text), + 'status': self.status or 'pending', + 'error': self.error or '', + 'chunks_count': self.chunks_count or 0, + 'extracted_chars': self.extracted_chars or 0, + 'indexed_at': self.indexed_at.isoformat() if self.indexed_at else None, + 'created_at': self.create_date.isoformat() if self.create_date else None, + } + + def get_decoded_file(self): + """Return raw bytes of the uploaded file, or ``b''`` if none.""" + self.ensure_one() + if not self.file: + return b'' + try: + return base64.b64decode(self.file) + except Exception: + return b'' diff --git a/backend/custom_addons/encoach_ai_course/security/ir.model.access.csv b/backend/custom_addons/encoach_ai_course/security/ir.model.access.csv index 557a6979..cacc6edd 100644 --- a/backend/custom_addons/encoach_ai_course/security/ir.model.access.csv +++ b/backend/custom_addons/encoach_ai_course/security/ir.model.access.csv @@ -4,7 +4,6 @@ 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 -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_source_user,encoach.course.plan.source.user,model_encoach_course_plan_source,base.group_user,1,1,1,1 +access_encoach_course_plan_media_user,encoach.course.plan.media.user,model_encoach_course_plan_media,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 diff --git a/backend/custom_addons/encoach_ai_course/services/__init__.py b/backend/custom_addons/encoach_ai_course/services/__init__.py index 8a59f2b2..195738fa 100644 --- a/backend/custom_addons/encoach_ai_course/services/__init__.py +++ b/backend/custom_addons/encoach_ai_course/services/__init__.py @@ -1,3 +1,6 @@ from .english_pipeline import EnglishPipeline from .ielts_pipeline import IeltsPipeline from .course_plan_pipeline import CoursePlanPipeline +from .source_indexer import SourceIndexer +from .media_service import MediaService +from .deliverables import compute_deliverables diff --git a/backend/custom_addons/encoach_ai_course/services/course_plan_pipeline.py b/backend/custom_addons/encoach_ai_course/services/course_plan_pipeline.py index cd22a84c..d7bfad08 100644 --- a/backend/custom_addons/encoach_ai_course/services/course_plan_pipeline.py +++ b/backend/custom_addons/encoach_ai_course/services/course_plan_pipeline.py @@ -1,6 +1,6 @@ """Course plan generation pipeline. -Three public entry points: +Two 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,22 +14,11 @@ Three 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 @@ -199,12 +188,6 @@ 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): @@ -511,291 +494,3 @@ 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}'} diff --git a/backend/custom_addons/encoach_ai_course/services/deliverables.py b/backend/custom_addons/encoach_ai_course/services/deliverables.py new file mode 100644 index 00000000..b07ffe65 --- /dev/null +++ b/backend/custom_addons/encoach_ai_course/services/deliverables.py @@ -0,0 +1,158 @@ +"""Compute the deliverables list and progress for a course plan. + +A deliverable is a single concrete artefact the AI should eventually +produce. Each week's planned skills (from ``items_json``) maps to a +deliverable, and each generated text material can in turn produce +multimedia children: + +* listening_script -> 1 audio narration + 1 illustration + 1 video +* reading_text -> 1 hero illustration +* speaking_prompt -> 1 model-answer audio +* vocabulary_list -> 1 image per term (capped at 8 per list) + +The frontend uses this to draw the Deliverables Preview before +generation and the progress strip on the detail page after. +""" + +from __future__ import annotations + +import json + + +SKILL_TO_MATERIAL_TYPE = { + 'reading': 'reading_text', + 'writing': 'writing_prompt', + 'listening': 'listening_script', + 'speaking': 'speaking_prompt', + 'grammar': 'grammar_lesson', + 'vocabulary': 'vocabulary_list', +} + +MATERIAL_MEDIA_PLAN = { + 'listening_script': [ + {'kind': 'audio', 'mandatory': True, 'note': 'Narrated MP3 of the script'}, + {'kind': 'image', 'mandatory': False, 'note': 'Illustration for the listening lesson'}, + {'kind': 'video', 'mandatory': False, 'note': 'Slide-style MP4 with audio'}, + ], + 'reading_text': [ + {'kind': 'image', 'mandatory': False, 'note': 'Hero illustration for the passage'}, + ], + 'speaking_prompt': [ + {'kind': 'audio', 'mandatory': False, 'note': 'Model-answer narration'}, + ], + 'vocabulary_list': [ + {'kind': 'image', 'mandatory': False, 'note': 'Flashcard image (one per term, capped)'}, + ], +} + +VOCAB_IMAGE_CAP = 8 + + +def _safe_loads(raw, default): + if not raw: + return default + try: + return json.loads(raw) + except (TypeError, ValueError): + return default + + +def compute_deliverables(plan): + """Return ``{summary, weeks: [...]}`` describing what the plan owes. + + Status logic per deliverable: + + * ``planned`` — no material row yet for that {week, material_type}. + * ``generated``— material row exists but no media, or media not + applicable for the type. + * ``ready`` — material has at least one ``ready`` media child for + every mandatory-or-cap-of-1 modality and one ``ready`` media for + vocab cap (we don't gate on every term). + + The frontend only needs the counts, but per-week breakdown is also + returned so it can render a checklist. + """ + weeks_payload = [] + + materials_by_week = {} + for m in plan.material_ids: + materials_by_week.setdefault(m.week_id.id if m.week_id else 0, []).append(m) + + counts = {'planned': 0, 'generated': 0, 'ready': 0} + media_counts = {'audio': 0, 'image': 0, 'video': 0} + media_ready_counts = {'audio': 0, 'image': 0, 'video': 0} + + for week in plan.week_ids.sorted('week_number'): + items = _safe_loads(week.items_json, []) + ws_materials = materials_by_week.get(week.id, []) + materials_by_type = {m.material_type: m for m in ws_materials} + + deliverables = [] + for item in items: + skill = (item.get('skill') or '').lower() + mtype = SKILL_TO_MATERIAL_TYPE.get(skill) + if not mtype: + continue + material = materials_by_type.get(mtype) + entry = { + 'skill': skill, + 'material_type': mtype, + 'material_id': material.id if material else None, + 'title': material.title if material else '', + 'media': [], + 'status': 'planned', + } + if material: + entry['status'] = 'generated' + planned_media = MATERIAL_MEDIA_PLAN.get(mtype, []) + ready_for_each_kind = {p['kind']: False for p in planned_media} + for child in material.media_ids: + media_counts[child.kind] = media_counts.get(child.kind, 0) + 1 + if child.status == 'ready': + media_ready_counts[child.kind] = ( + media_ready_counts.get(child.kind, 0) + 1 + ) + if child.kind in ready_for_each_kind: + ready_for_each_kind[child.kind] = True + entry['media'].append({ + 'id': child.id, 'kind': child.kind, + 'status': child.status, 'provider': child.provider, + }) + if planned_media and all( + ready_for_each_kind.get(p['kind'], False) + for p in planned_media if p.get('mandatory') + ): + entry['status'] = 'ready' + elif not planned_media: + entry['status'] = 'ready' + counts[entry['status']] += 1 + deliverables.append(entry) + weeks_payload.append({ + 'week_number': week.week_number, + 'date_label': week.date_label or '', + 'unit': week.unit or '', + 'focus': week.focus or '', + 'items_total': len(items), + 'deliverables': deliverables, + }) + + total = sum(counts.values()) + percent = round((counts['ready'] / total) * 100, 1) if total else 0.0 + return { + 'summary': { + 'total': total, + 'planned': counts['planned'], + 'generated': counts['generated'], + 'ready': counts['ready'], + 'percent_ready': percent, + 'media': { + 'audio': media_counts['audio'], + 'image': media_counts['image'], + 'video': media_counts['video'], + 'audio_ready': media_ready_counts['audio'], + 'image_ready': media_ready_counts['image'], + 'video_ready': media_ready_counts['video'], + }, + }, + 'weeks': weeks_payload, + } diff --git a/backend/custom_addons/encoach_ai_course/services/media_service.py b/backend/custom_addons/encoach_ai_course/services/media_service.py new file mode 100644 index 00000000..af625f59 --- /dev/null +++ b/backend/custom_addons/encoach_ai_course/services/media_service.py @@ -0,0 +1,387 @@ +"""Multimedia generation service for course-plan materials. + +Three modalities — each persists an ``encoach.course.plan.media`` row +with the bytes attached as an ``ir.attachment`` so the existing +``/web/content/`` URL serving works without extra plumbing. + +Audio: + Synthesise a TTS narration of a listening script or speaking + model-answer using AWS Polly (preferred) with a fallback to + ElevenLabs when configured. The voice picks itself from the plan's + target CEFR + a ``voice_key`` param. + +Image: + Use OpenAI's DALL-E 3 (via ``OpenAIService.generate_image``) with a + structured prompt built from the material body. Per-plan image + budgets are enforced so a single bad call doesn't bill an admin's + OpenAI account dry. + +Video: + Combine a generated image (or, if missing, generate one first) + with the audio narration into an MP4 using a local ``ffmpeg`` + subprocess. No third-party rendering service required for the + default install. ffmpeg presence is detected at call time and the + media row is marked ``failed`` with a clear error if it's missing. + +The service is deliberately stateless beyond the env handle so it can +be invoked from controllers, agent tools, or batch crons. +""" + +from __future__ import annotations + +import base64 +import io +import logging +import mimetypes +import os +import shutil +import subprocess +import tempfile +import time +from typing import Optional + +_logger = logging.getLogger(__name__) + + +# --- Helpers ---------------------------------------------------------------- + + +def _attach_bytes(env, *, name, mime_type, data: bytes, + res_model: str | None = None, + res_id: int | None = None, + public: bool = True): + """Persist ``data`` as an ``ir.attachment`` and return the record.""" + Attachment = env['ir.attachment'].sudo() + return Attachment.create({ + 'name': name, + 'type': 'binary', + 'datas': base64.b64encode(data), + 'mimetype': mime_type or 'application/octet-stream', + 'res_model': res_model or False, + 'res_id': res_id or 0, + 'public': bool(public), + }) + + +def _deduce_voice(language: str, gender: str = 'female') -> tuple[str, str]: + """Return ``(provider, voice_id)`` tuple for the requested language.""" + lang = (language or 'en-GB').strip() + return ('polly', '') # let the provider pick its default for the language + + +def _get_param(env, key, default): + return env['ir.config_parameter'].sudo().get_param(key, default) + + +def _enforce_image_budget(env, plan, planned_images: int = 1) -> None: + """Raise if generating ``planned_images`` would exceed the per-plan cap.""" + cap = int(_get_param(env, 'encoach_ai_course.image_budget_per_plan', '60')) + if cap <= 0: + return + Media = env['encoach.course.plan.media'].sudo() + used = Media.search_count([ + ('plan_id', '=', plan.id), + ('kind', '=', 'image'), + ('status', 'in', ('ready', 'generating')), + ]) + if used + planned_images > cap: + raise RuntimeError( + f'Image budget exceeded for this plan: {used} used, cap is {cap}. ' + f'Raise encoach_ai_course.image_budget_per_plan or delete old images.' + ) + + +def _build_audio_script(material) -> str: + """Choose the right text from a material's body for narration.""" + body = material._loads(material.body_json, {}) or {} + if material.material_type == 'listening_script': + return (body.get('script') or '').strip() + if material.material_type == 'speaking_prompt': + if isinstance(body.get('model_answer'), str) and body['model_answer'].strip(): + return body['model_answer'].strip() + prompts = body.get('prompts') or [] + if prompts: + return ' '.join(str(p) for p in prompts).strip() + if material.material_type == 'reading_text': + return (body.get('text') or '').strip() + return (material.body_text or '').strip() + + +def _build_image_prompt(material, *, plan) -> str: + """Construct a DALL-E prompt from the material content + plan CEFR.""" + body = material._loads(material.body_json, {}) or {} + cefr = (plan.cefr_level or '').upper() or 'A2' + style_hint = ( + 'flat illustration, soft pastel palette, friendly textbook style, ' + 'clean white background, high contrast, no text, no watermarks' + ) + if material.material_type == 'reading_text': + snippet = (body.get('text') or '')[:300].strip() + return ( + f'Editorial illustration for an English language reading lesson ' + f'(CEFR {cefr}) titled "{material.title}". Subject: {snippet} ' + f'Style: {style_hint}.' + ) + if material.material_type == 'listening_script': + snippet = (body.get('script') or '')[:300].strip() + return ( + f'Illustration showing the scene of an English listening ' + f'lesson dialogue (CEFR {cefr}) titled "{material.title}". ' + f'Scene: {snippet} Style: {style_hint}.' + ) + if material.material_type == 'vocabulary_list': + # Caller should pass a single term explicitly via ``custom_prompt``. + words = body.get('words') or [] + if words: + term = words[0].get('term') or material.title + return ( + f'Single concrete object representing the English word ' + f'"{term}" for a CEFR {cefr} vocabulary flashcard. {style_hint}.' + ) + return ( + f'Illustration for an English language teaching material (CEFR ' + f'{cefr}) titled "{material.title}". {style_hint}.' + ) + + +# --- Public service --------------------------------------------------------- + + +class MediaService: + """Generate audio / image / video assets for a course-plan material.""" + + def __init__(self, env): + self.env = env + + # -- Audio ----------------------------------------------------------- + def synthesize_audio(self, material, *, voice: Optional[str] = None, + language: str = 'en-GB', + gender: str = 'female', + provider: str = 'polly') -> 'models.Model': + """Generate a narration MP3 for ``material`` and persist it.""" + Media = self.env['encoach.course.plan.media'].sudo() + media = Media.create({ + 'plan_id': material.plan_id.id, + 'week_id': material.week_id.id if material.week_id else False, + 'material_id': material.id, + 'kind': 'audio', + 'provider': provider, + 'title': f'{material.title} — narration', + 'language': language, + 'voice': voice or '', + 'status': 'generating', + }) + text = _build_audio_script(material) + if not text: + media.write({'status': 'failed', 'error': 'No script text to narrate'}) + return media + media.write({'source_text': text[:3000]}) + try: + audio_bytes = self._call_tts( + text, voice=voice, language=language, + gender=gender, provider=provider, + ) + attach = _attach_bytes( + self.env, + name=f'plan-{material.plan_id.id}-week-{material.week_number}' + f'-{material.material_type}-{material.id}.mp3', + mime_type='audio/mpeg', + data=audio_bytes, + res_model='encoach.course.plan.media', + res_id=media.id, + ) + media.write({ + 'attachment_id': attach.id, + 'mime_type': 'audio/mpeg', + 'size_bytes': len(audio_bytes), + 'status': 'ready', + 'error': False, + }) + except Exception as exc: + _logger.exception('TTS failed for material %s', material.id) + media.write({'status': 'failed', 'error': str(exc)[:500]}) + return media + + def _call_tts(self, text, *, voice, language, gender, provider): + if provider == 'elevenlabs': + from odoo.addons.encoach_ai.services.elevenlabs_service import ( + ElevenLabsService, + ) + svc = ElevenLabsService(self.env) + res = svc.synthesize(text, voice_id=voice or None) + return res.get('audio') or res.get('audio_bytes') or b'' + from odoo.addons.encoach_ai.services.polly_service import ( + PollyService, + ) + svc = PollyService(self.env) + res = svc.synthesize( + text, voice=voice, language=language, gender=gender, + ) + return res['audio'] + + # -- Image ----------------------------------------------------------- + def generate_image(self, material, *, + custom_prompt: Optional[str] = None, + size: str = '1024x1024', + style: str = 'natural', + quality: str = 'standard') -> 'models.Model': + """Generate a DALL-E 3 illustration for ``material``.""" + Media = self.env['encoach.course.plan.media'].sudo() + plan = material.plan_id + _enforce_image_budget(self.env, plan, planned_images=1) + prompt = (custom_prompt or _build_image_prompt(material, plan=plan)).strip() + media = Media.create({ + 'plan_id': plan.id, + 'week_id': material.week_id.id if material.week_id else False, + 'material_id': material.id, + 'kind': 'image', + 'provider': 'openai_image', + 'title': f'{material.title} — illustration', + 'source_text': prompt[:3000], + 'style': style, + 'status': 'generating', + }) + try: + from odoo.addons.encoach_ai.services.openai_service import ( + OpenAIService, + ) + svc = OpenAIService(self.env) + result = svc.generate_image( + prompt, size=size, style=style, quality=quality, + ) + img = result['image'] + attach = _attach_bytes( + self.env, + name=f'plan-{plan.id}-week-{material.week_number}' + f'-{material.material_type}-{material.id}.png', + mime_type='image/png', + data=img, + res_model='encoach.course.plan.media', + res_id=media.id, + ) + try: + w, h = (int(s) for s in size.split('x')) + except Exception: + w, h = 0, 0 + media.write({ + 'attachment_id': attach.id, + 'mime_type': 'image/png', + 'size_bytes': len(img), + 'width': w, + 'height': h, + 'status': 'ready', + 'error': False, + 'cost_cents': 4 if quality == 'standard' else 8, + }) + except Exception as exc: + _logger.exception('Image gen failed for material %s', material.id) + media.write({'status': 'failed', 'error': str(exc)[:500]}) + return media + + # -- Video ----------------------------------------------------------- + def compose_video(self, material, *, audio_media=None, + image_media=None) -> 'models.Model': + """Compose a slide-style MP4 (image + audio) for ``material``. + + Auto-creates audio and/or image first if the caller didn't pass + them and they don't already exist on the material. Requires + ``ffmpeg`` on PATH; without it the media row is marked failed + with a clear error message. + """ + Media = self.env['encoach.course.plan.media'].sudo() + plan = material.plan_id + media = Media.create({ + 'plan_id': plan.id, + 'week_id': material.week_id.id if material.week_id else False, + 'material_id': material.id, + 'kind': 'video', + 'provider': 'ffmpeg', + 'title': f'{material.title} — slideshow', + 'status': 'generating', + }) + + if shutil.which('ffmpeg') is None: + media.write({ + 'status': 'failed', + 'error': 'ffmpeg not found on PATH; install it on the server', + }) + return media + + try: + audio = audio_media or material.media_ids.filtered( + lambda m: m.kind == 'audio' and m.status == 'ready' + )[:1] + if not audio: + audio = self.synthesize_audio(material) + if audio.status != 'ready': + raise RuntimeError( + f'Audio prerequisite not ready: {audio.error or "unknown"}' + ) + image = image_media or material.media_ids.filtered( + lambda m: m.kind == 'image' and m.status == 'ready' + )[:1] + if not image: + image = self.generate_image(material) + if image.status != 'ready': + raise RuntimeError( + f'Image prerequisite not ready: {image.error or "unknown"}' + ) + + audio_attach = audio.attachment_id + image_attach = image.attachment_id + if not audio_attach or not image_attach: + raise RuntimeError('Missing audio/image attachments') + + audio_bytes = base64.b64decode(audio_attach.datas) + image_bytes = base64.b64decode(image_attach.datas) + + with tempfile.TemporaryDirectory(prefix='encoach_video_') as tmp: + a_path = os.path.join(tmp, 'audio.mp3') + i_path = os.path.join(tmp, 'image.png') + v_path = os.path.join(tmp, 'out.mp4') + with open(a_path, 'wb') as f: + f.write(audio_bytes) + with open(i_path, 'wb') as f: + f.write(image_bytes) + cmd = [ + 'ffmpeg', '-y', + '-loop', '1', '-i', i_path, + '-i', a_path, + '-c:v', 'libx264', '-tune', 'stillimage', '-pix_fmt', 'yuv420p', + '-c:a', 'aac', '-b:a', '192k', + '-shortest', '-vf', 'scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=white', + v_path, + ] + t0 = time.time() + proc = subprocess.run( + cmd, capture_output=True, check=False, timeout=180, + ) + elapsed = time.time() - t0 + if proc.returncode != 0: + err = (proc.stderr or b'').decode('utf-8', errors='replace')[-500:] + raise RuntimeError(f'ffmpeg failed: {err}') + with open(v_path, 'rb') as f: + video_bytes = f.read() + attach = _attach_bytes( + self.env, + name=f'plan-{plan.id}-week-{material.week_number}' + f'-{material.material_type}-{material.id}.mp4', + mime_type='video/mp4', + data=video_bytes, + res_model='encoach.course.plan.media', + res_id=media.id, + ) + media.write({ + 'attachment_id': attach.id, + 'mime_type': 'video/mp4', + 'size_bytes': len(video_bytes), + 'duration_seconds': float(audio.duration_seconds or elapsed or 0), + 'width': 1280, + 'height': 720, + 'status': 'ready', + 'error': False, + }) + except Exception as exc: + _logger.exception('Video compose failed for material %s', material.id) + media.write({'status': 'failed', 'error': str(exc)[:500]}) + return media diff --git a/backend/custom_addons/encoach_ai_course/services/source_indexer.py b/backend/custom_addons/encoach_ai_course/services/source_indexer.py new file mode 100644 index 00000000..7599f889 --- /dev/null +++ b/backend/custom_addons/encoach_ai_course/services/source_indexer.py @@ -0,0 +1,202 @@ +"""Extract text from a course-plan source and embed it into pgvector. + +Supported inputs: + +* ``kind='file'`` — PDF (preferred via ``pypdf`` then ``PyPDF2``), DOCX + (via ``python-docx``), plain text, or any text MIME we can decode. +* ``kind='url'`` — fetched with ``requests``; HTML is reduced to text + via a tiny BeautifulSoup4 path when available, otherwise raw text. +* ``kind='text'`` — used as-is. + +Indexed chunks are stored under ``content_type='course_plan_source'`` +with ``entity_id=plan_id`` so the existing ``resources.search`` tool +can scope retrieval to a single plan. We never raise — failures are +recorded on the source row and surfaced to the UI through the status / +error fields. That way one bad PDF doesn't block the rest. +""" + +from __future__ import annotations + +import io +import logging +from datetime import datetime + +_logger = logging.getLogger(__name__) + + +def _extract_pdf(payload: bytes) -> str: + """Best-effort PDF text extraction. + + Tries ``pypdf`` first (newer, maintained), then ``PyPDF2`` (older, + still common). Both raise on encrypted PDFs we can't decrypt — we + swallow that and let the caller record the error. + """ + try: + from pypdf import PdfReader # type: ignore + except ImportError: + try: + from PyPDF2 import PdfReader # type: ignore + except ImportError as exc: + raise RuntimeError( + 'PDF parser not installed (pip install pypdf)', + ) from exc + reader = PdfReader(io.BytesIO(payload)) + pages = [] + for page in reader.pages: + try: + pages.append(page.extract_text() or '') + except Exception: + pages.append('') + return '\n\n'.join(p for p in pages if p).strip() + + +def _extract_docx(payload: bytes) -> str: + try: + import docx # type: ignore + except ImportError as exc: + raise RuntimeError( + 'DOCX parser not installed (pip install python-docx)', + ) from exc + document = docx.Document(io.BytesIO(payload)) + return '\n'.join(p.text for p in document.paragraphs if p.text).strip() + + +def _extract_html(html: str) -> str: + """Strip tags. Uses BeautifulSoup if available, else a regex fallback.""" + try: + from bs4 import BeautifulSoup # type: ignore + soup = BeautifulSoup(html, 'html.parser') + for tag in soup(['script', 'style', 'noscript']): + tag.decompose() + text = soup.get_text(separator='\n') + except ImportError: + import re + text = re.sub(r'<[^>]+>', ' ', html) + lines = [line.strip() for line in text.splitlines()] + return '\n'.join(line for line in lines if line) + + +def _fetch_url(url: str) -> tuple[str, str]: + """Fetch ``url`` and return ``(content_type, text)``.""" + try: + import requests # type: ignore + except ImportError as exc: + raise RuntimeError( + 'requests not installed (pip install requests)', + ) from exc + resp = requests.get(url, timeout=30, headers={ + 'User-Agent': 'EnCoach-CoursePlan-Indexer/1.0', + }) + resp.raise_for_status() + ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip().lower() + if ctype == 'application/pdf': + return ctype, _extract_pdf(resp.content) + if 'html' in ctype: + return ctype, _extract_html(resp.text) + if ctype.startswith('text/') or not ctype: + return ctype or 'text/plain', resp.text + raise RuntimeError(f'Unsupported content-type for URL: {ctype}') + + +class SourceIndexer: + """Extract text from a source row and (re-)index it to pgvector.""" + + CONTENT_TYPE = 'course_plan_source' + + def __init__(self, env): + self.env = env + + def _extract(self, source) -> str: + """Return raw extracted text — or raise if extraction fails.""" + if source.kind == 'text': + return (source.inline_text or '').strip() + + if source.kind == 'url': + url = (source.url or '').strip() + if not url: + raise ValueError('URL is empty') + mime, text = _fetch_url(url) + if not source.mime_type: + source.mime_type = mime + return text or '' + + if source.kind == 'file': + payload = source.get_decoded_file() + if not payload: + raise ValueError('No file payload to index') + mime = (source.mime_type or '').lower() + name = (source.file_name or '').lower() + if mime == 'application/pdf' or name.endswith('.pdf'): + return _extract_pdf(payload) + if (mime in ( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/msword', + ) or name.endswith('.docx') or name.endswith('.doc')): + return _extract_docx(payload) + try: + return payload.decode('utf-8', errors='replace').strip() + except Exception as exc: + raise RuntimeError(f'Cannot decode file: {exc}') from exc + + raise ValueError(f'Unknown source kind: {source.kind!r}') + + def index(self, source) -> dict: + """Run extraction + embedding for one source row.""" + from odoo.addons.encoach_vector.services.embedding_service import ( + EmbeddingService, + ) + source.write({'status': 'indexing', 'error': False}) + + try: + text = self._extract(source) + except Exception as exc: + source.write({ + 'status': 'failed', + 'error': str(exc)[:500], + 'chunks_count': 0, + 'extracted_chars': 0, + }) + _logger.warning('Extract failed for source %s: %s', source.id, exc) + return {'status': 'failed', 'error': str(exc)} + + if not text or not text.strip(): + source.write({ + 'status': 'failed', + 'error': 'Extracted no text from source', + 'chunks_count': 0, + 'extracted_chars': 0, + }) + return {'status': 'failed', 'error': 'empty'} + + try: + svc = EmbeddingService(self.env) + metadata = { + 'plan_id': source.plan_id.id, + 'source_id': source.id, + 'kind': source.kind, + 'title': source.name or source.file_name or source.url or '', + 'entity_id': source.plan_id.id, + } + chunks = svc.upsert( + self.CONTENT_TYPE, source.id, text, metadata, + ) + except Exception as exc: + source.write({ + 'status': 'failed', + 'error': str(exc)[:500], + }) + _logger.exception('Embedding failed for source %s', source.id) + return {'status': 'failed', 'error': str(exc)} + + source.write({ + 'status': 'indexed', + 'error': False, + 'chunks_count': len(chunks), + 'extracted_chars': len(text), + 'indexed_at': datetime.utcnow(), + }) + return { + 'status': 'indexed', + 'chunks_count': len(chunks), + 'extracted_chars': len(text), + } diff --git a/backend/custom_addons/encoach_vector/models/embedding.py b/backend/custom_addons/encoach_vector/models/embedding.py index fbcbe8bb..95b25b36 100644 --- a/backend/custom_addons/encoach_vector/models/embedding.py +++ b/backend/custom_addons/encoach_vector/models/embedding.py @@ -23,6 +23,7 @@ class EncoachEmbedding(models.Model): ('feedback', 'Feedback'), ('generation_log', 'Generation Log'), ('material', 'Course Material'), + ('course_plan_source', 'Course Plan Source'), ], required=True, index=True) content_id = fields.Integer(required=True, index=True) content_text = fields.Text() diff --git a/docs/PROJECT_SUMMARY.md b/docs/PROJECT_SUMMARY.md index fd42e4db..64face8c 100644 --- a/docs/PROJECT_SUMMARY.md +++ b/docs/PROJECT_SUMMARY.md @@ -1720,3 +1720,151 @@ docs/ENCOACH_FULL_DEMO_QA_REPORT.md # full QA write-up: credentials, dataset s - `khalid@encoach.test` was failing login because his password had drifted during earlier interactive testing. `reset_demo_passwords.py` now restores all canonical passwords idempotently. - `encoach.ai.log` field names differ from a naive guess — the model uses `service`, `action`, `model_used`, `prompt_tokens`, `completion_tokens`, `total_tokens`, `input_preview`, `output_preview`. `encoach.ai.feedback` uses `subject_type`, `subject_id`, `rating in {'up','down'}` (NOT `'thumbs_up'`). The seeder now matches both definitions. - The exam approval *post-approval* hook only fires when the **final** stage approves, so step 3 above advances the request without publishing the exam yet — that's correct behaviour, just not obvious from the API alone. The DB-side verification in step 6 is what makes it observable. + +## 24. AI Course-Plans — RAG, Multi-modal Media, Assignments (2026-04-25) + +§22 made LangGraph the runtime; §24 puts a real product on top of it. The user asked for an AI-driven **course-plan** experience modelled on the GE1 outline: detect deliverables up-front (e.g. "12 reading texts, 12 listening scripts, 24 audio narrations"), let admins drop reference files / URLs / pasted notes the AI must respect (RAG), generate multi-modal training material (images via DALL-E 3, voice via AWS Polly / ElevenLabs, slideshow video via local `ffmpeg`), then **assign** the finished plan to a class or to specific students. This section is the implementation log. + +### 24.1 What this section delivers + +| Phase | Capability | Where it lives | +|---|---|---| +| **A** | Reference sources (file/URL/text) → pgvector, scoped to plan | `encoach.course.plan.source` + `services/source_indexer.py` | +| **B** | Deliverables preview & live progress strip | `services/deliverables.py` + `/api/ai/course-plan//deliverables` | +| **C** | On-demand audio / image / video per material + bulk-per-week | `encoach.course.plan.media` + `services/media_service.py` | +| **D** | Assign a plan to an `op.batch` or specific `res.users` | `encoach.course.plan.assignment` | +| **E** | Student-side list + drilldown (read-only) | `/api/student/course-plans*` + `/student/course-plans` page | +| **i18n** | Full EN + AR strings for every new control | `frontend/src/i18n/locales/{en,ar}.ts` | + +### 24.2 New / extended backend artefacts + +```text +backend/custom_addons/encoach_ai_course/ +├── models/ +│ ├── course_plan_source.py ← NEW RAG ingestion row (file|url|text) +│ ├── course_plan_media.py ← NEW audio|image|video asset +│ └── course_plan_assignment.py ← NEW visibility unit (batch|students) +├── services/ +│ ├── source_indexer.py ← NEW PDF/DOCX/HTML extraction → pgvector +│ ├── deliverables.py ← NEW per-week status + percent_ready +│ └── media_service.py ← NEW Polly/ElevenLabs/DALL-E/ffmpeg +└── controllers/course_plan.py ← +18 endpoints (sources, media, assignments, student-side) +``` + +Existing models updated: + +* `encoach.course.plan` gains `source_ids`, `media_ids`, `assignment_ids`, `to_api_dict(include_media=...)`. +* `encoach.ai.tool.category` selection extends with `('media', 'Media generation')` so the new tools register cleanly. +* `encoach.embedding` queries gain a `plan_id` filter — `resources.search` is now plan-scoped when passed `plan_id`, so the agent sees only its own corpus. + +New tools (registered in `encoach_ai/data/agents_defaults.xml`): + +| Tool key | Provider | Notes | +|---|---|---| +| `media.synthesize_audio` | AWS Polly (default) / ElevenLabs | Used for `listening_script` + `speaking_prompt` | +| `media.generate_image` | OpenAI DALL-E 3 | Capped per plan via `encoach_ai_course.image_budget_per_plan` (default 60) | +| `media.compose_video` | local `ffmpeg` | Auto-creates audio + image first if missing | + +New agent: **`course_media_director`** — orchestrates per-week media batches; reuses `course_week_materials`'s tools plus the three media tools above. The base `course_week_materials` agent now also has `media.synthesize_audio` + `media.generate_image` in its tool set so a single LangGraph turn can emit the text material *and* its narration/illustration. + +### 24.3 New API endpoints (all JWT-protected unless noted) + +```text +# Sources (Phase A) +POST /api/ai/course-plan//sources create URL or text source +POST /api/ai/course-plan//sources/upload multipart file upload +GET /api/ai/course-plan//sources list sources for plan +POST /api/ai/course-plan/sources//reindex re-extract + re-embed +DELETE /api/ai/course-plan/sources/ cascade-deletes embeddings + +# Deliverables (Phase B) +GET /api/ai/course-plan//deliverables {summary, weeks: [...]} + +# Media (Phase C) +GET /api/ai/course-plan/material//media list media for material +POST /api/ai/course-plan/material//media/audio generate audio +POST /api/ai/course-plan/material//media/image generate image +POST /api/ai/course-plan/material//media/video compose video +DELETE /api/ai/course-plan/media/ delete media + attachment +POST /api/ai/course-plan//week//media bulk-generate week (audio+image) + +# Assignments (Phase D) +GET /api/ai/course-plan//assignments +POST /api/ai/course-plan//assignments +DELETE /api/ai/course-plan/assignments/ + +# Student-side (Phase E) +GET /api/student/course-plans list assigned plans +GET /api/student/course-plans/ read-only drilldown w/ media URLs +``` + +### 24.4 New / updated frontend surfaces + +* **Wizard** (`pages/admin/wizards/CoursePlanWizard.tsx`): now 6 steps — Brief → Resources (source builder) → Deliverables preview → Multimedia toggles → Review → Generate. Sources are queued client-side and uploaded post-creation against the new plan id. +* **Detail page** (`pages/admin/AdminCoursePlanDetail.tsx`): now shows `DeliverablesStrip`, `SourcesCard`, `AssignmentsCard` (with `AssignDialog`), and per-material `MediaDrawer` (audio/image/video preview, generate, download, delete). Plus a "Generate week media" bulk action per week. +* **Student** (`pages/student/StudentCoursePlans.tsx`, `StudentCoursePlanDetail.tsx`): list + read-only drilldown, `