feat(backend): course-plan student visibility, multi-voice TTS, OCR sources, branches

Ported from monorepo v4 commit 3b62075d (backend/* portion).

encoach_ai_course:
- workbook_attempt model + scoring + REST endpoints for student attempts
- dialogue_parser splits scripts by speaker, classifies gender, strips labels
- media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation,
  manual media upload endpoint (audio/image/video) with size validation
- source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs,
  page-streaming to stay under memory limit
- exercise_extractor + rag_context for RAG-grounded interactive workbooks
- course_plan_pipeline: v2 generator that grounds week material on indexed
  sources and persists grounded_on_json metadata
- security: access rules for new models

encoach_lms_api:
- branches model + controller (entity-scoped LMS branches)
- classroom_ext + course_ext (assignment + section workflow)
- classrooms controller: students/teachers/assign-course endpoints

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-28 00:20:35 +04:00
parent cd47d01f53
commit 565ddd5ff7
21 changed files with 4045 additions and 117 deletions

View File

@@ -27,6 +27,9 @@ from odoo.addons.encoach_ai_course.services.deliverables import (
compute_deliverables,
)
from odoo.addons.encoach_ai_course.services.media_service import MediaService
from odoo.addons.encoach_ai_course.services.exercise_extractor import (
ExerciseExtractor,
)
_logger = logging.getLogger(__name__)
@@ -45,10 +48,19 @@ def _request_language():
def _entity_scope():
"""Return (entity_ids, is_superadmin) for current JWT user."""
"""Return (entity_ids, is_superadmin) for current JWT user.
Mirrors ``encoach_lms_api.controllers.lms_core._entity_scope``: any user
linked to one or more entities is entity-scoped, even when they carry
Odoo settings groups.
"""
user = request.env.user.sudo()
is_super = bool(user.has_group('base.group_system'))
entity_ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
if entity_ids:
return entity_ids, False
is_super = bool(
user.id == 1 or user.has_group('base.group_system')
)
return entity_ids, is_super
@@ -222,6 +234,124 @@ class CoursePlanController(http.Controller):
_logger.exception('course-plan.generate_week_materials failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# POST /api/ai/course-plan/<id>/weeks/<n>/materials/v2
# ------------------------------------------------------------------
# RAG-grounded richer-schema generator. Requires at least one indexed
# source on the plan; if none exists, the response is a 409 so the UI
# can prompt the user to upload a reference book first. The pipeline
# is otherwise identical to /materials but persists ``grounded_on_json``
# on each created row and returns a ``rag.sources_used`` summary.
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials/v2',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def generate_week_materials_v2(self, plan_id, week_number, **kw):
try:
plan = self._get_plan_scoped(plan_id)
from odoo.addons.encoach_ai_course.services.rag_context import (
RAGContextBuilder,
)
builder = RAGContextBuilder(request.env)
if not builder.has_indexed_sources(plan):
return _error_response(
'No indexed reference sources for this plan. '
'Upload at least one PDF/DOCX before using v2.',
409,
)
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
materials = pipeline.generate_week_materials(
plan_id, week_number, use_rag=True,
)
sources_used = sorted({
src['source_id']
for m in materials
for src in (m._loads(m.grounded_on_json, []) or [])
if src.get('source_id')
})
return _json_response({
'items': [m.to_api_dict() for m in materials],
'count': len(materials),
'rag': {
'enabled': True,
'sources_used': sources_used,
},
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.generate_week_materials_v2 failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# POST /api/ai/course-plan/<id>/extract-workbooks
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>/extract-workbooks',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def extract_workbooks(self, plan_id, **kw):
"""Mine indexed sources for original exercises and persist them
as ``interactive_workbook`` materials on the plan."""
try:
plan = self._get_plan_scoped(plan_id)
body = _get_json_body() or {}
try:
max_batches = int(body.get('max_batches') or 0) or 8
except (TypeError, ValueError):
max_batches = 8
extractor = ExerciseExtractor(
request.env, language=_request_language(),
)
stats = extractor.run(plan, max_batches=max_batches)
return _json_response({
'data': stats,
'plan_id': plan.id,
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.extract_workbooks failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/material/<id>/grounding
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/material/<int:material_id>/grounding',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_material_grounding(self, material_id, **kw):
"""Return the source citations + extracted-from provenance for a material."""
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
grounded = material._loads(material.grounded_on_json, [])
extracted = material._loads(material.extracted_from_json, None)
sources = []
ids = [
int(s.get('source_id')) for s in (grounded or [])
if s.get('source_id')
]
if extracted and extracted.get('source_id'):
ids.append(int(extracted['source_id']))
if ids:
Source = request.env['encoach.course.plan.source'].sudo()
rows = Source.browse(list(set(ids)))
sources = [r.to_api_dict() for r in rows if r.exists()]
return _json_response({
'material_id': material.id,
'grounded_on': grounded if isinstance(grounded, list) else [],
'extracted_from': extracted if isinstance(extracted, dict) else None,
'sources': sources,
})
except Exception as exc:
_logger.exception('course-plan.get_material_grounding failed')
code = 404 if isinstance(exc, ValueError) else (
403 if isinstance(exc, PermissionError) else 500
)
return _error_response(str(exc), code)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
# ------------------------------------------------------------------
@@ -552,6 +682,106 @@ class CoursePlanController(http.Controller):
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route(
'/api/ai/course-plan/material/<int:material_id>/media/upload',
type='http', auth='none', methods=['POST'], csrf=False,
)
@jwt_required
def upload_media(self, material_id, **kw):
"""Attach an admin-supplied audio / image / video file to a material.
Multipart form fields:
* ``kind`` — ``audio`` | ``image`` | ``video`` (required)
* ``file`` — the binary blob (required)
* ``title`` — optional human label, defaults to the filename
The persisted ``encoach.course.plan.media`` row is marked
``provider='manual'`` so the drawer renders it distinctly from
AI-generated assets and admins know which clips were hand-curated.
Hard 100 MB cap on the upload to defend the filestore against an
accidental 4K-master-from-a-DSLR upload.
"""
try:
material = self._resolve_material(material_id)
if not material:
return _error_response('Material not found', 404)
kind = (request.httprequest.form.get('kind') or '').strip().lower()
if kind not in ('audio', 'image', 'video'):
return _error_response(
"kind must be one of 'audio', 'image', 'video'", 400,
)
uploaded = request.httprequest.files.get('file')
if not uploaded or not uploaded.filename:
return _error_response(
"Multipart field 'file' is required", 400,
)
data = uploaded.read()
if not data:
return _error_response('Uploaded file is empty', 400)
# 100 MB upper bound — well above the largest realistic
# listening track or lesson video, well below anything that
# could OOM the worker on base64-encode.
if len(data) > 100 * 1024 * 1024:
return _error_response(
'File too large — 100 MB maximum per upload', 413,
)
mime_type = (uploaded.mimetype or '').lower() or 'application/octet-stream'
expected_prefix = {
'audio': 'audio/',
'image': 'image/',
'video': 'video/',
}[kind]
# We don't *require* a strict mime match (browsers sometimes
# send octet-stream for .m4a, etc.) but if a caller flags
# ``kind=audio`` and uploads a clearly-image MIME, that's a
# real bug worth surfacing rather than silently swallowing.
if (mime_type
and not mime_type.startswith(expected_prefix)
and mime_type != 'application/octet-stream'):
return _error_response(
f"MIME {mime_type!r} does not match kind={kind!r}", 400,
)
title = (
(request.httprequest.form.get('title') or '').strip()
or uploaded.filename
)
Media = request.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': kind,
'provider': 'manual',
'title': title,
'mime_type': mime_type,
'size_bytes': len(data),
'status': 'generating',
})
attach = request.env['ir.attachment'].sudo().create({
'name': uploaded.filename,
'type': 'binary',
'datas': base64.b64encode(data),
'mimetype': mime_type,
'res_model': 'encoach.course.plan.media',
'res_id': media.id,
'public': True,
})
media.write({
'attachment_id': attach.id,
'status': 'ready',
'error': False,
})
return _json_response({'data': media.to_api_dict()})
except Exception as exc:
_logger.exception('course-plan.upload_media failed')
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
return _error_response(str(exc), code)
@http.route('/api/ai/course-plan/material/<int:material_id>/media',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
@@ -819,6 +1049,155 @@ class CoursePlanController(http.Controller):
_logger.exception('course-plan.student_list_plans failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# POST /api/student/course-plans/<plan_id>/materials/<material_id>/attempts
# ------------------------------------------------------------------
@http.route(
'/api/student/course-plans/<int:plan_id>/materials/<int:material_id>/attempts',
type='http', auth='none', methods=['POST'], csrf=False,
)
@jwt_required
def student_save_attempt(self, plan_id, material_id, **kw):
"""Persist a workbook attempt and return the freshly-graded score.
Body shape::
{ "answers": { "<exercise_id>": "<student answer>", ... },
"finalize": false }
The response is the same shape as ``GET .../attempts/me`` so the
UI can replace its local state in one swap.
"""
try:
user = request.env.user
material = self._resolve_attempt_target(plan_id, material_id, user)
body = _get_json_body() or {}
answers = body.get('answers') or {}
if not isinstance(answers, dict):
return _error_response('answers must be an object', 400)
finalize = bool(body.get('finalize'))
Attempt = request.env['encoach.course.plan.workbook.attempt'].sudo()
attempt = Attempt.search([
('material_id', '=', material.id),
('student_id', '=', user.id),
], order='attempt_number desc', limit=1)
if not attempt:
attempt = Attempt.create({
'material_id': material.id,
'student_id': user.id,
'attempt_number': 1,
})
elif attempt.is_final:
# Once finalised, a re-submit becomes a new attempt so the
# original locked attempt survives in the audit trail.
attempt = Attempt.create({
'material_id': material.id,
'student_id': user.id,
'attempt_number': attempt.attempt_number + 1,
})
attempt.grade(answers, finalize=finalize)
return _json_response({'data': attempt.to_api_dict()})
except PermissionError as exc:
return _error_response(str(exc), 403)
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.student_save_attempt failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# GET /api/student/course-plans/<plan_id>/materials/<material_id>/attempts/me
# ------------------------------------------------------------------
@http.route(
'/api/student/course-plans/<int:plan_id>/materials/<int:material_id>/attempts/me',
type='http', auth='none', methods=['GET'], csrf=False,
)
@jwt_required
def student_my_attempt(self, plan_id, material_id, **kw):
try:
user = request.env.user
material = self._resolve_attempt_target(plan_id, material_id, user)
attempt = request.env['encoach.course.plan.workbook.attempt'].sudo().search([
('material_id', '=', material.id),
('student_id', '=', user.id),
], order='attempt_number desc', limit=1)
if not attempt:
return _json_response({'data': None})
return _json_response({'data': attempt.to_api_dict()})
except PermissionError as exc:
return _error_response(str(exc), 403)
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.student_my_attempt failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/<plan_id>/materials/<material_id>/attempts
# ------------------------------------------------------------------
@http.route(
'/api/ai/course-plan/<int:plan_id>/materials/<int:material_id>/attempts',
type='http', auth='none', methods=['GET'], csrf=False,
)
@jwt_required
def list_material_attempts(self, plan_id, material_id, **kw):
"""Teacher dashboard: every student's latest attempt on a material."""
try:
self._get_plan_scoped(plan_id)
material = self._resolve_material(material_id)
if not material or material.plan_id.id != int(plan_id):
return _error_response('Material not found', 404)
attempts = request.env['encoach.course.plan.workbook.attempt'].sudo().search([
('material_id', '=', material.id),
], order='student_id, attempt_number desc')
# Latest attempt per student.
latest_by_student: dict[int, object] = {}
for a in attempts:
latest_by_student.setdefault(a.student_id.id, a)
items = [a.to_api_dict() for a in latest_by_student.values()]
return _json_response({
'items': items,
'count': len(items),
})
except ValueError as exc:
return _error_response(str(exc), 404)
except Exception as exc:
_logger.exception('course-plan.list_material_attempts failed')
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
# ------------------------------------------------------------------
def _resolve_attempt_target(self, plan_id, material_id, user):
"""Verify the material exists, belongs to the plan, and the student
actually has the plan assigned (entity-based access for non-students
is also allowed so admins / teachers can self-test the workbook).
"""
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
raise ValueError('Plan not found')
material = request.env['encoach.course.plan.material'].sudo().browse(
int(material_id),
)
if not material.exists() or material.plan_id.id != plan.id:
raise ValueError('Material not found')
# Student assignment path.
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:
return material
if a.mode == 'batch' and user.id in a.expand_user_ids():
return material
if a.mode == 'entities' and user.id in a.expand_user_ids():
return material
# Entity-admin / teacher fallback so they can preview-and-submit.
entity_ids, is_super = _entity_scope()
if is_super:
return material
if entity_ids and plan.entity_id and plan.entity_id.id in entity_ids:
return material
raise PermissionError('Plan not assigned to you')
@http.route('/api/student/course-plans/<int:plan_id>',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required