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:
181
smoke_course_plan.py
Normal file
181
smoke_course_plan.py
Normal 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)
|
||||
Reference in New Issue
Block a user