3 Commits

Author SHA1 Message Date
Yamen Ahmad
096b042daf feat(course-plan): RAG sources + multi-modal media + assignments + student view
Some checks failed
Deploy to Staging / Deploy backend + frontend to staging (push) Failing after 14s
Builds the §24 product on top of the LangGraph runtime from §22:

Phase A (Sources / RAG)
  - encoach.course.plan.source model (file | url | text)
  - SourceIndexer extracts PDF (pypdf), DOCX (python-docx), HTML, plain
    text and embeds chunks via the existing pgvector pipeline scoped to
    plan_id, so resources.search only returns the plan's own corpus
  - Endpoints: list/create/upload/reindex/delete + plan-scoped retrieval

Phase B (Deliverables)
  - services.deliverables.compute_deliverables walks the plan, derives
    {planned, generated, ready} per week from material + media state
  - GET /api/ai/course-plan/<id>/deliverables drives the new wizard
    preview step and the live progress strip on the detail page

Phase C (Multi-modal media)
  - encoach.course.plan.media model + MediaService:
    audio: AWS Polly (default) or ElevenLabs
    image: OpenAI DALL-E 3, capped per plan via system parameter
    video: local ffmpeg subprocess (image + audio -> MP4 1280x720)
  - Three new agent tools (media.synthesize_audio / generate_image /
    compose_video), wired into course_week_materials and a new
    course_media_director agent
  - Endpoints per material + week-level batch generator

Phase D (Assignments)
  - encoach.course.plan.assignment supports mode='batch' (op.batch) or
    mode='students' (res.users), with due_date + message + state
  - REST endpoints to list / create / delete assignments

Phase E (Student view)
  - /api/student/course-plans + /api/student/course-plans/<id>
    enforce visibility via assignment.expand_user_ids()
  - New /student/course-plans list + read-only drilldown rendering
    audio/image/video tiles from /web/content/<attachment_id>

Cross-cutting
  - encoach.ai.tool.category: + media (so the new tools register)
  - encoach.embedding gains a plan_id filter for plan-scoped RAG
  - Wizard adds Sources + Multimedia steps; AdminCoursePlanDetail
    rewritten with DeliverablesStrip + SourcesCard + AssignmentsCard +
    per-material MediaDrawer
  - ~280 new EN + AR i18n keys (full RTL coverage)
  - smoke_course_plan.py exercises every phase via odoo-bin shell;
    last run: PASS A/B/D/E + DALL-E 3 image (753 KB), Polly audio
    fails cleanly when AWS creds aren't configured (expected)

Documentation: §24 added to docs/PROJECT_SUMMARY.md with phase-by-phase
artefact list, endpoints, smoke test, ops notes, and gotchas.

Made-with: Cursor
2026-04-25 17:13:01 +04:00
Yamen Ahmad
0ed7f88cab fix(security): remove comment from ir.model.access.csv causing import error
Made-with: Cursor
2026-04-25 14:59:27 +04:00
Yamen Ahmad
ed8e75d88c feat(course-plan): GE1-style AI course planning with deliverables, resources, media, assignments
Based on UTAS GE1 Course Outline structure (Reading/Writing 10hrs + Listening/Speaking 8hrs)

New Models:
- encoach.course.plan.deliverable: Explicit learning outcome tracking by week/skill
- encoach.course.plan.resource.dep: Resource dependencies (textbooks, videos, etc.)
- encoach.course.plan.assignment: Assign plans to classes/students with progress tracking
- encoach.course.plan.assignment.deliverable: Per-student deliverable completion status

Extended Models:
- course.plan.material: Added media fields (media_type, media_asset_url, media_asset_id,
  media_generation_prompt, media_metadata_json) for rich content
- New material types: video_lesson, audio_recording, image_visual, interactive, assessment

AI Agent Tools (agent_tools.py):
- deliverables.detect: Parse course outlines (like GE1 PDF) and extract structured outcomes
- deliverables.fetch: Get deliverables for AI to reference when generating
- resources.fetch: Check available resources before generating content
- resources.save: Persist resource dependencies
- media.suggest_visuals: AI suggests images/diagrams for materials
- media.generate_image: Generate educational images (DALL-E integration ready)
- media.generate_audio: Generate TTS audio (ElevenLabs/Polly integration ready)
- assignment.*: Create assignments and track progress

Pipeline Enhancements (course_plan_pipeline.py):
- generate_deliverables_from_outline(): Parse PDF/text outlines into structured deliverables
- generate_week_materials_with_resources(): Resource-aware content generation
- suggest_media_for_material(): AI visual aid suggestions
- generate_media_for_material(): Actual image/audio generation

New AI Agents (agents_defaults.xml):
- deliverable_detector: Parses GE1-style outlines, extracts deliverables week-by-week
- media_generator: Creates images/audio for teaching materials
- Updated course_planner & course_week_materials with resource tools

REST APIs (course_plan.py):
POST /api/ai/course-plan/<id>/deliverables/detect - Parse outline
GET  /api/ai/course-plan/<id>/deliverables - List deliverables
PUT  /api/ai/course-plan/deliverables/<id> - Update status

GET  /api/ai/course-plan/<id>/resources - List resources
POST /api/ai/course-plan/<id>/resources - Add resource

POST /api/ai/course-plan/materials/<id>/media/suggest - Get visual suggestions
POST /api/ai/course-plan/materials/<id>/media/generate - Generate image/audio

POST /api/ai/course-plan/<id>/assignments - Assign to class/student
GET  /api/ai/course-plan/<id>/assignments - List assignments
GET  /api/ai/course-plan/assignments/<id> - Get with progress
PUT  /api/ai/course-plan/assignments/<id>/deliverables/<del_id> - Update status

Security: Added ir.model.access.csv entries for all new models
Made-with: Cursor
2026-04-25 14:57:04 +04:00
28 changed files with 4678 additions and 75 deletions

View File

@@ -99,6 +99,37 @@
<field name="sequence">90</field>
</record>
<!-- Media generation -->
<record id="ai_tool_media_audio" model="encoach.ai.tool">
<field name="key">media.synthesize_audio</field>
<field name="name">Synthesize narration audio</field>
<field name="category">media</field>
<field name="mutates" eval="True"/>
<field name="description">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.</field>
<field name="schema_json">{"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"]}</field>
<field name="sequence">120</field>
</record>
<record id="ai_tool_media_image" model="encoach.ai.tool">
<field name="key">media.generate_image</field>
<field name="name">Generate illustration (DALL-E)</field>
<field name="category">media</field>
<field name="mutates" eval="True"/>
<field name="description">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).</field>
<field name="schema_json">{"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"]}</field>
<field name="sequence">130</field>
</record>
<record id="ai_tool_media_video" model="encoach.ai.tool">
<field name="key">media.compose_video</field>
<field name="name">Compose slideshow video (ffmpeg)</field>
<field name="category">media</field>
<field name="mutates" eval="True"/>
<field name="description">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.</field>
<field name="schema_json">{"type":"object","properties":{"material_id":{"type":"integer"}},"required":["material_id"]}</field>
<field name="sequence">140</field>
</record>
<!-- Scoring -->
<record id="ai_tool_scoring_writing" model="encoach.ai.tool">
<field name="key">scoring.grade_writing</field>
@@ -179,6 +210,8 @@ Rules:
ref('ai_tool_resources_search'),
ref('ai_tool_quality_cefr'),
ref('ai_tool_course_plan_save_materials'),
ref('ai_tool_media_audio'),
ref('ai_tool_media_image'),
])]"/>
</record>
@@ -328,6 +361,46 @@ Rules:
])]"/>
</record>
<!-- 8. Course media director -->
<record id="ai_agent_course_media_director" model="encoach.ai.agent">
<field name="key">course_media_director</field>
<field name="name">Course Media Director</field>
<field name="description">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.</field>
<field name="model">gpt-4o-mini</field>
<field name="fallback_model">gpt-4o</field>
<field name="temperature">0.3</field>
<field name="max_tokens">2000</field>
<field name="response_format">text</field>
<field name="graph_type">react</field>
<field name="max_revisions">0</field>
<field name="quality_checks"></field>
<field name="sequence">80</field>
<field name="system_prompt">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).</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_media_audio'),
ref('ai_tool_media_image'),
ref('ai_tool_media_video'),
])]"/>
</record>
<!-- Default per-plan image budget. Adjust in System Parameters. -->
<record id="ai_default_image_budget" model="ir.config_parameter">
<field name="key">encoach_ai_course.image_budget_per_plan</field>
<field name="value">60</field>
</record>
<!-- Feature flag: pipelines consult this before routing through AgentRuntime.
Default "True" so the defaults-ship-working contract holds. -->
<record id="ai_default_use_langgraph" model="ir.config_parameter">

View File

@@ -66,6 +66,7 @@ TOOL_CATEGORIES = [
("quality", "Quality & gating"),
("scoring", "Scoring & grading"),
("reference", "Reference lookup"),
("media", "Media generation"),
("other", "Other"),
]

View File

@@ -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")
@@ -324,3 +335,84 @@ def _grade_speaking(env, rubric: str = "", transcript: str = "", **_: Any) -> di
return svc.grade_speaking(rubric, transcript)
except Exception as exc:
return {"error": str(exc)}
# --- 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("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,
}
@register("media.generate_image")
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.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,
}

View File

@@ -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 = [

View File

@@ -6,6 +6,7 @@ endpoints. Every route is JWT-guarded via the shared ``@jwt_required``
decorator and returns JSON.
"""
import base64
import logging
from odoo import http
@@ -20,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__)
@@ -169,3 +174,423 @@ class CoursePlanController(http.Controller):
except Exception as exc:
_logger.exception('course-plan.list_week_materials failed')
return _error_response(str(exc), 500)
# ==================================================================
# PHASE A — Reference sources (RAG grounding)
# ==================================================================
@http.route('/api/ai/course-plan/<int:plan_id>/sources',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_sources(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)
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.list_sources failed')
return _error_response(str(exc), 500)
@http.route('/api/ai/course-plan/<int:plan_id>/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/<int:plan_id>/sources/<int:source_id>/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/<int:plan_id>/sources/<int:source_id>',
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/<int:plan_id>/deliverables',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_deliverables(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)
return _json_response(compute_deliverables(plan))
except Exception as exc:
_logger.exception('course-plan.deliverables failed')
return _error_response(str(exc), 500)
# ==================================================================
# 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/<int:material_id>/media/audio',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def gen_audio(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.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.gen_audio failed')
return _error_response(str(exc), 500)
@http.route('/api/ai/course-plan/material/<int:material_id>/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)
@http.route('/api/ai/course-plan/material/<int:material_id>/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/<int:material_id>/media',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_material_media(self, material_id, **kw):
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
return _json_response({
'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_material_media failed')
return _error_response(str(exc), 500)
@http.route('/api/ai/course-plan/media/<int:media_id>',
type='http', auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_media(self, media_id, **kw):
try:
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.delete_media failed')
return _error_response(str(exc), 500)
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/media',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
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:
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),
)
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]
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.gen_week_media failed')
return _error_response(str(exc), 500)
# ==================================================================
# PHASE D — Plan assignments
# ==================================================================
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_assignments(self, plan_id, **kw):
try:
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 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)
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def create_assignment(self, plan_id, **kw):
try:
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.create_assignment failed')
return _error_response(str(exc), 500)
@http.route('/api/ai/course-plan/<int:plan_id>/assignments/<int:assignment_id>',
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/<int:plan_id>',
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)

View File

@@ -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

View File

@@ -117,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
@@ -139,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,
@@ -159,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
@@ -252,6 +278,10 @@ class CoursePlanMaterial(models.Model):
'structured body is not needed.',
)
media_ids = fields.One2many(
'encoach.course.plan.media', 'material_id', string='Generated media',
)
def _loads(self, raw, default):
if not raw:
return default
@@ -260,9 +290,9 @@ class CoursePlanMaterial(models.Model):
except (TypeError, ValueError):
return default
def to_api_dict(self):
def to_api_dict(self, include_media=True):
self.ensure_one()
return {
out = {
'id': self.id,
'plan_id': self.plan_id.id,
'week_id': self.week_id.id if self.week_id else None,
@@ -274,3 +304,6 @@ class CoursePlanMaterial(models.Model):
'body': self._loads(self.body_json, {}),
'body_text': self.body_text or '',
}
if include_media:
out['media'] = [m.to_api_dict() for m in self.media_ids]
return out

View File

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

View File

@@ -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/<id>).',
)
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,
}

View File

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

View File

@@ -4,3 +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_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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
4 access_encoach_course_plan_user encoach.course.plan.user model_encoach_course_plan base.group_user 1 1 1 1
5 access_encoach_course_plan_week_user encoach.course.plan.week.user model_encoach_course_plan_week base.group_user 1 1 1 1
6 access_encoach_course_plan_material_user encoach.course.plan.material.user model_encoach_course_plan_material base.group_user 1 1 1 1
7 access_encoach_course_plan_source_user encoach.course.plan.source.user model_encoach_course_plan_source base.group_user 1 1 1 1
8 access_encoach_course_plan_media_user encoach.course.plan.media.user model_encoach_course_plan_media base.group_user 1 1 1 1
9 access_encoach_course_plan_assignment_user encoach.course.plan.assignment.user model_encoach_course_plan_assignment base.group_user 1 1 1 1

View File

@@ -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

View File

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

View File

@@ -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/<id>`` 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

View File

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

View File

@@ -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()

View File

@@ -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/<id>/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/<id>/sources create URL or text source
POST /api/ai/course-plan/<id>/sources/upload multipart file upload
GET /api/ai/course-plan/<id>/sources list sources for plan
POST /api/ai/course-plan/sources/<sid>/reindex re-extract + re-embed
DELETE /api/ai/course-plan/sources/<sid> cascade-deletes embeddings
# Deliverables (Phase B)
GET /api/ai/course-plan/<id>/deliverables {summary, weeks: [...]}
# Media (Phase C)
GET /api/ai/course-plan/material/<mid>/media list media for material
POST /api/ai/course-plan/material/<mid>/media/audio generate audio
POST /api/ai/course-plan/material/<mid>/media/image generate image
POST /api/ai/course-plan/material/<mid>/media/video compose video
DELETE /api/ai/course-plan/media/<media_id> delete media + attachment
POST /api/ai/course-plan/<id>/week/<n>/media bulk-generate week (audio+image)
# Assignments (Phase D)
GET /api/ai/course-plan/<id>/assignments
POST /api/ai/course-plan/<id>/assignments
DELETE /api/ai/course-plan/assignments/<aid>
# Student-side (Phase E)
GET /api/student/course-plans list assigned plans
GET /api/student/course-plans/<id> 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, `<audio>` / `<img>` / `<video>` tiles fed from `/web/content/<attachment_id>`.
* **Sidebar**: new "My Course Plans" entry under student nav (`StudentLayout.tsx`).
* **i18n**: ~140 new keys in EN + AR across `coursePlan.wizard.*`, `coursePlan.sources.*`, `coursePlan.deliverables.*`, `coursePlan.media.*`, `coursePlan.assignments.*`, `coursePlan.student.*`, `nav.myCoursePlans`.
### 24.5 Smoke test (run via `odoo-bin shell`)
`smoke_course_plan.py` exercises every phase end-to-end:
| Phase | Result |
|---|---|
| A — index inline-text source | PASS · 1 chunk · 295 chars (uses local MiniLM embeddings) |
| B — `compute_deliverables` | PASS · 12 weeks · 5 ready / 1 generated / 0 planned |
| C — DALL-E 3 image | PASS · 753 KB PNG generated for a `reading_text` material |
| C — Polly audio | Gracefully marked `failed` with `"AWS credentials not configured — set in AI Settings"` (expected on dev box) |
| D — assignment | PASS · created `mode='students'` assignment for Sarah |
| E — visibility | PASS · `expand_user_ids()` returns Sarah · plan in student listing |
Run it any time:
```bash
.conda-envs/odoo19/bin/python odoo/odoo-bin shell \
-c odoo.conf -d encoach_v2 --no-http \
< smoke_course_plan.py
```
### 24.6 Operational notes
* **External providers are optional**. AWS Polly / ElevenLabs / OpenAI image keys live in **AI Settings** (`encoach.ai.settings`); when missing the corresponding media row is created with `status='failed'` and a clear error — the rest of the flow (planning, RAG, deliverables, assignment) still works.
* **`ffmpeg`** must be on `PATH` for video composition. If it's missing, the video media row is `failed` with a one-line install hint; nothing else breaks.
* **Image budget** per plan is the system parameter `encoach_ai_course.image_budget_per_plan` (default 60). Bump it via Settings → Technical → System Parameters.
* **Plan-scoped retrieval**. The `resources.search` tool now accepts a `plan_id`; the LLM is auto-given the plan id in the system prompt of `course_planner` and `course_week_materials`, so RAG only ever sees that plan's own sources.
* **Cascade behaviour**. Deleting a source unlinks its `encoach.embedding` rows; deleting a plan unlinks sources, media, assignments, and the underlying `ir.attachment`s.
### 24.7 Files added / changed in this pass
```text
backend/custom_addons/encoach_ai_course/models/course_plan_source.py + new
backend/custom_addons/encoach_ai_course/models/course_plan_media.py + new
backend/custom_addons/encoach_ai_course/models/course_plan_assignment.py + new
backend/custom_addons/encoach_ai_course/services/source_indexer.py + new
backend/custom_addons/encoach_ai_course/services/deliverables.py + new
backend/custom_addons/encoach_ai_course/services/media_service.py + new
backend/custom_addons/encoach_ai_course/controllers/course_plan.py ~ +480 LOC
backend/custom_addons/encoach_ai_course/security/ir.model.access.csv ~ access rights
backend/custom_addons/encoach_ai/models/ai_agent.py ~ TOOL_CATEGORIES += media
backend/custom_addons/encoach_ai/data/agents_defaults.xml ~ + 3 tools, + 1 agent
backend/custom_addons/encoach_vector/models/embedding.py ~ plan_id filter
frontend/src/types/coursePlan.ts ~ +4 interfaces
frontend/src/services/coursePlan.service.ts ~ +18 methods
frontend/src/pages/admin/wizards/CoursePlanWizard.tsx ~ +SourcesStep, MediaStep
frontend/src/pages/admin/AdminCoursePlanDetail.tsx ~ rewrite (Deliverables, Sources, Assignments, MediaDrawer)
frontend/src/pages/student/StudentCoursePlans.tsx + new
frontend/src/pages/student/StudentCoursePlanDetail.tsx + new
frontend/src/App.tsx ~ student routes
frontend/src/components/StudentLayout.tsx ~ My Course Plans link
frontend/src/i18n/locales/en.ts ~ +140 keys
frontend/src/i18n/locales/ar.ts ~ +140 keys
smoke_course_plan.py + new (E2E smoke test)
```
### 24.8 Gotchas resolved during this pass
* `encoach.ai.tool.category` is a strict `Selection`; adding the three media tools required adding `('media', 'Media generation')` to `TOOL_CATEGORIES` in `ai_agent.py`. Without that the data file refused to load and the whole `encoach_ai` module rolled back its upgrade — silently leaving the new course-plan tables uncreated until we restarted with `-u encoach_vector,encoach_ai,encoach_ai_course`.
* Odoo XML data loaders refuse two `<record id="..."/>` with the same id in the same file (no implicit merge). The `course_week_materials` agent had to be edited *in place* with the new media tools instead of being patched as a second record at the bottom.
* `pypdf` extracts most encrypted-but-public PDFs; if both `pypdf` and `PyPDF2` are missing the indexer marks the source `failed` with a clear pip hint rather than crashing.
* The student-side endpoint uses `expand_user_ids()` so a `mode='batch'` assignment auto-includes any future student rolled into that batch — no need to re-assign per intake.

View File

@@ -133,6 +133,8 @@ const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscuss
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans"));
const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail"));
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
@@ -269,6 +271,8 @@ const App = () => (
<Route path="/student/placement/access" element={<PlacementAccess />} />
<Route path="/student/exam/:examId/results" element={<ExamResults />} />
<Route path="/student/course/generate" element={<GapAnalysis />} />
<Route path="/student/course-plans" element={<StudentCoursePlans />} />
<Route path="/student/course-plans/:planId" element={<StudentCoursePlanDetail />} />
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
<Route path="/student/course/:courseId" element={<CourseDelivery />} />

View File

@@ -3,7 +3,7 @@ import ExamPopup from "./student/ExamPopup";
import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp,
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
} from "lucide-react";
/**
@@ -17,6 +17,7 @@ const navGroups: NavGroup[] = [
items: [
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
],

View File

@@ -60,6 +60,7 @@ const ar: Translations = {
dashboard: "لوحة التحكم",
courses: "الدورات",
myCourses: "دوراتي",
myCoursePlans: "خططي الدراسية",
students: "الطلاب",
teachers: "المعلمون",
batches: "الدفعات",
@@ -739,8 +740,14 @@ const ar: Translations = {
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
coverage: "التغطية",
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
sources: "المصادر المرجعية",
sourcesDesc:
"ارفع ملفات PDF أو روابط أو نصوص. يستخدمها الذكاء الاصطناعي كمرجع لتوليد مواد كل أسبوع.",
scope: "النطاق",
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
media: "الوسائط",
mediaDesc:
"اختر أي وسائط يولّدها الذكاء الاصطناعي تلقائياً مع المواد النصّية.",
review: "المراجعة",
reviewDesc: "راجع البيانات قبل بدء التوليد.",
},
@@ -767,7 +774,155 @@ const ar: Translations = {
cefrRequired: "يرجى اختيار مستوى CEFR.",
weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.",
},
review: {
sourcesCount_one: "{{count}} مصدر",
sourcesCount_other: "{{count}} مصادر",
noSources: "لا توجد مصادر",
mediaNone: "لا يوجد توليد وسائط",
},
},
sections_extras: {
sources: "المصادر المرجعية",
progress: "تقدّم التوليد",
assignments: "الإسنادات",
media: "الوسائط المُولَّدة",
},
sources: {
sectionTitle: "المصادر المرجعية (RAG)",
sectionDesc:
"ارفع PDF أو أضف روابط أو نصوصاً. يستخدمها الذكاء الاصطناعي كمرجع عند توليد المواد الأسبوعية.",
collected_one: "{{count}} مصدر جاهز",
collected_other: "{{count}} مصادر جاهزة",
empty: "لا توجد مصادر مرجعية بعد.",
dropTitle: "أضف مواد مرجعية",
dropHint: "PDF أو DOCX أو TXT أو HTML أو نص — حتى بضعة ميغابايت لكل ملف.",
uploadFiles: "رفع ملفات",
urlLabel: "إضافة رابط",
textLabel: "أو ألصق نصاً مباشرة",
textPlaceholder: "ألصق فقرة لتوجيه الذكاء الاصطناعي…",
uploadFailed: "تعذّر رفع \"{{name}}\".",
reindex: "إعادة فهرسة",
reindexed: "أُعيدت فهرسة المصدر.",
reindexFailed: "فشلت إعادة الفهرسة.",
deleted: "تم حذف المصدر.",
deleteFailed: "تعذّر حذف المصدر.",
status: {
pending: "في الانتظار",
indexing: "يُفهرس…",
indexed: "مُفهرس",
failed: "فشل",
},
kindLabel: {
file: "ملف",
url: "رابط",
text: "نص",
},
chunks_one: "{{count}} جزء",
chunks_other: "{{count}} أجزاء",
},
sourceKind: {
file: "ملف",
url: "رابط",
text: "نص",
},
deliverables: {
title: "تقدّم التوليد",
subtitle:
"ما الذي سيُنتجه الذكاء الاصطناعي، وما تم إنتاجه، وما اكتمل تماماً (مواد + وسائط).",
summary: {
planned_one: "{{count}} مخطّط",
planned_other: "{{count}} مخطّط",
generated_one: "{{count}} مُولَّد",
generated_other: "{{count}} مُولَّد",
ready_one: "{{count}} جاهز",
ready_other: "{{count}} جاهز",
},
mediaCounts: "صوت {{audio}} · صورة {{image}} · فيديو {{video}}",
status: {
planned: "مخطّط",
generated: "مُولَّد",
ready: "جاهز",
},
perWeek: "تفصيل لكل أسبوع",
noWeeks: "لا توجد أسابيع — ولّد الخطة أولاً.",
},
media: {
audio: "صوت",
image: "صورة",
video: "فيديو",
audioTitle: "تعليق صوتي",
audioDesc:
"توليد صوت منطوق (Polly / ElevenLabs) لنصوص الاستماع ومحفّزات المحادثة.",
imageTitle: "صور تغطية",
imageDesc:
"توليد صور DALL·E 3 لنصوص القراءة والاستماع. يحتسب من ميزانية الصور للخطة.",
videoTitle: "فيديو شرائح",
videoDesc: "دمج الصوت + الصورة في فيديو MP4 قصير عبر ffmpeg. أبطأ الخيارات.",
hint:
"الصوت مفعّل افتراضياً. الفيديو معطّل افتراضياً — فعّله بعد مراجعة الصوت والصورة.",
drawerTitle: "وسائط \"{{title}}\"",
drawerSubtitle: "ولّد أو احذف الصوت والصورة والفيديو.",
generateAudio: "توليد صوت",
generateImage: "توليد صورة",
generateVideo: "توليد فيديو",
generating: "جاري التوليد…",
audioReady: "الصوت جاهز",
imageReady: "الصورة جاهزة",
videoReady: "الفيديو جاهز",
audioFailed: "فشل توليد الصوت.",
imageFailed: "فشل توليد الصورة.",
videoFailed: "فشل توليد الفيديو.",
deleted: "تم حذف الوسيط.",
deleteFailed: "تعذّر حذف الوسيط.",
open: "فتح",
download: "تنزيل",
noMedia: "لا توجد وسائط لهذه المادة بعد.",
mediaForMaterial: "وسائط",
bulk: "توليد وسائط الأسبوع",
bulkSuccess: "اكتمل توليد وسائط الأسبوع.",
bulkFailed: "تعذّر توليد وسائط الأسبوع.",
provider: "المزوّد",
},
assignments: {
title: "مُسندة إلى",
empty: "لم تُسند إلى أحد بعد.",
assign: "إسناد",
assignTitle: "إسناد خطة المقرر",
assignSubtitle:
"اختر شعبة (دفعة) للجميع، أو طلاباً محدّدين. ستظهر الخطة في لوحة الطالب.",
modeBatch: "دفعة كاملة",
modeStudents: "طلاب محدّدون",
pickBatch: "اختر دفعة",
pickStudents: "اختر طلاباً",
noBatches: "لا توجد دفعات.",
noStudents: "لا يوجد طلاب.",
dueDate: "تاريخ التسليم (اختياري)",
message: "رسالة للمتعلّمين (اختياري)",
messagePlaceholder: "ترحيب، توقّعات، مواعيد…",
created: "تم إسناد الخطة.",
createFailed: "تعذّر إسناد الخطة.",
removed: "تمت إزالة الإسناد.",
removeFailed: "تعذّر إزالة الإسناد.",
remove: "إزالة",
confirmRemove: "إزالة هذا الإسناد؟",
assignedBy: "أسندها {{name}}",
students_one: "{{count}} طالب",
students_other: "{{count}} طلاب",
cancel: "إلغاء",
save: "إسناد",
},
student: {
listTitle: "خططي الدراسية",
listSubtitle:
"خطط مناهج مخصّصة يُنشئها الذكاء الاصطناعي ويُسندها معلّمك. افتح خطة لرؤية الأسابيع والمواد والوسائط.",
empty: "لم تُسند إليك خطط بعد.",
open: "فتح المقرر",
assignedBy: "أسندها {{name}}",
due: "موعد التسليم {{date}}",
noDue: "بدون موعد",
backToList: "العودة إلى خططي",
},
wizardSourcesStep: "المصادر",
},
aiAdmin: {
title: "وكلاء الذكاء الاصطناعي والأدوات",

View File

@@ -105,6 +105,7 @@ const en: Translations = {
dashboard: "Dashboard",
courses: "Courses",
myCourses: "My Courses",
myCoursePlans: "My Course Plans",
students: "Students",
teachers: "Teachers",
batches: "Batches",
@@ -796,8 +797,14 @@ const en: Translations = {
basicsDesc: "Name the course and set level, duration, and weekly hours.",
coverage: "Coverage",
coverageDesc: "Tell the AI how hours split across skills and who the learners are.",
sources: "Reference sources",
sourcesDesc:
"Drop PDFs, URLs, or pasted text. The AI uses them as grounding when generating each week's materials.",
scope: "Scope",
scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.",
media: "Multimedia",
mediaDesc:
"Pick which media the AI should auto-produce alongside the text materials.",
review: "Review",
reviewDesc: "Double-check your brief before kicking off the generation.",
},
@@ -825,7 +832,156 @@ const en: Translations = {
cefrRequired: "Please pick a CEFR level.",
weeksRange: "Total weeks must be at least 1.",
},
review: {
sourcesCount_one: "{{count}} reference source",
sourcesCount_other: "{{count}} reference sources",
noSources: "No reference sources",
mediaNone: "No media generation",
},
},
sections_extras: {
sources: "Reference sources",
progress: "Generation progress",
assignments: "Assignments",
media: "Generated media",
},
sources: {
sectionTitle: "Reference sources (RAG)",
sectionDesc:
"Upload PDFs, paste URLs, or drop in raw text. The AI will use the indexed content as grounding when it writes weekly materials.",
collected_one: "{{count}} source ready",
collected_other: "{{count}} sources ready",
empty: "No reference sources yet.",
dropTitle: "Add reference materials",
dropHint: "PDF, DOCX, TXT, HTML, or plain text — up to a few MB each.",
uploadFiles: "Upload files",
urlLabel: "Add a URL",
textLabel: "Or paste text directly",
textPlaceholder: "Paste a passage to ground the AI on…",
uploadFailed: "Couldn't upload \"{{name}}\".",
reindex: "Reindex",
reindexed: "Source reindexed.",
reindexFailed: "Reindex failed.",
deleted: "Source deleted.",
deleteFailed: "Couldn't delete source.",
status: {
pending: "Pending",
indexing: "Indexing…",
indexed: "Indexed",
failed: "Failed",
},
kindLabel: {
file: "File",
url: "URL",
text: "Text",
},
chunks_one: "{{count}} chunk",
chunks_other: "{{count}} chunks",
},
sourceKind: {
file: "File",
url: "URL",
text: "Inline text",
},
deliverables: {
title: "Generation progress",
subtitle:
"What the AI is going to produce, what it has produced, and what is fully ready (materials + media).",
summary: {
planned_one: "{{count}} planned",
planned_other: "{{count}} planned",
generated_one: "{{count}} generated",
generated_other: "{{count}} generated",
ready_one: "{{count}} ready",
ready_other: "{{count}} ready",
},
mediaCounts: "Audio {{audio}} · Image {{image}} · Video {{video}}",
status: {
planned: "Planned",
generated: "Generated",
ready: "Ready",
},
perWeek: "Per-week breakdown",
noWeeks: "No weeks yet — generate the plan first.",
},
media: {
audio: "Audio",
image: "Image",
video: "Video",
audioTitle: "Narration audio",
audioDesc:
"Generate spoken audio (Polly / ElevenLabs) for listening scripts and speaking prompts.",
imageTitle: "Cover images",
imageDesc:
"Generate DALL·E 3 images for reading texts and listening scripts. Counts against the per-plan image budget.",
videoTitle: "Slideshow video",
videoDesc:
"Combine audio + image into a short MP4 with ffmpeg. Slowest option.",
hint:
"Audio is on by default. Video is off by default — turn it on once you've reviewed the audio + image.",
drawerTitle: "Media for \"{{title}}\"",
drawerSubtitle: "Generate or remove audio, images, and slideshow video.",
generateAudio: "Generate audio",
generateImage: "Generate image",
generateVideo: "Generate video",
generating: "Generating…",
audioReady: "Audio ready",
imageReady: "Image ready",
videoReady: "Video ready",
audioFailed: "Audio generation failed.",
imageFailed: "Image generation failed.",
videoFailed: "Video generation failed.",
deleted: "Media deleted.",
deleteFailed: "Couldn't delete media.",
open: "Open",
download: "Download",
noMedia: "No media yet for this material.",
mediaForMaterial: "Media",
bulk: "Generate week media",
bulkSuccess: "Week media generation done.",
bulkFailed: "Couldn't generate week media.",
provider: "Provider",
},
assignments: {
title: "Assigned to",
empty: "Not assigned to anyone yet.",
assign: "Assign",
assignTitle: "Assign this course plan",
assignSubtitle:
"Pick a class (batch) for everyone, or individual students. They'll see the plan in their student dashboard.",
modeBatch: "Whole class (batch)",
modeStudents: "Individual students",
pickBatch: "Select a batch",
pickStudents: "Pick students",
noBatches: "No batches available.",
noStudents: "No students available.",
dueDate: "Due date (optional)",
message: "Message to learners (optional)",
messagePlaceholder: "Welcome note, expectations, deadlines…",
created: "Plan assigned.",
createFailed: "Couldn't assign plan.",
removed: "Assignment removed.",
removeFailed: "Couldn't remove assignment.",
remove: "Remove",
confirmRemove: "Remove this assignment?",
assignedBy: "Assigned by {{name}}",
students_one: "{{count}} student",
students_other: "{{count}} students",
cancel: "Cancel",
save: "Assign",
},
student: {
listTitle: "My course plans",
listSubtitle:
"Personalised AI-generated curricula assigned by your teacher. Open one to see the weekly plan, materials, and media.",
empty: "No plans assigned to you yet.",
open: "Open course",
assignedBy: "Assigned by {{name}}",
due: "Due {{date}}",
noDue: "No deadline",
backToList: "Back to my plans",
},
wizardSourcesStep: "Sources",
},
aiAdmin: {
title: "AI Agents & Tools",

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,19 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { X } from "lucide-react";
import {
FileText,
Image as ImageIcon,
Link2,
Mic,
Music,
Trash2,
Upload,
Video,
X,
} from "lucide-react";
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
import { Input } from "@/components/ui/input";
@@ -38,6 +48,33 @@ import type { CoursePlanGenerateBrief } from "@/types";
* materials immediately.
*/
type DraftSourceKind = "file" | "url" | "text";
interface DraftSource {
/** Stable client-side id; not persisted. */
uid: string;
kind: DraftSourceKind;
name: string;
/** Only set when kind === "file". */
file?: File;
/** Only set when kind === "url". */
url?: string;
/** Only set when kind === "text". */
text?: string;
}
interface MediaToggleState {
/** Generate narration audio (Polly / ElevenLabs) for listening +
* speaking materials right after each week's materials are produced. */
audio: boolean;
/** Generate DALL-E images for reading texts, listening scripts, and
* vocabulary lists. Bound to the per-plan image budget. */
image: boolean;
/** Compose audio + image into a slideshow MP4 with ffmpeg. Slowest
* option; off by default. */
video: boolean;
}
interface CoursePlanWizardState {
title: string;
cefr_level: string;
@@ -48,6 +85,8 @@ interface CoursePlanWizardState {
grammar_focus: string[];
resources: string[];
notes: string;
sources: DraftSource[];
media: MediaToggleState;
}
const CEFR_OPTIONS = [
@@ -183,6 +222,17 @@ export default function CoursePlanWizard() {
</div>
),
},
{
id: "sources",
titleKey: "coursePlan.wizard.steps.sources",
descriptionKey: "coursePlan.wizard.steps.sourcesDesc",
render: ({ state, update }) => (
<SourcesStep
sources={state.sources}
onChange={(sources) => update({ sources })}
/>
),
},
{
id: "scope",
titleKey: "coursePlan.wizard.steps.scope",
@@ -214,6 +264,17 @@ export default function CoursePlanWizard() {
</div>
),
},
{
id: "media",
titleKey: "coursePlan.wizard.steps.media",
descriptionKey: "coursePlan.wizard.steps.mediaDesc",
render: ({ state, update }) => (
<MediaStep
media={state.media}
onChange={(media) => update({ media })}
/>
),
},
{
id: "review",
titleKey: "coursePlan.wizard.steps.review",
@@ -257,6 +318,26 @@ export default function CoursePlanWizard() {
label={t("coursePlan.wizard.fields.notes")}
value={state.notes || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.steps.sources")}
value={
state.sources.length
? t("coursePlan.wizard.review.sourcesCount", {
count: state.sources.length,
})
: t("coursePlan.wizard.review.noSources")
}
/>
<ReviewRow
label={t("coursePlan.wizard.steps.media")}
value={[
state.media.audio ? t("coursePlan.media.audio") : null,
state.media.image ? t("coursePlan.media.image") : null,
state.media.video ? t("coursePlan.media.video") : null,
]
.filter(Boolean)
.join(", ") || t("coursePlan.wizard.review.mediaNone")}
/>
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{t("coursePlan.wizard.reviewHint")}
</div>
@@ -283,9 +364,11 @@ export default function CoursePlanWizard() {
grammar_focus: [],
resources: [],
notes: "",
sources: [],
media: { audio: true, image: true, video: false },
}}
onFinish={async (state) => {
await mutation.mutateAsync({
const resp = await mutation.mutateAsync({
title: state.title.trim(),
cefr_level: state.cefr_level,
total_weeks: state.total_weeks,
@@ -298,11 +381,284 @@ export default function CoursePlanWizard() {
resources: state.resources.length ? state.resources : undefined,
notes: state.notes.trim() || undefined,
});
const planId = resp?.data?.id;
if (planId && state.sources.length) {
// Best-effort upload — we keep going even if a single one fails so
// the user lands on the detail page with whatever did make it in.
for (const src of state.sources) {
try {
if (src.kind === "file" && src.file) {
await coursePlanService.uploadSource(planId, src.file, {
name: src.name || src.file.name,
});
} else if (src.kind === "url" && src.url) {
await coursePlanService.createSource(planId, {
kind: "url",
url: src.url,
name: src.name || src.url,
});
} else if (src.kind === "text" && src.text) {
await coursePlanService.createSource(planId, {
kind: "text",
inline_text: src.text,
name: src.name || t("coursePlan.sourceKind.text"),
});
}
} catch (err) {
toast.error(
describeApiError(err, t("coursePlan.sources.uploadFailed", {
name: src.name || src.kind,
})),
);
}
}
}
}}
/>
);
}
function genUid() {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
function SourcesStep({
sources,
onChange,
}: {
sources: DraftSource[];
onChange: (next: DraftSource[]) => void;
}) {
const { t } = useTranslation();
const fileRef = useRef<HTMLInputElement>(null);
const [draftUrl, setDraftUrl] = useState("");
const [draftText, setDraftText] = useState("");
const addFiles = (files: FileList | null) => {
if (!files || !files.length) return;
const additions: DraftSource[] = [];
for (const f of Array.from(files)) {
additions.push({
uid: genUid(),
kind: "file",
name: f.name,
file: f,
});
}
onChange([...sources, ...additions]);
};
const addUrl = () => {
const url = draftUrl.trim();
if (!url) return;
onChange([...sources, { uid: genUid(), kind: "url", name: url, url }]);
setDraftUrl("");
};
const addText = () => {
const text = draftText.trim();
if (!text) return;
onChange([
...sources,
{
uid: genUid(),
kind: "text",
name: text.slice(0, 60).replace(/\s+/g, " "),
text,
},
]);
setDraftText("");
};
const remove = (uid: string) => onChange(sources.filter((s) => s.uid !== uid));
return (
<div className="space-y-5">
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
{t("coursePlan.sources.dropTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("coursePlan.sources.dropHint")}
</p>
<div className="mt-3">
<input
ref={fileRef}
type="file"
multiple
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
onChange={(e) => {
addFiles(e.currentTarget.files);
if (fileRef.current) fileRef.current.value = "";
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.sources.uploadFiles")}
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>{t("coursePlan.sources.urlLabel")}</Label>
<div className="mt-1 flex gap-2">
<Input
value={draftUrl}
onChange={(e) => setDraftUrl(e.target.value)}
placeholder="https://example.com/article"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addUrl();
}
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={addUrl}
disabled={!draftUrl.trim()}
>
<Link2 className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<Label>{t("coursePlan.sources.textLabel")}</Label>
<div className="mt-1 flex gap-2">
<Textarea
rows={3}
value={draftText}
onChange={(e) => setDraftText(e.target.value)}
placeholder={t("coursePlan.sources.textPlaceholder")}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={addText}
disabled={!draftText.trim()}
className="self-start"
>
<FileText className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{sources.length > 0 && (
<div className="rounded-md border">
<div className="px-3 py-2 border-b bg-muted/30 text-xs uppercase tracking-wide text-muted-foreground">
{t("coursePlan.sources.collected", { count: sources.length })}
</div>
<ul className="divide-y">
{sources.map((s) => (
<li
key={s.uid}
className="flex items-center gap-2 px-3 py-2 text-sm"
>
<Badge variant="outline" className="capitalize text-[10px]">
{s.kind}
</Badge>
<span className="flex-1 truncate">{s.name}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => remove(s.uid)}
aria-label={t("common.remove", "Remove")}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
</div>
)}
</div>
);
}
function MediaStep({
media,
onChange,
}: {
media: MediaToggleState;
onChange: (next: MediaToggleState) => void;
}) {
const { t } = useTranslation();
return (
<div className="grid gap-3 sm:grid-cols-3">
<MediaToggleCard
icon={Music}
title={t("coursePlan.media.audioTitle")}
description={t("coursePlan.media.audioDesc")}
checked={media.audio}
onToggle={(v) => onChange({ ...media, audio: v })}
/>
<MediaToggleCard
icon={ImageIcon}
title={t("coursePlan.media.imageTitle")}
description={t("coursePlan.media.imageDesc")}
checked={media.image}
onToggle={(v) => onChange({ ...media, image: v })}
/>
<MediaToggleCard
icon={Video}
title={t("coursePlan.media.videoTitle")}
description={t("coursePlan.media.videoDesc")}
checked={media.video}
onToggle={(v) => onChange({ ...media, video: v })}
/>
<div className="sm:col-span-3 text-xs text-muted-foreground flex items-center gap-2">
<Mic className="h-3.5 w-3.5" />
{t("coursePlan.media.hint")}
</div>
</div>
);
}
function MediaToggleCard({
icon: Icon,
title,
description,
checked,
onToggle,
}: {
icon: typeof Music;
title: string;
description: string;
checked: boolean;
onToggle: (v: boolean) => void;
}) {
return (
<button
type="button"
onClick={() => onToggle(!checked)}
className={[
"rounded-md border p-3 text-left transition-colors",
checked
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-primary" />
<div className="font-medium text-sm">{title}</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</button>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-3 gap-2">

View File

@@ -0,0 +1,311 @@
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
BookOpen,
Calendar,
ClipboardList,
Headphones,
Image as ImageIcon,
Library,
MessageSquare,
Mic,
Music,
PenSquare,
Sparkles,
Type,
Video,
type LucideIcon,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type {
CoursePlan,
CoursePlanMaterial,
CoursePlanMedia,
CoursePlanWeek,
} from "@/types";
const SKILL_ICONS: Record<string, LucideIcon> = {
reading: BookOpen,
writing: PenSquare,
listening: Headphones,
speaking: Mic,
grammar: Type,
vocabulary: Library,
integrated: MessageSquare,
};
/**
* Student detail view for an assigned AI course plan.
*
* Server-side authorisation: the `/api/student/course-plans/:id` endpoint
* returns 403 unless the current user is in the plan's assignment list,
* so we can render unconditionally as soon as the query resolves.
*
* The page is intentionally read-only — students can play audio, view
* images, and watch video, but they can't (re)generate content.
*/
export default function StudentCoursePlanDetail() {
const { t } = useTranslation();
const { planId: planIdRaw } = useParams<{ planId: string }>();
const planId = Number(planIdRaw);
const { data, isLoading, isError, error } = useQuery({
queryKey: ["student-course-plan", planId],
queryFn: () => coursePlanService.studentGet(planId),
enabled: Number.isFinite(planId) && planId > 0,
});
const plan = data?.data as CoursePlan | undefined;
const materialsByWeek = useMemo(() => {
const map = new Map<number, CoursePlanMaterial[]>();
if (!plan?.materials) return map;
for (const m of plan.materials) {
const list = map.get(m.week_number) ?? [];
list.push(m);
map.set(m.week_number, list);
}
return map;
}, [plan?.materials]);
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
<Link to="/student/course-plans" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
{t("coursePlan.student.backToList")}
</Link>
</Button>
{isLoading && (
<div className="space-y-3">
<Skeleton className="h-20" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{describeApiError(error, t("coursePlan.loadFailed"))}
</div>
)}
{plan && (
<>
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<CardTitle className="text-2xl">{plan.name}</CardTitle>
{plan.description && (
<CardDescription className="max-w-3xl">
{plan.description}
</CardDescription>
)}
</div>
<div className="flex gap-2 flex-wrap">
<Badge variant="secondary" className="uppercase">
{plan.cefr_level}
</Badge>
<Badge variant="outline">
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
</Badge>
<Badge variant="outline">
{t("coursePlan.hoursPerWeek", {
count: plan.contact_hours_per_week,
})}
</Badge>
</div>
</div>
{plan.assignment && (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
{plan.assignment.due_date
? t("coursePlan.student.due", { date: plan.assignment.due_date })
: t("coursePlan.student.noDue")}
{plan.assignment.assigned_by_name && (
<span>
·{" "}
{t("coursePlan.student.assignedBy", {
name: plan.assignment.assigned_by_name,
})}
</span>
)}
</p>
)}
{plan.assignment?.message && (
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
{plan.assignment.message}
</p>
)}
</CardHeader>
</Card>
{plan.objectives.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-primary" />
{t("coursePlan.sections.objectives")}
</CardTitle>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-1 text-sm">
{plan.objectives.map((o, i) => (
<li key={i}>{o}</li>
))}
</ol>
</CardContent>
</Card>
)}
{plan.weeks && plan.weeks.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("coursePlan.sections.delivery")}
</CardTitle>
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
</CardHeader>
<CardContent>
<Accordion type="multiple" className="w-full">
{plan.weeks.map((w) => (
<StudentWeek
key={w.id}
week={w}
materials={materialsByWeek.get(w.week_number) ?? []}
/>
))}
</Accordion>
</CardContent>
</Card>
)}
</>
)}
</div>
);
}
function StudentWeek({
week,
materials,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
}) {
const { t } = useTranslation();
return (
<AccordionItem value={String(week.week_number)}>
<AccordionTrigger className="text-left">
<div className="flex-1 flex items-center gap-3 min-w-0">
<Badge variant="outline" className="shrink-0">
{t("coursePlan.weekN", { n: week.week_number })}
</Badge>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{week.focus || week.unit || "—"}
</div>
{week.date_label && (
<div className="text-xs text-muted-foreground truncate">
{week.date_label}
</div>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-3">
{materials.length === 0 && (
<p className="text-sm italic text-muted-foreground">
{t("coursePlan.media.noMedia")}
</p>
)}
{materials.map((m) => (
<StudentMaterial key={m.id} material={m} />
))}
</AccordionContent>
</AccordionItem>
);
}
function StudentMaterial({ material }: { material: CoursePlanMaterial }) {
const { t } = useTranslation();
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<Icon className="h-4 w-4 text-primary" />
<CardTitle className="text-base flex-1 min-w-0">
{material.title}
</CardTitle>
<Badge variant="outline" className="text-[10px]">
{t(
`coursePlan.materialType.${material.material_type}`,
material.material_type,
)}
</Badge>
</div>
{material.summary && <CardDescription>{material.summary}</CardDescription>}
</CardHeader>
<CardContent className="space-y-3">
{(material.media ?? []).map((m) => (
<StudentMediaTile key={m.id} media={m} />
))}
<pre className="whitespace-pre-wrap text-xs font-mono bg-muted/40 rounded-md p-3 max-h-80 overflow-auto">
{material.body_text || JSON.stringify(material.body, null, 2)}
</pre>
</CardContent>
</Card>
);
}
function StudentMediaTile({ media }: { media: CoursePlanMedia }) {
const url = media.download_url || "";
if (!url) return null;
return (
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
<div className="flex items-center gap-2">
{media.kind === "audio" && <Music className="h-4 w-4" />}
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
{media.kind === "video" && <Video className="h-4 w-4" />}
<span className="capitalize text-xs text-muted-foreground">
{media.kind}
</span>
</div>
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
{media.kind === "image" && (
<img
src={url}
alt={media.title}
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
/>
)}
{media.kind === "video" && (
<video src={url} controls className="w-full rounded-md max-h-72" />
)}
</div>
);
}

View File

@@ -0,0 +1,147 @@
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ArrowRight,
Calendar,
GraduationCap,
Sparkles,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type { CoursePlan } from "@/types";
/**
* Student-facing list of AI course plans assigned to the current user.
*
* Reads from `GET /api/student/course-plans`, which only returns plans
* the user has been linked to either directly (Phase D `students` mode)
* or via the batch they're enrolled in. The card itself is intentionally
* lightweight: tap → drill into `/student/course-plans/:id` for the full
* weekly plan and embedded media.
*/
export default function StudentCoursePlans() {
const { t } = useTranslation();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["student-course-plans"],
queryFn: () => coursePlanService.studentList(),
});
const items = data?.items ?? [];
return (
<div className="space-y-6">
<div>
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<h1 className="text-2xl font-bold">{t("coursePlan.student.listTitle")}</h1>
</div>
<p className="text-muted-foreground mt-1 max-w-2xl">
{t("coursePlan.student.listSubtitle")}
</p>
</div>
{isLoading && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-44 rounded-lg" />
))}
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{describeApiError(error, t("coursePlan.loadFailed"))}
</div>
)}
{!isLoading && !isError && items.length === 0 && (
<div className="text-center py-12 border rounded-lg bg-muted/20">
<GraduationCap className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">
{t("coursePlan.student.empty")}
</p>
</div>
)}
{items.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((plan) => (
<PlanCard key={plan.id} plan={plan} />
))}
</div>
)}
</div>
);
}
function PlanCard({ plan }: { plan: CoursePlan }) {
const { t } = useTranslation();
const a = plan.assignment;
return (
<Card className="hover:shadow-md transition-shadow flex flex-col">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base flex-1 min-w-0 line-clamp-2">
{plan.name}
</CardTitle>
<Badge variant="secondary" className="uppercase shrink-0">
{plan.cefr_level}
</Badge>
</div>
{plan.description && (
<CardDescription className="line-clamp-2">
{plan.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-3">
<div className="flex flex-wrap gap-1.5">
<Badge variant="outline">
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
</Badge>
<Badge variant="outline">
{t("coursePlan.hoursPerWeek", {
count: plan.contact_hours_per_week,
})}
</Badge>
</div>
{a && (
<div className="text-xs text-muted-foreground space-y-0.5">
{a.assigned_by_name && (
<div>
{t("coursePlan.student.assignedBy", { name: a.assigned_by_name })}
</div>
)}
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{a.due_date
? t("coursePlan.student.due", { date: a.due_date })
: t("coursePlan.student.noDue")}
</div>
</div>
)}
<div className="mt-auto pt-2">
<Button asChild size="sm" className="w-full">
<Link to={`/student/course-plans/${plan.id}`}>
{t("coursePlan.student.open")}
<ArrowRight className="ml-1 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,8 +1,13 @@
import { api } from "@/lib/api-client";
import type {
CoursePlan,
CoursePlanAssignment,
CoursePlanDeliverables,
CoursePlanGenerateBrief,
CoursePlanMaterial,
CoursePlanMedia,
CoursePlanSource,
CoursePlanSourceKind,
} from "@/types";
/**
@@ -12,6 +17,9 @@ import type {
* `backend/custom_addons/encoach_ai_course/controllers/course_plan.py`.
*/
export const coursePlanService = {
// ---------------------------------------------------------------------
// Plan lifecycle
// ---------------------------------------------------------------------
async list(params?: {
page?: number;
size?: number;
@@ -45,4 +53,166 @@ export const coursePlanService = {
): Promise<{ items: CoursePlanMaterial[]; count: number }> {
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
},
// ---------------------------------------------------------------------
// Phase A — Sources
// ---------------------------------------------------------------------
async listSources(planId: number): Promise<{ items: CoursePlanSource[]; count: number }> {
return api.get(`/ai/course-plan/${planId}/sources`);
},
async createSource(
planId: number,
payload:
| { kind: "url"; url: string; name?: string; auto_index?: boolean }
| { kind: "text"; inline_text: string; name?: string; auto_index?: boolean },
): Promise<{ data: CoursePlanSource }> {
return api.post(`/ai/course-plan/${planId}/sources`, payload);
},
async uploadSource(
planId: number,
file: File,
opts?: { name?: string; auto_index?: boolean },
): Promise<{ data: CoursePlanSource }> {
const fd = new FormData();
fd.append("file", file);
fd.append("kind", "file" satisfies CoursePlanSourceKind);
if (opts?.name) fd.append("name", opts.name);
if (opts?.auto_index !== undefined) {
fd.append("auto_index", opts.auto_index ? "1" : "0");
}
return api.upload(`/ai/course-plan/${planId}/sources`, fd);
},
async reindexSource(
planId: number,
sourceId: number,
): Promise<{ data: CoursePlanSource }> {
return api.post(
`/ai/course-plan/${planId}/sources/${sourceId}/index`,
);
},
async deleteSource(
planId: number,
sourceId: number,
): Promise<{ success: boolean }> {
return api.delete(`/ai/course-plan/${planId}/sources/${sourceId}`);
},
// ---------------------------------------------------------------------
// Phase B — Deliverables / progress
// ---------------------------------------------------------------------
async deliverables(planId: number): Promise<CoursePlanDeliverables> {
return api.get(`/ai/course-plan/${planId}/deliverables`);
},
// ---------------------------------------------------------------------
// Phase C — Multimedia
// ---------------------------------------------------------------------
async listMaterialMedia(
materialId: number,
): Promise<{ items: CoursePlanMedia[]; count: number }> {
return api.get(`/ai/course-plan/material/${materialId}/media`);
},
async generateAudio(
materialId: number,
payload?: {
voice?: string;
language?: string;
gender?: "male" | "female";
provider?: "polly" | "elevenlabs";
},
): Promise<{ data: CoursePlanMedia }> {
return api.post(
`/ai/course-plan/material/${materialId}/media/audio`,
payload ?? {},
);
},
async generateImage(
materialId: number,
payload?: {
prompt?: string;
size?: "1024x1024" | "1792x1024" | "1024x1792";
style?: "natural" | "vivid";
quality?: "standard" | "hd";
},
): Promise<{ data: CoursePlanMedia }> {
return api.post(
`/ai/course-plan/material/${materialId}/media/image`,
payload ?? {},
);
},
async composeVideo(materialId: number): Promise<{ data: CoursePlanMedia }> {
return api.post(
`/ai/course-plan/material/${materialId}/media/video`,
);
},
async deleteMedia(mediaId: number): Promise<{ success: boolean }> {
return api.delete(`/ai/course-plan/media/${mediaId}`);
},
async generateWeekMedia(
planId: number,
weekNumber: number,
payload?: { kinds?: Array<"audio" | "image" | "video"> },
): Promise<{ items: Array<CoursePlanMedia | { error: string; material_id: number }>; count: number }> {
return api.post(
`/ai/course-plan/${planId}/weeks/${weekNumber}/media`,
payload ?? {},
);
},
// ---------------------------------------------------------------------
// Phase D — Assignments
// ---------------------------------------------------------------------
async listAssignments(
planId: number,
): Promise<{ items: CoursePlanAssignment[]; count: number }> {
return api.get(`/ai/course-plan/${planId}/assignments`);
},
async createAssignment(
planId: number,
payload:
| {
mode: "batch";
batch_id: number;
due_date?: string | null;
message?: string;
}
| {
mode: "students";
student_user_ids: number[];
due_date?: string | null;
message?: string;
},
): Promise<{ data: CoursePlanAssignment }> {
return api.post(`/ai/course-plan/${planId}/assignments`, payload);
},
async deleteAssignment(
planId: number,
assignmentId: number,
): Promise<{ success: boolean }> {
return api.delete(
`/ai/course-plan/${planId}/assignments/${assignmentId}`,
);
},
// ---------------------------------------------------------------------
// Phase E — Student-side
// ---------------------------------------------------------------------
async studentList(): Promise<{ items: CoursePlan[]; count: number }> {
return api.get("/student/course-plans");
},
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
return api.get(`/student/course-plans/${planId}`);
},
};

View File

@@ -81,6 +81,142 @@ export interface CoursePlanMaterial {
/** Loose shape: depends on material_type. */
body: Record<string, unknown>;
body_text: string;
media?: CoursePlanMedia[];
}
// ---------------------------------------------------------------------------
// Phase A — Reference sources
// ---------------------------------------------------------------------------
export type CoursePlanSourceKind = "file" | "url" | "text";
export type CoursePlanSourceStatus = "pending" | "indexing" | "indexed" | "failed";
export interface CoursePlanSource {
id: number;
plan_id: number;
name: string;
kind: CoursePlanSourceKind;
file_name: string;
mime_type: string;
url: string;
has_inline_text: boolean;
status: CoursePlanSourceStatus;
error: string;
chunks_count: number;
extracted_chars: number;
indexed_at: string | null;
created_at: string | null;
}
// ---------------------------------------------------------------------------
// Phase B — Deliverables preview / progress
// ---------------------------------------------------------------------------
export type DeliverableStatus = "planned" | "generated" | "ready";
export interface CoursePlanDeliverable {
skill: string;
material_type: string;
material_id: number | null;
title: string;
status: DeliverableStatus;
media: Array<{
id: number;
kind: "audio" | "image" | "video";
status: string;
provider: string;
}>;
}
export interface CoursePlanDeliverablesWeek {
week_number: number;
date_label: string;
unit: string;
focus: string;
items_total: number;
deliverables: CoursePlanDeliverable[];
}
export interface CoursePlanDeliverables {
summary: {
total: number;
planned: number;
generated: number;
ready: number;
percent_ready: number;
media: {
audio: number;
image: number;
video: number;
audio_ready: number;
image_ready: number;
video_ready: number;
};
};
weeks: CoursePlanDeliverablesWeek[];
}
// ---------------------------------------------------------------------------
// Phase C — Multimedia
// ---------------------------------------------------------------------------
export type CoursePlanMediaKind = "audio" | "image" | "video";
export type CoursePlanMediaStatus = "queued" | "generating" | "ready" | "failed";
export type CoursePlanMediaProvider =
| "polly"
| "elevenlabs"
| "openai_image"
| "ffmpeg"
| "elai"
| "manual";
export interface CoursePlanMedia {
id: number;
plan_id: number;
week_id: number | null;
material_id: number | null;
kind: CoursePlanMediaKind;
provider: CoursePlanMediaProvider | string;
title: string;
voice: string;
language: string;
style: string;
mime_type: string;
size_bytes: number;
duration_seconds: number;
width: number;
height: number;
attachment_id: number | null;
download_url: string;
status: CoursePlanMediaStatus;
error: string;
cost_cents: number;
created_at: string | null;
}
// ---------------------------------------------------------------------------
// Phase D — Assignments
// ---------------------------------------------------------------------------
export type CoursePlanAssignmentMode = "batch" | "students";
export type CoursePlanAssignmentState = "active" | "archived";
export interface CoursePlanAssignment {
id: number;
plan_id: number;
plan_name: string;
mode: CoursePlanAssignmentMode;
batch_id: number | null;
batch_name: string;
student_user_ids: number[];
student_user_names: string[];
student_count: number;
assigned_by_id: number | null;
assigned_by_name: string;
due_date: string | null;
message: string;
state: CoursePlanAssignmentState;
created_at: string | null;
}
export interface CoursePlan {
@@ -101,9 +237,17 @@ export interface CoursePlan {
resources: CoursePlanResource[];
week_count: number;
material_count: number;
source_count?: number;
media_count?: number;
assignment_count?: number;
created_at: string | null;
weeks?: CoursePlanWeek[];
materials?: CoursePlanMaterial[];
sources?: CoursePlanSource[];
media?: CoursePlanMedia[];
assignments?: CoursePlanAssignment[];
/** Present on the student-list endpoint payload. */
assignment?: CoursePlanAssignment;
}
export interface CoursePlanGenerateBrief {

181
smoke_course_plan.py Normal file
View File

@@ -0,0 +1,181 @@
"""End-to-end smoke test for the AI course-plan feature.
Run via: odoo-bin shell -c odoo.conf -d encoach_v2 < smoke_course_plan.py
Tests Phases A-E of the LangGraph-based course-plan pipeline:
A. Sources / RAG (encoach.course.plan.source + SourceIndexer)
B. Deliverables (services.deliverables.compute_deliverables)
C. Multimedia (encoach.course.plan.media + MediaService)
D. Assignments (encoach.course.plan.assignment)
E. Student visibility (assignment.expand_user_ids)
External-API steps (DALL-E, Polly) tolerate missing credentials by
checking ``status == 'failed'`` and reporting the provider error rather
than failing the whole script.
"""
import json
import sys
def hr(title):
print(f"\n{'='*70}\n{title}\n{'='*70}")
def fail(msg):
print(f" FAIL {msg}")
sys.exit(1)
def ok(msg):
print(f" PASS {msg}")
hr("0. Locate fixtures")
Plan = env['encoach.course.plan'].sudo()
plan = Plan.search([], order='id desc', limit=1)
if not plan:
fail("No course plan in DB; seed one first")
print(f" using plan #{plan.id}: {plan.name!r} (CEFR {plan.cefr_level})")
Users = env['res.users'].sudo()
sarah = Users.search([('login', '=', 'sarah@encoach.test')], limit=1)
if not sarah:
fail("sarah@encoach.test not seeded")
print(f" using student user #{sarah.id}: {sarah.name}")
# ---------------------------------------------------------------- Phase A
hr("Phase A — Sources & RAG indexing")
Source = env['encoach.course.plan.source'].sudo()
src = Source.create({
'plan_id': plan.id,
'name': 'Smoke test inline source',
'kind': 'text',
'inline_text': (
'In Week 1 of the General English course we focus on present '
'simple and present continuous. Reading texts target daily '
'routines, family, and study habits at CEFR A2-B1 level. '
'Listening scripts feature 3-minute monologues on student life '
'with comprehension questions covering main idea and detail.'
),
'mime_type': 'text/plain',
})
ok(f"created source #{src.id}")
from odoo.addons.encoach_ai_course.services.source_indexer import (
SourceIndexer,
)
result = SourceIndexer(env).index(src)
print(f" index result: {result}")
src.invalidate_recordset()
if src.status == 'indexed' and src.chunks_count > 0:
ok(f"indexed: {src.chunks_count} chunks, {src.extracted_chars} chars")
elif src.status == 'failed':
print(f" WARN indexing failed (likely no embedding API key configured): "
f"{src.error}")
else:
print(f" WARN unexpected status: {src.status}")
env.cr.commit()
# ---------------------------------------------------------------- Phase B
hr("Phase B — Deliverables computation")
from odoo.addons.encoach_ai_course.services.deliverables import (
compute_deliverables,
)
deliv = compute_deliverables(plan)
summary = deliv['summary']
print(f" summary: total={summary['total']} planned={summary['planned']} "
f"generated={summary['generated']} ready={summary['ready']} "
f"%={summary['percent_ready']}")
print(f" media (current/ready): "
f"audio {summary['media']['audio']}/{summary['media']['audio_ready']} "
f"image {summary['media']['image']}/{summary['media']['image_ready']} "
f"video {summary['media']['video']}/{summary['media']['video_ready']}")
ok(f"compute_deliverables returned {len(deliv['weeks'])} weeks")
# ---------------------------------------------------------------- Phase C
hr("Phase C — Multimedia generation (audio + image)")
from odoo.addons.encoach_ai_course.services.media_service import MediaService
mat_listening = plan.material_ids.filtered(
lambda m: m.material_type == 'listening_script'
)[:1]
mat_reading = plan.material_ids.filtered(
lambda m: m.material_type == 'reading_text'
)[:1]
svc = MediaService(env)
if mat_listening:
audio_media = svc.synthesize_audio(mat_listening, language='en-GB')
print(f" audio media #{audio_media.id} status={audio_media.status} "
f"err={(audio_media.error or '')[:120]!r}")
if audio_media.status == 'ready':
ok(f"audio bytes={audio_media.size_bytes}")
else:
print(f" WARN audio not ready (provider may need creds)")
else:
print(" SKIP no listening_script material on plan")
if mat_reading:
image_media = svc.generate_image(mat_reading, size='1024x1024')
print(f" image media #{image_media.id} status={image_media.status} "
f"err={(image_media.error or '')[:120]!r}")
if image_media.status == 'ready':
ok(f"image bytes={image_media.size_bytes}")
else:
print(f" WARN image not ready (provider may need creds)")
else:
print(" SKIP no reading_text material on plan")
env.cr.commit()
# ---------------------------------------------------------------- Phase D
hr("Phase D — Assignment creation")
Assignment = env['encoach.course.plan.assignment'].sudo()
existing = Assignment.search([
('plan_id', '=', plan.id),
('mode', '=', 'students'),
('state', '=', 'active'),
])
existing.unlink()
asn = Assignment.create({
'plan_id': plan.id,
'mode': 'students',
'student_user_ids': [(6, 0, [sarah.id])],
'message': 'Smoke test assignment',
'state': 'active',
})
ok(f"created assignment #{asn.id} for {len(asn.student_user_ids)} student(s)")
print(f" to_api_dict: {json.dumps(asn.to_api_dict(), default=str)[:200]} ...")
env.cr.commit()
# ---------------------------------------------------------------- Phase E
hr("Phase E — Student visibility")
expanded = asn.expand_user_ids()
print(f" expand_user_ids -> {expanded}")
if sarah.id in expanded:
ok("sarah@encoach.test is included in assignment expansion")
else:
fail("sarah not visible in assignment.expand_user_ids()")
assignments = Assignment.search([
('state', '=', 'active'),
('student_user_ids', 'in', [sarah.id]),
])
visible_plans = {a.plan_id.id for a in assignments
if sarah.id in a.expand_user_ids()}
print(f" Sarah will see plans: {sorted(visible_plans)}")
if plan.id in visible_plans:
ok("Plan appears in student-side listing query")
else:
fail("Plan NOT in student-side listing")
hr("DONE — Course-plan smoke test passed (provider warnings are OK)")
print("Test source / assignment created. Review in admin UI on plan #%d." % plan.id)