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:
@@ -6,10 +6,10 @@ endpoints. Every route is JWT-guarded via the shared ``@jwt_required``
|
||||
decorator and returns JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from odoo import http, fields
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required,
|
||||
@@ -21,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__)
|
||||
|
||||
@@ -172,313 +176,421 @@ class CoursePlanController(http.Controller):
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ==================================================================
|
||||
# NEW: Deliverable Detection & Management
|
||||
# PHASE A — Reference sources (RAG grounding)
|
||||
# ==================================================================
|
||||
|
||||
# POST /api/ai/course-plan/<id>/deliverables/detect
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/deliverables/detect',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def detect_deliverables(self, plan_id, **kw):
|
||||
"""Parse course outline text and extract structured deliverables."""
|
||||
def list_sources(self, plan_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
outline_text = body.get('outline_text', '').strip()
|
||||
if not outline_text:
|
||||
return _error_response('outline_text is required', 400)
|
||||
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
)
|
||||
result = pipeline.generate_deliverables_from_outline(plan_id, outline_text)
|
||||
return _json_response(result)
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
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.detect_deliverables failed')
|
||||
_logger.exception('course-plan.list_sources failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# GET /api/ai/course-plan/<id>/deliverables
|
||||
@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 list_deliverables(self, plan_id, **kw):
|
||||
"""List deliverables for a course plan."""
|
||||
def get_deliverables(self, plan_id, **kw):
|
||||
try:
|
||||
params = request.httprequest.args
|
||||
domain = [('plan_id', '=', int(plan_id))]
|
||||
week = params.get('week')
|
||||
if week:
|
||||
domain.append(('week_number', '=', int(week)))
|
||||
skill = params.get('skill')
|
||||
if skill:
|
||||
domain.append(('skill', '=', skill))
|
||||
|
||||
Deliverable = request.env['encoach.course.plan.deliverable'].sudo()
|
||||
records = Deliverable.search(domain, order='week_number, code')
|
||||
return _json_response({
|
||||
'items': [d.to_api_dict() for d in records],
|
||||
'count': len(records),
|
||||
})
|
||||
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.list_deliverables failed')
|
||||
_logger.exception('course-plan.deliverables failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# PUT /api/ai/course-plan/deliverables/<id>
|
||||
@http.route('/api/ai/course-plan/deliverables/<int:deliverable_id>',
|
||||
type='http', auth='none', methods=['PUT'], csrf=False)
|
||||
# ==================================================================
|
||||
# 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 update_deliverable(self, deliverable_id, **kw):
|
||||
"""Update deliverable status or details."""
|
||||
def gen_audio(self, material_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
Deliverable = request.env['encoach.course.plan.deliverable'].sudo()
|
||||
rec = Deliverable.browse(int(deliverable_id))
|
||||
if not rec.exists():
|
||||
return _error_response('Deliverable not found', 404)
|
||||
|
||||
updatable = {}
|
||||
if 'status' in body:
|
||||
updatable['status'] = body['status']
|
||||
if 'target_date' in body:
|
||||
updatable['target_date'] = body['target_date']
|
||||
if 'description' in body:
|
||||
updatable['description'] = body['description']
|
||||
|
||||
if updatable:
|
||||
rec.write(updatable)
|
||||
return _json_response({'data': rec.to_api_dict()})
|
||||
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.update_deliverable failed')
|
||||
_logger.exception('course-plan.gen_audio failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ==================================================================
|
||||
# NEW: Resource Dependencies
|
||||
# ==================================================================
|
||||
@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)
|
||||
|
||||
# GET /api/ai/course-plan/<id>/resources
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/resources',
|
||||
@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_resources(self, plan_id, **kw):
|
||||
"""List resource dependencies for a course plan."""
|
||||
def list_material_media(self, material_id, **kw):
|
||||
try:
|
||||
params = request.httprequest.args
|
||||
domain = [('plan_id', '=', int(plan_id))]
|
||||
resource_type = params.get('type')
|
||||
if resource_type:
|
||||
domain.append(('resource_type', '=', resource_type))
|
||||
|
||||
ResourceDep = request.env['encoach.course.plan.resource.dep'].sudo()
|
||||
records = ResourceDep.search(domain)
|
||||
material = self._resolve_material(material_id)
|
||||
if not material:
|
||||
return _error_response('Material not found', 404)
|
||||
return _json_response({
|
||||
'items': [r.to_api_dict() for r in records],
|
||||
'count': len(records),
|
||||
'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_resources failed')
|
||||
_logger.exception('course-plan.list_material_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# POST /api/ai/course-plan/<id>/resources
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/resources',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@http.route('/api/ai/course-plan/media/<int:media_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@jwt_required
|
||||
def add_resource(self, plan_id, **kw):
|
||||
"""Add a resource dependency to a course plan."""
|
||||
def delete_media(self, media_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
if not body.get('name'):
|
||||
return _error_response('name is required', 400)
|
||||
|
||||
ResourceDep = request.env['encoach.course.plan.resource.dep'].sudo()
|
||||
rec = ResourceDep.create({
|
||||
'plan_id': int(plan_id),
|
||||
'name': body['name'],
|
||||
'resource_type': body.get('resource_type', 'textbook'),
|
||||
'citation': body.get('citation', ''),
|
||||
'url': body.get('url', ''),
|
||||
'ai_usage_notes': body.get('ai_usage_notes', ''),
|
||||
'is_required': body.get('is_required', True),
|
||||
'extracted_content_json': json.dumps(body.get('extracted_content', {})),
|
||||
})
|
||||
return _json_response({'data': rec.to_api_dict()})
|
||||
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.add_resource failed')
|
||||
return _json_response(str(exc), 500)
|
||||
_logger.exception('course-plan.delete_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ==================================================================
|
||||
# NEW: Rich Media Generation
|
||||
# ==================================================================
|
||||
|
||||
# POST /api/ai/course-plan/materials/<id>/media/suggest
|
||||
@http.route('/api/ai/course-plan/materials/<int:material_id>/media/suggest',
|
||||
@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 suggest_media(self, material_id, **kw):
|
||||
"""Get AI suggestions for media that would enhance this material."""
|
||||
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:
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
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),
|
||||
)
|
||||
result = pipeline.suggest_media_for_material(material_id)
|
||||
if 'error' in result:
|
||||
return _error_response(result['error'], 400)
|
||||
return _json_response(result)
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.suggest_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
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]
|
||||
|
||||
# POST /api/ai/course-plan/materials/<id>/media/generate
|
||||
@http.route('/api/ai/course-plan/materials/<int:material_id>/media/generate',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def generate_media(self, material_id, **kw):
|
||||
"""Generate media (image, audio) for a teaching material."""
|
||||
try:
|
||||
body = _get_json_body()
|
||||
media_type = body.get('media_type', 'image')
|
||||
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
)
|
||||
result = pipeline.generate_media_for_material(material_id, media_type)
|
||||
if 'error' in result:
|
||||
return _error_response(result['error'], 400)
|
||||
return _json_response(result)
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
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.generate_media failed')
|
||||
_logger.exception('course-plan.gen_week_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# ==================================================================
|
||||
# NEW: Course Plan Assignment to Classes/Students
|
||||
# PHASE D — Plan assignments
|
||||
# ==================================================================
|
||||
|
||||
# POST /api/ai/course-plan/<id>/assignments
|
||||
@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):
|
||||
"""Assign a course plan to a class or student."""
|
||||
try:
|
||||
body = _get_json_body()
|
||||
Assignment = request.env['encoach.course.plan.assignment'].sudo()
|
||||
|
||||
vals = {
|
||||
'plan_id': int(plan_id),
|
||||
'assignment_type': body.get('assignment_type', 'class'),
|
||||
'delivery_mode': body.get('delivery_mode', 'sequential'),
|
||||
'status': 'scheduled',
|
||||
}
|
||||
if body.get('batch_id'):
|
||||
vals['batch_id'] = int(body['batch_id'])
|
||||
if body.get('student_id'):
|
||||
vals['student_id'] = int(body['student_id'])
|
||||
if body.get('start_date'):
|
||||
vals['start_date'] = body['start_date']
|
||||
|
||||
assignment = Assignment.create(vals)
|
||||
|
||||
# Create deliverable tracking rows
|
||||
Deliverable = request.env['encoach.course.plan.deliverable'].sudo()
|
||||
AssignmentDeliverable = request.env['encoach.course.plan.assignment.deliverable'].sudo()
|
||||
deliverables = Deliverable.search([('plan_id', '=', int(plan_id))])
|
||||
for d in deliverables:
|
||||
AssignmentDeliverable.create({
|
||||
'assignment_id': assignment.id,
|
||||
'deliverable_id': d.id,
|
||||
'status': 'not_started',
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'data': assignment.to_api_dict(),
|
||||
'deliverables_tracked': len(deliverables),
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.create_assignment failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# GET /api/ai/course-plan/<id>/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):
|
||||
"""List assignments for a course plan."""
|
||||
try:
|
||||
Assignment = request.env['encoach.course.plan.assignment'].sudo()
|
||||
records = Assignment.search([('plan_id', '=', int(plan_id))])
|
||||
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 records],
|
||||
'count': len(records),
|
||||
'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)
|
||||
|
||||
# GET /api/ai/course-plan/assignments/<id>
|
||||
@http.route('/api/ai/course-plan/assignments/<int:assignment_id>',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/assignments',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def get_assignment(self, assignment_id, **kw):
|
||||
"""Get assignment details with progress."""
|
||||
def create_assignment(self, plan_id, **kw):
|
||||
try:
|
||||
Assignment = request.env['encoach.course.plan.assignment'].sudo()
|
||||
AssignmentDeliverable = request.env['encoach.course.plan.assignment.deliverable'].sudo()
|
||||
|
||||
assignment = Assignment.browse(int(assignment_id))
|
||||
if not assignment.exists():
|
||||
return _error_response('Assignment not found', 404)
|
||||
|
||||
# Get deliverable tracking
|
||||
tracking = AssignmentDeliverable.search([('assignment_id', '=', int(assignment_id))])
|
||||
status_counts = {}
|
||||
for t in tracking:
|
||||
status_counts[t.status] = status_counts.get(t.status, 0) + 1
|
||||
|
||||
return _json_response({
|
||||
'data': assignment.to_api_dict(),
|
||||
'deliverables': {
|
||||
'total': len(tracking),
|
||||
'by_status': status_counts,
|
||||
'items': [t.to_api_dict() for t in tracking],
|
||||
},
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.get_assignment failed')
|
||||
return _error_response(str(exc), 500)
|
||||
|
||||
# PUT /api/ai/course-plan/assignments/<id>/deliverables/<del_id>
|
||||
@http.route('/api/ai/course-plan/assignments/<int:assignment_id>/deliverables/<int:deliverable_id>',
|
||||
type='http', auth='none', methods=['PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update_assignment_deliverable(self, assignment_id, deliverable_id, **kw):
|
||||
"""Update deliverable status for an assignment."""
|
||||
try:
|
||||
body = _get_json_body()
|
||||
AssignmentDeliverable = request.env['encoach.course.plan.assignment.deliverable'].sudo()
|
||||
|
||||
rec = AssignmentDeliverable.search([
|
||||
('assignment_id', '=', int(assignment_id)),
|
||||
('deliverable_id', '=', int(deliverable_id)),
|
||||
], limit=1)
|
||||
if not rec:
|
||||
return _error_response('Assignment deliverable not found', 404)
|
||||
|
||||
vals = {}
|
||||
if 'status' in body:
|
||||
vals['status'] = body['status']
|
||||
if 'score' in body:
|
||||
vals['score'] = float(body['score'])
|
||||
if 'notes' in body:
|
||||
vals['notes'] = body['notes']
|
||||
if body.get('status') == 'completed':
|
||||
vals['completion_date'] = fields.Datetime.now()
|
||||
|
||||
rec.write(vals)
|
||||
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.update_assignment_deliverable failed')
|
||||
_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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,28 +44,10 @@ MATERIAL_TYPE_SELECTION = [
|
||||
('grammar_lesson', 'Grammar Lesson'),
|
||||
('vocabulary_list', 'Vocabulary List'),
|
||||
('practice', 'Practice Exercises'),
|
||||
# Rich media types for comprehensive teaching materials
|
||||
('video_lesson', 'Video Lesson'),
|
||||
('audio_recording', 'Audio Recording'),
|
||||
('image_visual', 'Image / Visual Aid'),
|
||||
('interactive', 'Interactive Activity'),
|
||||
('assessment', 'Assessment / Quiz'),
|
||||
('other', 'Other'),
|
||||
]
|
||||
|
||||
|
||||
# Deliverable status tracking
|
||||
DELIVERABLE_STATUS_SELECTION = [
|
||||
('planned', 'Planned'),
|
||||
('generating', 'Generating'),
|
||||
('generated', 'Generated'),
|
||||
('reviewing', 'Under Review'),
|
||||
('approved', 'Approved'),
|
||||
('delivered', 'Delivered to Students'),
|
||||
('archived', 'Archived'),
|
||||
]
|
||||
|
||||
|
||||
class CoursePlan(models.Model):
|
||||
_name = 'encoach.course.plan'
|
||||
_description = 'AI-generated Course Plan'
|
||||
@@ -135,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
|
||||
@@ -157,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,
|
||||
@@ -177,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
|
||||
|
||||
|
||||
@@ -270,48 +278,8 @@ class CoursePlanMaterial(models.Model):
|
||||
'structured body is not needed.',
|
||||
)
|
||||
|
||||
def _loads(self, raw, default):
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
# Rich media asset fields for generated content
|
||||
media_type = fields.Selection([
|
||||
('none', 'No Media'),
|
||||
('image', 'Image (AI Generated)'),
|
||||
('audio', 'Audio (AI Generated)'),
|
||||
('video', 'Video (AI Generated)'),
|
||||
('external', 'External Media URL'),
|
||||
], default='none', string='Media Type')
|
||||
media_asset_url = fields.Char(
|
||||
string='Media Asset URL',
|
||||
help='URL to stored/generated media (image, audio, video)'
|
||||
)
|
||||
media_asset_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Media Attachment',
|
||||
help='Stored media file in Odoo'
|
||||
)
|
||||
media_generation_prompt = fields.Text(
|
||||
string='Media Generation Prompt',
|
||||
help='The prompt used to generate this media asset'
|
||||
)
|
||||
media_metadata_json = fields.Text(
|
||||
string='Media Metadata',
|
||||
help='JSON with dimensions, duration, file size, etc.'
|
||||
)
|
||||
|
||||
# Resource dependencies tracking
|
||||
required_resources_json = fields.Text(
|
||||
string='Required Resources',
|
||||
help='JSON list of resource IDs/URLs this material depends on'
|
||||
)
|
||||
ai_generation_notes = fields.Text(
|
||||
string='AI Generation Notes',
|
||||
help='Internal notes from AI about generation decisions'
|
||||
media_ids = fields.One2many(
|
||||
'encoach.course.plan.media', 'material_id', string='Generated media',
|
||||
)
|
||||
|
||||
def _loads(self, raw, default):
|
||||
@@ -322,9 +290,9 @@ class CoursePlanMaterial(models.Model):
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def to_api_dict(self, include_media=False):
|
||||
def to_api_dict(self, include_media=True):
|
||||
self.ensure_one()
|
||||
data = {
|
||||
out = {
|
||||
'id': self.id,
|
||||
'plan_id': self.plan_id.id,
|
||||
'week_id': self.week_id.id if self.week_id else None,
|
||||
@@ -335,315 +303,7 @@ class CoursePlanMaterial(models.Model):
|
||||
'summary': self.summary or '',
|
||||
'body': self._loads(self.body_json, {}),
|
||||
'body_text': self.body_text or '',
|
||||
'media_type': self.media_type or 'none',
|
||||
'required_resources': self._loads(self.required_resources_json, []),
|
||||
}
|
||||
if include_media:
|
||||
data['media_asset_url'] = self.media_asset_url or ''
|
||||
data['media_metadata'] = self._loads(self.media_metadata_json, {})
|
||||
data['media_generation_prompt'] = self.media_generation_prompt or ''
|
||||
return data
|
||||
|
||||
|
||||
class CoursePlanDeliverable(models.Model):
|
||||
"""Explicit deliverable tracking for GE1-style course planning.
|
||||
|
||||
Each deliverable represents a specific learning outcome that must be
|
||||
achieved by students. The AI uses these to generate targeted materials
|
||||
and track completion status.
|
||||
"""
|
||||
_name = 'encoach.course.plan.deliverable'
|
||||
_description = 'Course Plan Deliverable (Learning Outcome)'
|
||||
_order = 'plan_id, week_number, skill, code'
|
||||
|
||||
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,
|
||||
)
|
||||
week_number = fields.Integer(required=True, index=True)
|
||||
|
||||
# GE1-style outcome identification
|
||||
code = fields.Char(required=True, index=True, string='Outcome Code')
|
||||
skill = fields.Selection(SKILL_SELECTION, required=True, index=True)
|
||||
description = fields.Text(required=True)
|
||||
cefr_level = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'),
|
||||
('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'),
|
||||
], string='CEFR Level')
|
||||
|
||||
# Deliverable status and tracking
|
||||
status = fields.Selection(DELIVERABLE_STATUS_SELECTION, default='planned')
|
||||
target_date = fields.Date(string='Target Delivery Date')
|
||||
completion_date = fields.Datetime(string='Completed At')
|
||||
|
||||
# AI generation tracking
|
||||
ai_generated = fields.Boolean(default=False, string='AI Generated')
|
||||
generation_prompt = fields.Text(string='Generation Prompt Used')
|
||||
|
||||
# Resource dependencies for this specific deliverable
|
||||
resource_dependencies_json = fields.Text(
|
||||
string='Required Resources',
|
||||
help='JSON list of {resource_id, name, type, required_for} objects'
|
||||
)
|
||||
material_ids = fields.Many2many(
|
||||
'encoach.course.plan.material',
|
||||
'deliverable_material_rel',
|
||||
'deliverable_id', 'material_id',
|
||||
string='Generated Materials',
|
||||
help='Materials generated to support this deliverable'
|
||||
)
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'plan_id': self.plan_id.id,
|
||||
'week_number': self.week_number,
|
||||
'code': self.code or '',
|
||||
'skill': self.skill or '',
|
||||
'description': self.description or '',
|
||||
'cefr_level': self.cefr_level or '',
|
||||
'status': self.status or 'planned',
|
||||
'target_date': str(self.target_date) if self.target_date else None,
|
||||
'ai_generated': self.ai_generated,
|
||||
'resources': json.loads(self.resource_dependencies_json or '[]'),
|
||||
'material_ids': [m.id for m in self.material_ids],
|
||||
}
|
||||
|
||||
|
||||
class CoursePlanResourceDependency(models.Model):
|
||||
"""Track what resources the AI depends on for course generation.
|
||||
|
||||
This model stores the relationship between course plans and the external
|
||||
resources (textbooks, videos, etc.) that the AI references when generating
|
||||
content. It enables the system to check resource availability before
|
||||
generation and suggest alternatives if resources are missing.
|
||||
"""
|
||||
_name = 'encoach.course.plan.resource.dep'
|
||||
_description = 'Course Plan Resource Dependency'
|
||||
_order = 'plan_id, resource_type, name'
|
||||
|
||||
plan_id = fields.Many2one(
|
||||
'encoach.course.plan', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
name = fields.Char(required=True, string='Resource Name')
|
||||
resource_type = fields.Selection([
|
||||
('textbook', 'Textbook'),
|
||||
('workbook', 'Workbook'),
|
||||
('video', 'Video / Film'),
|
||||
('audio', 'Audio Recording'),
|
||||
('website', 'Website / Online Resource'),
|
||||
('software', 'Software / App'),
|
||||
('assessment', 'Assessment Bank'),
|
||||
('other', 'Other'),
|
||||
], required=True, default='textbook')
|
||||
|
||||
# Resource identification
|
||||
citation = fields.Text(string='Full Citation')
|
||||
isbn = fields.Char(string='ISBN / Identifier')
|
||||
url = fields.Char(string='URL / Link')
|
||||
file_attachment_id = fields.Many2one('ir.attachment', string='Attached File')
|
||||
|
||||
# AI dependency tracking
|
||||
is_required = fields.Boolean(default=True, string='Required for Generation')
|
||||
is_available = fields.Boolean(default=False, string='Resource Available')
|
||||
ai_usage_notes = fields.Text(
|
||||
string='AI Usage Notes',
|
||||
help='How the AI should use this resource (e.g., "extract dialogue scripts", "reference grammar explanations")'
|
||||
)
|
||||
|
||||
# Content extraction for AI consumption
|
||||
extracted_content_json = fields.Text(
|
||||
string='Extracted Content for AI',
|
||||
help='JSON with key excerpts, chapter summaries, or structured content the AI can reference'
|
||||
)
|
||||
|
||||
# Status
|
||||
status = fields.Selection([
|
||||
('needed', 'Needed'),
|
||||
('sourcing', 'Sourcing'),
|
||||
('available', 'Available'),
|
||||
('extracted', 'Content Extracted'),
|
||||
('unavailable', 'Unavailable'),
|
||||
('replaced', 'Replaced with Alternative'),
|
||||
], default='needed', string='Status')
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'plan_id': self.plan_id.id,
|
||||
'name': self.name or '',
|
||||
'resource_type': self.resource_type or '',
|
||||
'citation': self.citation or '',
|
||||
'is_required': self.is_required,
|
||||
'is_available': self.is_available,
|
||||
'status': self.status or 'needed',
|
||||
'ai_usage_notes': self.ai_usage_notes or '',
|
||||
'extracted_content': json.loads(self.extracted_content_json or '{}'),
|
||||
}
|
||||
|
||||
|
||||
class CoursePlanAssignment(models.Model):
|
||||
"""Assignment of a course plan to classes or individual students.
|
||||
|
||||
After a course plan is generated and approved, it can be assigned to
|
||||
delivery targets (classes, batches, or individual students). This model
|
||||
tracks the assignment, delivery progress, and completion status.
|
||||
"""
|
||||
_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,
|
||||
)
|
||||
name = fields.Char(compute='_compute_name', store=True)
|
||||
|
||||
# Assignment target
|
||||
assignment_type = fields.Selection([
|
||||
('class', 'Class / Batch'),
|
||||
('student', 'Individual Student'),
|
||||
('group', 'Custom Group'),
|
||||
], required=True, default='class')
|
||||
|
||||
# Link to OpenEduCat models
|
||||
course_id = fields.Many2one('op.course', string='Course', index=True)
|
||||
batch_id = fields.Many2one('op.batch', string='Batch / Class', index=True)
|
||||
student_id = fields.Many2one('op.student', string='Student', index=True)
|
||||
|
||||
# Assignment metadata
|
||||
assigned_by_id = fields.Many2one('res.users', string='Assigned By', default=lambda s: s.env.uid)
|
||||
assigned_date = fields.Datetime(default=fields.Datetime.now, string='Assigned At')
|
||||
start_date = fields.Date(string='Start Date')
|
||||
end_date = fields.Date(string='End Date')
|
||||
|
||||
# Delivery status
|
||||
status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('scheduled', 'Scheduled'),
|
||||
('active', 'Active - In Progress'),
|
||||
('paused', 'Paused'),
|
||||
('completed', 'Completed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], default='draft', string='Assignment Status')
|
||||
|
||||
# Progress tracking
|
||||
current_week = fields.Integer(default=1, string='Current Week')
|
||||
progress_percent = fields.Float(compute='_compute_progress', store=True, string='Progress %')
|
||||
|
||||
# Delivery configuration
|
||||
delivery_mode = fields.Selection([
|
||||
('sequential', 'Sequential (Week by Week)'),
|
||||
('parallel', 'Parallel (All Skills)'),
|
||||
('adaptive', 'Adaptive (AI-Paced)'),
|
||||
], default='sequential', string='Delivery Mode')
|
||||
|
||||
# Notes
|
||||
notes = fields.Text(string='Assignment Notes')
|
||||
|
||||
# Related records
|
||||
deliverable_ids = fields.One2many(
|
||||
'encoach.course.plan.assignment.deliverable',
|
||||
'assignment_id',
|
||||
string='Deliverable Tracking',
|
||||
)
|
||||
|
||||
@api.depends('plan_id', 'batch_id', 'student_id')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
parts = []
|
||||
if rec.plan_id:
|
||||
parts.append(rec.plan_id.name)
|
||||
if rec.batch_id:
|
||||
parts.append(f"→ {rec.batch_id.name}")
|
||||
if rec.student_id:
|
||||
parts.append(f"→ {rec.student_id.name}")
|
||||
rec.name = ' '.join(parts) if parts else 'Assignment'
|
||||
|
||||
@api.depends('current_week', 'plan_id.total_weeks')
|
||||
def _compute_progress(self):
|
||||
for rec in self:
|
||||
total = rec.plan_id.total_weeks or 1
|
||||
rec.progress_percent = min(100.0, (rec.current_week / total) * 100)
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name or '',
|
||||
'plan_id': self.plan_id.id if self.plan_id else None,
|
||||
'plan_name': self.plan_id.name if self.plan_id else '',
|
||||
'assignment_type': self.assignment_type or '',
|
||||
'batch_id': self.batch_id.id if self.batch_id else None,
|
||||
'batch_name': self.batch_id.name if self.batch_id else '',
|
||||
'student_id': self.student_id.id if self.student_id else None,
|
||||
'student_name': self.student_id.name if self.student_id else '',
|
||||
'status': self.status or 'draft',
|
||||
'start_date': str(self.start_date) if self.start_date else None,
|
||||
'end_date': str(self.end_date) if self.end_date else None,
|
||||
'current_week': self.current_week,
|
||||
'progress_percent': self.progress_percent,
|
||||
'delivery_mode': self.delivery_mode or 'sequential',
|
||||
}
|
||||
|
||||
|
||||
class CoursePlanAssignmentDeliverable(models.Model):
|
||||
"""Per-assignment tracking of deliverable completion.
|
||||
|
||||
When a course plan is assigned, this creates a tracking row for each
|
||||
deliverable so instructors can see which students have completed which
|
||||
learning outcomes.
|
||||
"""
|
||||
_name = 'encoach.course.plan.assignment.deliverable'
|
||||
_description = 'Assignment Deliverable Status'
|
||||
_order = 'assignment_id, week_number, code'
|
||||
|
||||
assignment_id = fields.Many2one(
|
||||
'encoach.course.plan.assignment', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
deliverable_id = fields.Many2one(
|
||||
'encoach.course.plan.deliverable', required=True, ondelete='cascade', index=True,
|
||||
)
|
||||
plan_id = fields.Many2one(
|
||||
related='assignment_id.plan_id', store=True, index=True,
|
||||
)
|
||||
|
||||
# Copy of deliverable info for quick reference
|
||||
week_number = fields.Integer(related='deliverable_id.week_number', store=True)
|
||||
code = fields.Char(related='deliverable_id.code', store=True)
|
||||
skill = fields.Selection(related='deliverable_id.skill', store=True)
|
||||
description = fields.Text(related='deliverable_id.description', store=True)
|
||||
|
||||
# Student completion tracking
|
||||
status = fields.Selection([
|
||||
('not_started', 'Not Started'),
|
||||
('in_progress', 'In Progress'),
|
||||
('submitted', 'Submitted for Review'),
|
||||
('completed', 'Completed'),
|
||||
('exempt', 'Exempt'),
|
||||
], default='not_started', string='Student Status')
|
||||
|
||||
# Progress details
|
||||
completion_date = fields.Datetime(string='Completed At')
|
||||
completed_by_id = fields.Many2one('res.users', string='Completed By')
|
||||
evidence_url = fields.Char(string='Evidence / Submission URL')
|
||||
score = fields.Float(string='Assessment Score')
|
||||
notes = fields.Text(string='Instructor Notes')
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'id': self.id,
|
||||
'assignment_id': self.assignment_id.id,
|
||||
'deliverable_id': self.deliverable_id.id,
|
||||
'week_number': self.week_number,
|
||||
'code': self.code or '',
|
||||
'skill': self.skill or '',
|
||||
'description': self.description or '',
|
||||
'status': self.status or 'not_started',
|
||||
'completion_date': self.completion_date.isoformat() if self.completion_date else None,
|
||||
'score': self.score or 0.0,
|
||||
}
|
||||
out['media'] = [m.to_api_dict() for m in self.media_ids]
|
||||
return out
|
||||
|
||||
128
custom_addons/encoach_ai_course/models/course_plan_assignment.py
Normal file
128
custom_addons/encoach_ai_course/models/course_plan_assignment.py
Normal 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,
|
||||
}
|
||||
124
custom_addons/encoach_ai_course/models/course_plan_media.py
Normal file
124
custom_addons/encoach_ai_course/models/course_plan_media.py
Normal 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,
|
||||
}
|
||||
130
custom_addons/encoach_ai_course/models/course_plan_source.py
Normal file
130
custom_addons/encoach_ai_course/models/course_plan_source.py
Normal 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''
|
||||
@@ -4,7 +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_deliverable_user,encoach.course.plan.deliverable.user,model_encoach_course_plan_deliverable,base.group_user,1,1,1,1
|
||||
access_encoach_course_plan_resource_dep_user,encoach.course.plan.resource.dep.user,model_encoach_course_plan_resource_dep,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
|
||||
access_encoach_course_plan_assignment_deliverable_user,encoach.course.plan.assignment.deliverable.user,model_encoach_course_plan_assignment_deliverable,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Course plan generation pipeline.
|
||||
|
||||
Three public entry points:
|
||||
Two public entry points:
|
||||
|
||||
* :py:meth:`generate_plan` — given a short brief (course title, CEFR level,
|
||||
duration, skill coverage, grammar focus, resources), produce a full
|
||||
@@ -14,22 +14,11 @@ Three public entry points:
|
||||
+ practice, writing prompt, vocabulary list) and persist each as an
|
||||
:py:class:`encoach.course.plan.material` row.
|
||||
|
||||
* :py:meth:`generate_deliverables_from_outline` — NEW: Parse a course
|
||||
outline (like GE1 PDF) and extract structured deliverables (learning
|
||||
outcomes) that the AI uses to generate targeted materials.
|
||||
|
||||
* :py:meth:`generate_rich_media` — NEW: Generate images, audio, or video
|
||||
to accompany teaching materials.
|
||||
|
||||
We deliberately ask the LLM to return strict JSON and then normalise it
|
||||
server-side — the frontend gets a stable shape no matter how loose the
|
||||
model's output is. Any parse failure is swallowed and reported back
|
||||
through the standard error channel so the caller can retry without the
|
||||
server crashing.
|
||||
|
||||
Resource-aware generation: Before generating materials, the pipeline
|
||||
fetches registered resources (textbooks, videos) and includes them in the
|
||||
prompt so the AI can reference real content instead of hallucinating.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -199,12 +188,6 @@ class CoursePlanPipeline:
|
||||
# Decide once per instance whether to route through the LangGraph
|
||||
# AgentRuntime or fall back to the direct chat_json path.
|
||||
self._use_agent = self._resolve_agent_flag(env)
|
||||
# Import agent tools for resource-aware generation
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.agent_tools import invoke as agent_invoke
|
||||
self._agent_invoke = agent_invoke
|
||||
except ImportError:
|
||||
self._agent_invoke = None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_flag(env):
|
||||
@@ -511,291 +494,3 @@ class CoursePlanPipeline:
|
||||
elif isinstance(value, dict):
|
||||
lines.append(f"{key}: " + json.dumps(value, ensure_ascii=False))
|
||||
return "\n".join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# NEW: Deliverable detection from course outlines (GE1-style)
|
||||
# ------------------------------------------------------------------
|
||||
def generate_deliverables_from_outline(self, plan_id, course_outline_text):
|
||||
"""Parse a course outline (PDF/text) and extract structured deliverables.
|
||||
|
||||
Creates :py:class:`encoach.course.plan.deliverable` rows that
|
||||
the AI uses when generating week materials.
|
||||
"""
|
||||
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
raise ValueError('Plan not found')
|
||||
|
||||
# Use agent tool to detect deliverables
|
||||
if self._agent_invoke:
|
||||
result = self._agent_invoke(
|
||||
self.env, "deliverables.detect",
|
||||
{
|
||||
"course_outline_text": course_outline_text,
|
||||
"cefr_level": plan.cefr_level,
|
||||
"total_weeks": plan.total_weeks,
|
||||
}
|
||||
)
|
||||
if result.get("ok"):
|
||||
# Create deliverable records
|
||||
Deliverable = self.env['encoach.course.plan.deliverable'].sudo()
|
||||
created = []
|
||||
for d in result.get("deliverables", []):
|
||||
try:
|
||||
rec = Deliverable.create({
|
||||
'plan_id': plan.id,
|
||||
'week_number': int(d.get('week_number', 1)),
|
||||
'code': d.get('code', ''),
|
||||
'skill': d.get('skill', 'integrated'),
|
||||
'description': d.get('description', ''),
|
||||
'cefr_level': d.get('cefr_level', plan.cefr_level),
|
||||
'resource_dependencies_json': json.dumps(d.get('resource_hints', []), ensure_ascii=False),
|
||||
'ai_generated': True,
|
||||
'generation_prompt': f"Extracted from course outline: {course_outline_text[:200]}...",
|
||||
})
|
||||
created.append(rec)
|
||||
except Exception as exc:
|
||||
_logger.warning("Failed to create deliverable: %s", exc)
|
||||
|
||||
# Save resource dependencies too
|
||||
ResourceDep = self.env['encoach.course.plan.resource.dep'].sudo()
|
||||
for r in result.get("resources_needed", []):
|
||||
try:
|
||||
ResourceDep.create({
|
||||
'plan_id': plan.id,
|
||||
'name': r.get('title', 'Unknown Resource'),
|
||||
'resource_type': r.get('type', 'other'),
|
||||
'citation': r.get('citation', ''),
|
||||
'ai_usage_notes': r.get('purpose', ''),
|
||||
'is_required': True,
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.warning("Failed to save resource: %s", exc)
|
||||
|
||||
return {
|
||||
'deliverables_created': len(created),
|
||||
'resources_needed': len(result.get('resources_needed', [])),
|
||||
}
|
||||
else:
|
||||
raise RuntimeError(result.get('error', 'Deliverable detection failed'))
|
||||
else:
|
||||
raise RuntimeError('Agent tools not available for deliverable detection')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# NEW: Resource-aware week material generation
|
||||
# ------------------------------------------------------------------
|
||||
def generate_week_materials_with_resources(self, plan_id, week_number):
|
||||
"""Generate materials using registered resource dependencies."""
|
||||
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
raise ValueError('Plan not found')
|
||||
|
||||
week = plan.week_ids.filtered(lambda w: w.week_number == int(week_number))
|
||||
if not week:
|
||||
raise ValueError(f'Week {week_number} not found')
|
||||
week = week[0]
|
||||
|
||||
# Fetch available resources
|
||||
resources = []
|
||||
if self._agent_invoke:
|
||||
res_result = self._agent_invoke(
|
||||
self.env, "resources.fetch",
|
||||
{"plan_id": plan.id, "is_available": True}
|
||||
)
|
||||
if res_result.get("ok"):
|
||||
resources = res_result.get("resources", [])
|
||||
|
||||
# Fetch deliverables for this week
|
||||
deliverables = []
|
||||
if self._agent_invoke:
|
||||
del_result = self._agent_invoke(
|
||||
self.env, "deliverables.fetch",
|
||||
{"plan_id": plan.id, "week_number": int(week_number)}
|
||||
)
|
||||
if del_result.get("ok"):
|
||||
deliverables = del_result.get("deliverables", [])
|
||||
|
||||
# Build enhanced prompt with resources and deliverables
|
||||
resource_context = ""
|
||||
if resources:
|
||||
resource_context = "\n\nAvailable Resources:\n" + json.dumps(resources, indent=2, ensure_ascii=False)
|
||||
|
||||
deliverable_context = ""
|
||||
if deliverables:
|
||||
deliverable_context = "\n\nTarget Deliverables (must be addressed in materials):\n" + json.dumps(deliverables, indent=2, ensure_ascii=False)
|
||||
|
||||
# Call base generation with enhanced context
|
||||
outcomes = plan._loads(plan.outcomes_json, {})
|
||||
items = week._loads(week.items_json, [])
|
||||
|
||||
system_msg = (
|
||||
"You are an expert English language teacher creating ready-to-use classroom materials. "
|
||||
"Your output MUST be valid JSON matching the schema. USE the available resources "
|
||||
"provided below. Address ALL deliverables listed."
|
||||
)
|
||||
user_msg = (
|
||||
f"Course: {plan.name}\n"
|
||||
f"CEFR: {(plan.cefr_level or '').upper()}\n"
|
||||
f"Week {week.week_number} — {week.date_label or ''}\n"
|
||||
f"Unit: {week.unit or ''}\n"
|
||||
f"Focus: {week.focus or ''}\n\n"
|
||||
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
|
||||
f"Full outcome catalogue:\n{json.dumps(outcomes, indent=2, ensure_ascii=False)}"
|
||||
f"{resource_context}"
|
||||
f"{deliverable_context}\n\n"
|
||||
+ _WEEK_JSON_HINT
|
||||
)
|
||||
|
||||
content = self._invoke_agent_or_chat(
|
||||
agent_key="course_week_materials",
|
||||
system_msg=system_msg,
|
||||
user_msg=user_msg,
|
||||
variables={
|
||||
"course": plan.name,
|
||||
"cefr_level": (plan.cefr_level or "").lower(),
|
||||
"week_number": week.week_number,
|
||||
},
|
||||
temperature=0.6,
|
||||
max_tokens=6000,
|
||||
action="course_plan.generate_week_with_resources",
|
||||
)
|
||||
|
||||
if content is None or 'error' in content:
|
||||
raise RuntimeError(
|
||||
(content or {}).get('error', 'AI generation failed.')
|
||||
)
|
||||
|
||||
# Wipe previous materials
|
||||
existing = self.env['encoach.course.plan.material'].sudo().search([
|
||||
('plan_id', '=', plan.id), ('week_id', '=', week.id),
|
||||
])
|
||||
if existing:
|
||||
existing.unlink()
|
||||
|
||||
# Create new materials with resource tracking
|
||||
Material = self.env['encoach.course.plan.material'].sudo()
|
||||
created = []
|
||||
for m in content.get('materials') or []:
|
||||
try:
|
||||
rec = Material.create({
|
||||
'plan_id': plan.id,
|
||||
'week_id': week.id,
|
||||
'skill': (m.get('skill') or 'integrated').strip().lower(),
|
||||
'material_type': (m.get('material_type') or 'other').strip(),
|
||||
'title': (m.get('title') or '').strip() or 'Untitled',
|
||||
'summary': (m.get('summary') or '').strip(),
|
||||
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
|
||||
'body_text': self._flatten_body(m.get('body') or {}),
|
||||
'required_resources_json': json.dumps(m.get('resources_used', []), ensure_ascii=False),
|
||||
'ai_generation_notes': m.get('generation_notes', ''),
|
||||
})
|
||||
created.append(rec)
|
||||
except Exception as exc:
|
||||
_logger.warning("Skipping bad material row: %s", exc)
|
||||
|
||||
return created
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# NEW: Rich media generation for materials
|
||||
# ------------------------------------------------------------------
|
||||
def suggest_media_for_material(self, material_id):
|
||||
"""Suggest what media (images, audio) would enhance a material."""
|
||||
material = self.env['encoach.course.plan.material'].sudo().browse(int(material_id))
|
||||
if not material.exists():
|
||||
raise ValueError('Material not found')
|
||||
|
||||
if not self._agent_invoke:
|
||||
raise RuntimeError('Agent tools not available')
|
||||
|
||||
# Get visual suggestions
|
||||
result = self._agent_invoke(
|
||||
self.env, "media.suggest_visuals",
|
||||
{
|
||||
"content_description": material.body_text or material.summary or '',
|
||||
"material_type": material.material_type,
|
||||
"target_audience": f"{material.plan_id.cefr_level} level learners",
|
||||
}
|
||||
)
|
||||
|
||||
if result.get("ok"):
|
||||
return {
|
||||
'suggestions': result.get('visuals', []),
|
||||
'material_id': material_id,
|
||||
}
|
||||
return {'error': result.get('error', 'Could not generate suggestions')}
|
||||
|
||||
def generate_media_for_material(self, material_id, media_type='image'):
|
||||
"""Generate actual media (image/audio) for a material."""
|
||||
material = self.env['encoach.course.plan.material'].sudo().browse(int(material_id))
|
||||
if not material.exists():
|
||||
raise ValueError('Material not found')
|
||||
|
||||
if not self._agent_invoke:
|
||||
raise RuntimeError('Agent tools not available')
|
||||
|
||||
if media_type == 'image':
|
||||
# Get suggestions first
|
||||
suggestions = self.suggest_media_for_material(material_id)
|
||||
if 'error' in suggestions:
|
||||
return suggestions
|
||||
|
||||
# Use first suggestion's prompt
|
||||
visuals = suggestions.get('suggestions', [])
|
||||
if not visuals:
|
||||
return {'error': 'No visual suggestions available'}
|
||||
|
||||
prompt = visuals[0].get('prompt_for_ai', visuals[0].get('description', ''))
|
||||
|
||||
result = self._agent_invoke(
|
||||
self.env, "media.generate_image",
|
||||
{
|
||||
"prompt": prompt,
|
||||
"material_id": material_id,
|
||||
"style": visuals[0].get('complexity', 'educational'),
|
||||
}
|
||||
)
|
||||
|
||||
if result.get("ok"):
|
||||
# Update material with media info
|
||||
material.write({
|
||||
'media_type': 'image',
|
||||
'media_generation_prompt': result.get('prompt_used', prompt),
|
||||
'media_asset_url': result.get('note', ''), # Would be actual URL after generation
|
||||
})
|
||||
return {
|
||||
'media_type': 'image',
|
||||
'prompt': prompt,
|
||||
'suggestion': visuals[0],
|
||||
'material_id': material_id,
|
||||
}
|
||||
return {'error': result.get('error', 'Image generation failed')}
|
||||
|
||||
elif media_type == 'audio':
|
||||
# For listening scripts, generate audio
|
||||
body = material._loads(material.body_json, {})
|
||||
text = body.get('script', body.get('text', ''))
|
||||
|
||||
if not text:
|
||||
return {'error': 'No text available for audio generation'}
|
||||
|
||||
result = self._agent_invoke(
|
||||
self.env, "media.generate_audio",
|
||||
{
|
||||
"text": text[:3000], # Limit length
|
||||
"material_id": material_id,
|
||||
"purpose": "listening_exercise" if material.material_type == 'listening_script' else "pronunciation_guide",
|
||||
}
|
||||
)
|
||||
|
||||
if result.get("ok"):
|
||||
material.write({
|
||||
'media_type': 'audio',
|
||||
'media_generation_prompt': f"Audio for: {text[:200]}...",
|
||||
})
|
||||
return {
|
||||
'media_type': 'audio',
|
||||
'service': result.get('service'),
|
||||
'material_id': material_id,
|
||||
}
|
||||
return {'error': result.get('error', 'Audio generation failed')}
|
||||
|
||||
return {'error': f'Unsupported media type: {media_type}'}
|
||||
|
||||
158
custom_addons/encoach_ai_course/services/deliverables.py
Normal file
158
custom_addons/encoach_ai_course/services/deliverables.py
Normal 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,
|
||||
}
|
||||
387
custom_addons/encoach_ai_course/services/media_service.py
Normal file
387
custom_addons/encoach_ai_course/services/media_service.py
Normal 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
|
||||
202
custom_addons/encoach_ai_course/services/source_indexer.py
Normal file
202
custom_addons/encoach_ai_course/services/source_indexer.py
Normal 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),
|
||||
}
|
||||
Reference in New Issue
Block a user