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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user