feat(course-plan): RAG sources + multi-modal media + assignments + student view

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
This commit is contained in:
Yamen Ahmad
2026-04-25 17:13:01 +04:00
parent 0ed7f88cab
commit 096b042daf
29 changed files with 4608 additions and 1586 deletions

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)