feat(platform): ship AI fallback stack and entity-scoped course planning
Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform. Made-with: Cursor
This commit is contained in:
@@ -7,12 +7,14 @@ decorator and returns JSON.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required,
|
||||
validate_token,
|
||||
_json_response,
|
||||
_error_response,
|
||||
_get_json_body,
|
||||
@@ -42,7 +44,65 @@ def _request_language():
|
||||
return str(raw).split(',')[0].split(';')[0].split('-')[0].strip().lower() or 'en'
|
||||
|
||||
|
||||
def _entity_scope():
|
||||
"""Return (entity_ids, is_superadmin) for current JWT user."""
|
||||
user = request.env.user.sudo()
|
||||
is_super = bool(user.has_group('base.group_system'))
|
||||
entity_ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else []
|
||||
return entity_ids, is_super
|
||||
|
||||
|
||||
def _default_entity_id_from_scope():
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if entity_ids:
|
||||
return entity_ids[0]
|
||||
if is_super:
|
||||
return False
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
|
||||
|
||||
def _ensure_entity_access(entity_id):
|
||||
if not entity_id:
|
||||
raise PermissionError('entity_id is required')
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return int(entity_id)
|
||||
if not entity_ids:
|
||||
raise PermissionError('User is not linked to any entity')
|
||||
if int(entity_id) not in entity_ids:
|
||||
raise PermissionError('Entity access denied')
|
||||
return int(entity_id)
|
||||
|
||||
|
||||
class CoursePlanController(http.Controller):
|
||||
def _plan_domain(self, extra=None):
|
||||
domain = list(extra or [])
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return domain
|
||||
if not entity_ids:
|
||||
return domain + [('id', '=', 0)]
|
||||
return domain + [('entity_id', 'in', entity_ids)]
|
||||
|
||||
def _assert_plan_access(self, plan):
|
||||
if not plan or not plan.exists():
|
||||
raise ValueError('Plan not found')
|
||||
entity_ids, is_super = _entity_scope()
|
||||
if is_super:
|
||||
return
|
||||
if not plan.entity_id or plan.entity_id.id not in entity_ids:
|
||||
raise PermissionError('Entity access denied')
|
||||
|
||||
def _get_plan_scoped(self, plan_id):
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
self._assert_plan_access(plan)
|
||||
return plan
|
||||
|
||||
def _assert_material_access(self, material):
|
||||
if not material or not material.exists():
|
||||
raise ValueError('Material not found')
|
||||
self._assert_plan_access(material.plan_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan
|
||||
# ------------------------------------------------------------------
|
||||
@@ -54,15 +114,21 @@ class CoursePlanController(http.Controller):
|
||||
body = _get_json_body()
|
||||
if not (body.get('title') or '').strip():
|
||||
return _error_response('title is required', 400)
|
||||
if body.get('entity_id'):
|
||||
entity_id = _ensure_entity_access(int(body['entity_id']))
|
||||
else:
|
||||
entity_id = _default_entity_id_from_scope()
|
||||
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
)
|
||||
plan = pipeline.generate_plan(body)
|
||||
if entity_id:
|
||||
plan.sudo().write({'entity_id': entity_id})
|
||||
return _json_response({'data': plan.to_api_dict(include_weeks=True)})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.generate failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan
|
||||
@@ -73,10 +139,12 @@ class CoursePlanController(http.Controller):
|
||||
def list_plans(self, **kw):
|
||||
try:
|
||||
params = request.httprequest.args
|
||||
domain = []
|
||||
domain = self._plan_domain([])
|
||||
search = (params.get('search') or '').strip()
|
||||
if search:
|
||||
domain.append(('name', 'ilike', search))
|
||||
if params.get('entity_id'):
|
||||
domain.append(('entity_id', '=', _ensure_entity_access(int(params.get('entity_id')))))
|
||||
|
||||
Plan = request.env['encoach.course.plan'].sudo()
|
||||
offset, limit, page = _paginate({
|
||||
@@ -94,7 +162,7 @@ class CoursePlanController(http.Controller):
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/<id>
|
||||
@@ -104,15 +172,15 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def get_plan(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)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response({
|
||||
'data': plan.to_api_dict(include_weeks=True, include_materials=True),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.get failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DELETE /api/ai/course-plan/<id>
|
||||
@@ -122,14 +190,14 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def delete_plan(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)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
plan.unlink()
|
||||
return _json_response({'success': True})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan/<id>/weeks/<n>/materials
|
||||
@@ -139,6 +207,7 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def generate_week_materials(self, plan_id, week_number, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
pipeline = CoursePlanPipeline(
|
||||
request.env, language=_request_language(),
|
||||
)
|
||||
@@ -151,7 +220,7 @@ class CoursePlanController(http.Controller):
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.generate_week_materials failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
|
||||
@@ -161,6 +230,7 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def list_week_materials(self, plan_id, week_number, **kw):
|
||||
try:
|
||||
self._get_plan_scoped(plan_id)
|
||||
week = request.env['encoach.course.plan.week'].sudo().search([
|
||||
('plan_id', '=', int(plan_id)),
|
||||
('week_number', '=', int(week_number)),
|
||||
@@ -173,7 +243,7 @@ class CoursePlanController(http.Controller):
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_week_materials failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE A — Reference sources (RAG grounding)
|
||||
@@ -184,25 +254,23 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def list_sources(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)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response({
|
||||
'items': [s.to_api_dict() for s in plan.source_ids],
|
||||
'count': len(plan.source_ids),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_sources failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@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)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
|
||||
ct = request.httprequest.content_type or ''
|
||||
files = request.httprequest.files
|
||||
@@ -250,37 +318,110 @@ class CoursePlanController(http.Controller):
|
||||
|
||||
rec = request.env['encoach.course.plan.source'].sudo().create(vals)
|
||||
return _json_response({'data': rec.to_api_dict()})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.create_source failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai/course-plan/<plan_id>/sources/from-resources
|
||||
# ------------------------------------------------------------------
|
||||
# Attach one or more existing library resources (``encoach.resource``,
|
||||
# the items shown under /admin/resources) as RAG sources for a plan.
|
||||
#
|
||||
# Body shape: ``{ "resource_ids": [<int>, ...] }``. We dedupe against
|
||||
# already-linked resources so re-clicking "Attach" is a no-op rather
|
||||
# than producing duplicate index entries. Each new row auto-indexes
|
||||
# via the ``encoach.course.plan.source`` create() hook.
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/sources/from-resources',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def attach_library_resources(self, plan_id, **kw):
|
||||
try:
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
|
||||
body = _get_json_body() or {}
|
||||
raw_ids = body.get('resource_ids') or body.get('ids') or []
|
||||
if not isinstance(raw_ids, list):
|
||||
return _error_response('resource_ids must be a list', 400)
|
||||
try:
|
||||
resource_ids = [int(x) for x in raw_ids if x is not None]
|
||||
except (TypeError, ValueError):
|
||||
return _error_response('resource_ids must contain integers', 400)
|
||||
if not resource_ids:
|
||||
return _error_response('No resource ids provided', 400)
|
||||
|
||||
Resource = request.env['encoach.resource'].sudo()
|
||||
Source = request.env['encoach.course.plan.source'].sudo()
|
||||
|
||||
already_linked = set(
|
||||
plan.source_ids.filtered('resource_id').mapped('resource_id.id')
|
||||
)
|
||||
attached, skipped, missing = [], [], []
|
||||
for rid in resource_ids:
|
||||
if rid in already_linked:
|
||||
skipped.append(rid)
|
||||
continue
|
||||
res = Resource.browse(rid)
|
||||
if not res.exists():
|
||||
missing.append(rid)
|
||||
continue
|
||||
rec = Source.create({
|
||||
'plan_id': plan.id,
|
||||
'kind': 'resource',
|
||||
'resource_id': res.id,
|
||||
'name': res.name or f'Resource #{res.id}',
|
||||
'file_name': res.name or '',
|
||||
'mime_type': '',
|
||||
})
|
||||
attached.append(rec.to_api_dict())
|
||||
|
||||
return _json_response({
|
||||
'attached': attached,
|
||||
'skipped_existing': skipped,
|
||||
'missing': missing,
|
||||
'count': len(attached),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.attach_library_resources failed')
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 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:
|
||||
self._get_plan_scoped(plan_id)
|
||||
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 ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.reindex_source failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 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:
|
||||
self._get_plan_scoped(plan_id)
|
||||
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 ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_source failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE B — Deliverables preview / progress
|
||||
@@ -291,13 +432,13 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def get_deliverables(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)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response(compute_deliverables(plan))
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.deliverables failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE C — Multimedia generation per material
|
||||
@@ -307,8 +448,48 @@ class CoursePlanController(http.Controller):
|
||||
rec = request.env['encoach.course.plan.material'].sudo().browse(int(material_id))
|
||||
if not rec.exists():
|
||||
return None
|
||||
self._assert_material_access(rec)
|
||||
return rec
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>',
|
||||
type='http', auth='none', methods=['PATCH'], csrf=False)
|
||||
@jwt_required
|
||||
def update_material(self, material_id, **kw):
|
||||
"""Edit generated material metadata/content without regenerating."""
|
||||
try:
|
||||
material = self._resolve_material(material_id)
|
||||
if not material:
|
||||
return _error_response('Material not found', 404)
|
||||
body = _get_json_body() or {}
|
||||
vals = {}
|
||||
if 'title' in body:
|
||||
new_title = (body.get('title') or '').strip()
|
||||
if not new_title:
|
||||
return _error_response('title cannot be empty', 400)
|
||||
vals['title'] = new_title
|
||||
if 'summary' in body:
|
||||
vals['summary'] = (body.get('summary') or '').strip()
|
||||
if 'is_static' in body:
|
||||
vals['is_static'] = bool(body.get('is_static'))
|
||||
if 'share_date' in body:
|
||||
vals['share_date'] = body.get('share_date') or False
|
||||
if 'body' in body:
|
||||
vals['body_json'] = json.dumps(body.get('body') or {}, ensure_ascii=False)
|
||||
if 'body_text' in body:
|
||||
body_text = (body.get('body_text') or '').strip()
|
||||
vals['body_text'] = body_text
|
||||
if 'body' not in body and body_text:
|
||||
# Keep non-technical editing simple: if only plain text is
|
||||
# provided, mirror it into a minimal JSON structure.
|
||||
vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False)
|
||||
if vals:
|
||||
material.sudo().write(vals)
|
||||
return _json_response({'data': material.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.update_material failed')
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media/audio',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
@@ -324,12 +505,13 @@ class CoursePlanController(http.Controller):
|
||||
voice=body.get('voice'),
|
||||
language=body.get('language') or 'en-GB',
|
||||
gender=body.get('gender') or 'female',
|
||||
provider=body.get('provider') or 'polly',
|
||||
provider=body.get('provider') or 'auto',
|
||||
)
|
||||
return _json_response({'data': media.to_api_dict()})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_audio failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media/image',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@@ -351,7 +533,8 @@ class CoursePlanController(http.Controller):
|
||||
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)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media/video',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@@ -366,7 +549,8 @@ class CoursePlanController(http.Controller):
|
||||
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)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/material/<int:material_id>/media',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@@ -382,7 +566,63 @@ class CoursePlanController(http.Controller):
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_material_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai/course-plan/media/<id>/raw
|
||||
# ------------------------------------------------------------------
|
||||
# Streams the binary backing a media row. Used as the ``src`` for
|
||||
# ``<img>`` / ``<audio>`` / ``<video>`` tags AND as the target of
|
||||
# download links — the only difference is the ``?download=1`` flag.
|
||||
#
|
||||
# Accepts the JWT via the standard ``Authorization: Bearer …`` header
|
||||
# OR via ``?token=<jwt>`` / ``?access_token=<jwt>`` so plain ``<img>``
|
||||
# tags can render the asset without us blob-converting every fetch.
|
||||
@http.route('/api/ai/course-plan/media/<int:media_id>/raw',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
def stream_media(self, media_id, **kw):
|
||||
try:
|
||||
user = validate_token(allow_query_param=True)
|
||||
if not user:
|
||||
return _error_response('Authentication required', 401)
|
||||
request.update_env(user=user.id)
|
||||
|
||||
rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
|
||||
if not rec.exists() or not rec.attachment_id:
|
||||
return _error_response('Media not found', 404)
|
||||
self._assert_plan_access(rec.plan_id)
|
||||
|
||||
attachment = rec.attachment_id
|
||||
payload = attachment.raw or b''
|
||||
if not payload and attachment.datas:
|
||||
# Older rows store the binary base64-encoded under ``datas``.
|
||||
payload = base64.b64decode(attachment.datas)
|
||||
if not payload:
|
||||
return _error_response('Media payload missing', 410)
|
||||
|
||||
mimetype = attachment.mimetype or rec.mime_type or 'application/octet-stream'
|
||||
filename = attachment.name or f'media-{rec.id}'
|
||||
disposition = (
|
||||
f'attachment; filename="{filename}"'
|
||||
if request.httprequest.args.get('download')
|
||||
else f'inline; filename="{filename}"'
|
||||
)
|
||||
headers = [
|
||||
('Content-Type', mimetype),
|
||||
('Content-Length', str(len(payload))),
|
||||
('Content-Disposition', disposition),
|
||||
# Aggressive caching is safe — the URL is keyed by the row id
|
||||
# and the binary is immutable once generated. Setting a 1h
|
||||
# max-age avoids re-streaming the same WAV/PNG every time the
|
||||
# admin re-opens the media drawer.
|
||||
('Cache-Control', 'private, max-age=3600'),
|
||||
]
|
||||
return request.make_response(payload, headers=headers)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.stream_media failed')
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/media/<int:media_id>',
|
||||
type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@@ -392,13 +632,15 @@ class CoursePlanController(http.Controller):
|
||||
rec = request.env['encoach.course.plan.media'].sudo().browse(int(media_id))
|
||||
if not rec.exists():
|
||||
return _error_response('Media not found', 404)
|
||||
self._assert_plan_access(rec.plan_id)
|
||||
if rec.attachment_id:
|
||||
rec.attachment_id.unlink()
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/media',
|
||||
type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@@ -411,9 +653,7 @@ class CoursePlanController(http.Controller):
|
||||
depends on ffmpeg + the audio + image steps and is slower.
|
||||
"""
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
week = plan.week_ids.filtered(
|
||||
lambda w: w.week_number == int(week_number),
|
||||
)
|
||||
@@ -443,7 +683,8 @@ class CoursePlanController(http.Controller):
|
||||
return _json_response({'items': results, 'count': len(results)})
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.gen_week_media failed')
|
||||
return _error_response(str(exc), 500)
|
||||
code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500
|
||||
return _error_response(str(exc), code)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE D — Plan assignments
|
||||
@@ -454,25 +695,23 @@ class CoursePlanController(http.Controller):
|
||||
@jwt_required
|
||||
def list_assignments(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)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
return _json_response({
|
||||
'items': [a.to_api_dict() for a in plan.assignment_ids],
|
||||
'count': len(plan.assignment_ids),
|
||||
})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.list_assignments failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
@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):
|
||||
try:
|
||||
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
|
||||
if not plan.exists():
|
||||
return _error_response('Plan not found', 404)
|
||||
plan = self._get_plan_scoped(plan_id)
|
||||
body = _get_json_body() or {}
|
||||
mode = (body.get('mode') or 'batch').strip()
|
||||
vals = {
|
||||
@@ -485,25 +724,40 @@ class CoursePlanController(http.Controller):
|
||||
if mode == 'batch':
|
||||
if not body.get('batch_id'):
|
||||
return _error_response('batch_id is required', 400)
|
||||
vals['batch_id'] = int(body['batch_id'])
|
||||
batch_id = int(body['batch_id'])
|
||||
batch = request.env['op.batch'].sudo().browse(batch_id)
|
||||
if not batch.exists():
|
||||
return _error_response('Batch not found', 404)
|
||||
if plan.entity_id and batch.entity_id and plan.entity_id.id != batch.entity_id.id:
|
||||
return _error_response('Batch entity does not match plan entity', 400)
|
||||
vals['batch_id'] = 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])]
|
||||
elif mode == 'entities':
|
||||
ids = body.get('entity_ids') or []
|
||||
if not isinstance(ids, list) or not ids:
|
||||
return _error_response('entity_ids is required', 400)
|
||||
checked = [_ensure_entity_access(int(i)) for i in ids]
|
||||
vals['entity_ids'] = [(6, 0, checked)]
|
||||
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 ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.create_assignment failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 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:
|
||||
self._get_plan_scoped(plan_id)
|
||||
rec = request.env['encoach.course.plan.assignment'].sudo().browse(
|
||||
int(assignment_id),
|
||||
)
|
||||
@@ -511,9 +765,11 @@ class CoursePlanController(http.Controller):
|
||||
return _error_response('Assignment not found', 404)
|
||||
rec.unlink()
|
||||
return _json_response({'success': True})
|
||||
except ValueError as exc:
|
||||
return _error_response(str(exc), 404)
|
||||
except Exception as exc:
|
||||
_logger.exception('course-plan.delete_assignment failed')
|
||||
return _error_response(str(exc), 500)
|
||||
return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500)
|
||||
|
||||
# ==================================================================
|
||||
# PHASE E — Student-side endpoints
|
||||
@@ -535,12 +791,7 @@ class CoursePlanController(http.Controller):
|
||||
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),
|
||||
])
|
||||
assignments = Assignment.search([('state', '=', 'active')])
|
||||
visible = []
|
||||
for a in assignments:
|
||||
if a.mode == 'students' and user.id in a.student_user_ids.ids:
|
||||
@@ -548,6 +799,9 @@ class CoursePlanController(http.Controller):
|
||||
continue
|
||||
if a.mode == 'batch' and user.id in a.expand_user_ids():
|
||||
visible.append(a)
|
||||
continue
|
||||
if a.mode == 'entities' and user.id in a.expand_user_ids():
|
||||
visible.append(a)
|
||||
|
||||
seen = set()
|
||||
out = []
|
||||
@@ -582,6 +836,9 @@ class CoursePlanController(http.Controller):
|
||||
if a.mode == 'batch' and user.id in a.expand_user_ids():
|
||||
allowed = True
|
||||
break
|
||||
if a.mode == 'entities' 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({
|
||||
|
||||
@@ -54,6 +54,13 @@ class CoursePlan(models.Model):
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
name = fields.Char(required=True)
|
||||
entity_id = fields.Many2one(
|
||||
'encoach.entity',
|
||||
string='Entity',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
help='Owning entity/organization for LMS isolation.',
|
||||
)
|
||||
course_id = fields.Many2one('op.course', ondelete='set null', string='Linked course')
|
||||
cefr_level = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'),
|
||||
@@ -142,6 +149,15 @@ class CoursePlan(models.Model):
|
||||
rec.media_count = len(rec.media_ids)
|
||||
rec.assignment_count = len(rec.assignment_ids)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
user = self.env.user.sudo()
|
||||
default_entity = user.entity_ids[:1].id if hasattr(user, 'entity_ids') else False
|
||||
for vals in vals_list:
|
||||
if vals.get('entity_id') is None and default_entity:
|
||||
vals['entity_id'] = default_entity
|
||||
return super().create(vals_list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Serialisation helpers — used by the REST controller so payload
|
||||
# shape stays in a single, obvious place.
|
||||
@@ -161,6 +177,8 @@ class CoursePlan(models.Model):
|
||||
data = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'entity_id': self.entity_id.id if self.entity_id else None,
|
||||
'entity_name': self.entity_id.name if self.entity_id else '',
|
||||
'course_id': self.course_id.id if self.course_id else None,
|
||||
'course_name': self.course_id.name if self.course_id else '',
|
||||
'cefr_level': self.cefr_level or '',
|
||||
@@ -264,6 +282,13 @@ class CoursePlanMaterial(models.Model):
|
||||
MATERIAL_TYPE_SELECTION, required=True, default='other',
|
||||
)
|
||||
title = fields.Char(required=True)
|
||||
is_static = fields.Boolean(
|
||||
default=False,
|
||||
help='When enabled, regenerate-week keeps this material untouched.',
|
||||
)
|
||||
share_date = fields.Date(
|
||||
help='Optional date when the material becomes visible/shared.',
|
||||
)
|
||||
summary = fields.Text(
|
||||
help='Short blurb — purpose / learning outcomes targeted / how to use.',
|
||||
)
|
||||
@@ -300,6 +325,8 @@ class CoursePlanMaterial(models.Model):
|
||||
'skill': self.skill or '',
|
||||
'material_type': self.material_type or 'other',
|
||||
'title': self.title or '',
|
||||
'is_static': bool(self.is_static),
|
||||
'share_date': self.share_date.isoformat() if self.share_date else None,
|
||||
'summary': self.summary or '',
|
||||
'body': self._loads(self.body_json, {}),
|
||||
'body_text': self.body_text or '',
|
||||
|
||||
@@ -22,6 +22,7 @@ _logger = logging.getLogger(__name__)
|
||||
ASSIGNMENT_MODE_SELECTION = [
|
||||
('batch', 'Class / Batch'),
|
||||
('students', 'Specific students'),
|
||||
('entities', 'Entities'),
|
||||
]
|
||||
|
||||
ASSIGNMENT_STATE_SELECTION = [
|
||||
@@ -47,6 +48,10 @@ class CoursePlanAssignment(models.Model):
|
||||
'res.users', 'course_plan_assignment_student_rel',
|
||||
'assignment_id', 'user_id', string='Specific students',
|
||||
)
|
||||
entity_ids = fields.Many2many(
|
||||
'encoach.entity', 'course_plan_assignment_entity_rel',
|
||||
'assignment_id', 'entity_id', string='Entities',
|
||||
)
|
||||
|
||||
assigned_by_id = fields.Many2one(
|
||||
'res.users', default=lambda self: self.env.user, string='Assigned by',
|
||||
@@ -57,7 +62,7 @@ class CoursePlanAssignment(models.Model):
|
||||
|
||||
student_count = fields.Integer(compute='_compute_student_count', store=False)
|
||||
|
||||
@api.depends('mode', 'batch_id', 'student_user_ids')
|
||||
@api.depends('mode', 'batch_id', 'student_user_ids', 'entity_ids')
|
||||
def _compute_student_count(self):
|
||||
Enroll = self.env['op.student.course'].sudo()
|
||||
Batch = self.env['op.batch'].sudo()
|
||||
@@ -65,6 +70,14 @@ class CoursePlanAssignment(models.Model):
|
||||
if rec.mode == 'students':
|
||||
rec.student_count = len(rec.student_user_ids)
|
||||
continue
|
||||
if rec.mode == 'entities':
|
||||
user_ids = set()
|
||||
for entity in rec.entity_ids:
|
||||
for user in entity.user_ids:
|
||||
if user and user.id:
|
||||
user_ids.add(user.id)
|
||||
rec.student_count = len(user_ids)
|
||||
continue
|
||||
if not rec.batch_id:
|
||||
rec.student_count = 0
|
||||
continue
|
||||
@@ -85,6 +98,13 @@ class CoursePlanAssignment(models.Model):
|
||||
self.ensure_one()
|
||||
if self.mode == 'students':
|
||||
return self.student_user_ids.ids
|
||||
if self.mode == 'entities':
|
||||
user_ids = []
|
||||
for entity in self.entity_ids:
|
||||
for user in entity.user_ids:
|
||||
if user and user.id:
|
||||
user_ids.append(user.id)
|
||||
return list(set(user_ids))
|
||||
if self.mode == 'batch' and self.batch_id:
|
||||
try:
|
||||
Enroll = self.env['op.student.course'].sudo()
|
||||
@@ -118,6 +138,8 @@ class CoursePlanAssignment(models.Model):
|
||||
'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],
|
||||
'entity_ids': self.entity_ids.ids,
|
||||
'entity_names': [e.name for e in self.entity_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 '',
|
||||
|
||||
@@ -26,12 +26,25 @@ MEDIA_KIND_SELECTION = [
|
||||
]
|
||||
|
||||
MEDIA_PROVIDER_SELECTION = [
|
||||
# ── Paid providers ──
|
||||
('polly', 'AWS Polly'),
|
||||
('elevenlabs', 'ElevenLabs'),
|
||||
('openai_image', 'OpenAI (DALL-E)'),
|
||||
('ffmpeg', 'ffmpeg (slideshow)'),
|
||||
('elai', 'Elai.io'),
|
||||
# ── Free fallbacks (Phase 24.1) ──
|
||||
('pillow', 'Pillow placeholder (offline)'),
|
||||
('unsplash', 'Unsplash Source (free)'),
|
||||
('gtts', 'gTTS (free TTS)'),
|
||||
('silent', 'Silent stub'),
|
||||
('static', 'Static image as video'),
|
||||
('mock', 'Mock'),
|
||||
# ── Composers / manual ──
|
||||
('ffmpeg', 'ffmpeg (slideshow)'),
|
||||
('manual', 'Manual upload'),
|
||||
# Sentinel value used while the chain is still resolving — written
|
||||
# transiently by MediaService.create() and overwritten with the
|
||||
# successful provider name once a fallback step succeeds.
|
||||
('auto', 'Auto (fallback chain)'),
|
||||
]
|
||||
|
||||
MEDIA_STATUS_SELECTION = [
|
||||
@@ -76,8 +89,16 @@ class CoursePlanMedia(models.Model):
|
||||
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>).',
|
||||
compute='_compute_media_urls', store=False,
|
||||
help='Authenticated REST URL that serves the binary as an attachment '
|
||||
'(``Content-Disposition: attachment``). Frontend appends '
|
||||
'``?token=<jwt>`` for download buttons.',
|
||||
)
|
||||
preview_url = fields.Char(
|
||||
compute='_compute_media_urls', store=False,
|
||||
help='Authenticated REST URL that serves the binary inline so it can '
|
||||
'be used as the ``src`` for ``<img>`` / ``<audio>`` / ``<video>`` '
|
||||
'elements. Frontend appends ``?token=<jwt>``.',
|
||||
)
|
||||
|
||||
status = fields.Selection(MEDIA_STATUS_SELECTION, default='queued')
|
||||
@@ -89,13 +110,24 @@ class CoursePlanMedia(models.Model):
|
||||
)
|
||||
|
||||
@api.depends('attachment_id')
|
||||
def _compute_download_url(self):
|
||||
def _compute_media_urls(self):
|
||||
# Both URLs hit the same JWT-protected streaming endpoint exposed by
|
||||
# ``encoach_ai_course.controllers.course_plan.CoursePlanController.
|
||||
# stream_media``. The endpoint accepts the JWT either as a Bearer
|
||||
# header (REST clients) or as a ``?token=`` query param so plain
|
||||
# ``<img>`` / ``<audio>`` / ``<video>`` tags can render the asset
|
||||
# without us proxying every request through fetch + blob URLs.
|
||||
# ``download=1`` only changes the Content-Disposition: attachment
|
||||
# header so the same route serves both inline previews and explicit
|
||||
# downloads.
|
||||
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 ''
|
||||
)
|
||||
if not rec.id:
|
||||
rec.download_url = ''
|
||||
rec.preview_url = ''
|
||||
continue
|
||||
base = f'/api/ai/course-plan/media/{rec.id}/raw'
|
||||
rec.preview_url = base
|
||||
rec.download_url = f'{base}?download=1'
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
@@ -117,6 +149,7 @@ class CoursePlanMedia(models.Model):
|
||||
'height': self.height or 0,
|
||||
'attachment_id': self.attachment_id.id if self.attachment_id else None,
|
||||
'download_url': self.download_url,
|
||||
'preview_url': self.preview_url,
|
||||
'status': self.status or 'queued',
|
||||
'error': self.error or '',
|
||||
'cost_cents': self.cost_cents or 0,
|
||||
|
||||
@@ -25,6 +25,13 @@ SOURCE_KIND_SELECTION = [
|
||||
('file', 'File'),
|
||||
('url', 'URL'),
|
||||
('text', 'Inline text'),
|
||||
# ``resource`` is a soft-link to the central ``encoach.resource``
|
||||
# library so the admin can re-use a PDF / DOCX / link they already
|
||||
# uploaded under /admin/resources without re-uploading the binary.
|
||||
# The indexer dereferences the link at extraction time; the binary
|
||||
# itself stays in the library record so a single edit / rotation
|
||||
# propagates to every plan that grounds on it.
|
||||
('resource', 'Library resource'),
|
||||
]
|
||||
|
||||
SOURCE_STATUS_SELECTION = [
|
||||
@@ -56,6 +63,20 @@ class CoursePlanSource(models.Model):
|
||||
url = fields.Char(string='Source URL')
|
||||
inline_text = fields.Text(string='Inline text')
|
||||
|
||||
# Optional pointer to the central /admin/resources library. When set
|
||||
# the indexer pulls the binary / URL / inline text from the linked
|
||||
# ``encoach.resource`` instead of re-storing it on this row, which
|
||||
# avoids duplicating large PDFs across every plan that grounds on
|
||||
# the same library item.
|
||||
resource_id = fields.Many2one(
|
||||
'encoach.resource',
|
||||
string='Library resource',
|
||||
ondelete='set null',
|
||||
help='Link to a resource already uploaded under /admin/resources. '
|
||||
'The indexer reads the binary from the library at extraction '
|
||||
'time so updates to the library propagate to every plan.',
|
||||
)
|
||||
|
||||
auto_index = fields.Boolean(
|
||||
default=True,
|
||||
help='If true, indexing runs automatically on create. '
|
||||
@@ -74,18 +95,44 @@ class CoursePlanSource(models.Model):
|
||||
if rec.auto_index:
|
||||
try:
|
||||
rec.action_index()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
# If indexing crashes before SourceIndexer has a chance
|
||||
# to mark the row as ``failed`` (e.g. an unexpected
|
||||
# import or DB error) we MUST still set the status to
|
||||
# ``failed``; otherwise the source sits in ``pending``
|
||||
# forever and the deliverables UI can't show progress.
|
||||
_logger.exception('Auto-index failed for source %s', rec.id)
|
||||
try:
|
||||
rec.write({
|
||||
'status': 'failed',
|
||||
'error': str(exc)[:500],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return records
|
||||
|
||||
def action_index(self):
|
||||
"""(Re-)extract text and push chunks to the vector store."""
|
||||
"""(Re-)extract text and push chunks to the vector store.
|
||||
|
||||
Wraps each per-record indexing call so a failure on one source
|
||||
doesn't abort the loop and leave later records orphaned.
|
||||
"""
|
||||
from odoo.addons.encoach_ai_course.services.source_indexer import (
|
||||
SourceIndexer,
|
||||
)
|
||||
indexer = SourceIndexer(self.env)
|
||||
for rec in self:
|
||||
indexer = SourceIndexer(self.env)
|
||||
indexer.index(rec)
|
||||
try:
|
||||
indexer.index(rec)
|
||||
except Exception as exc:
|
||||
_logger.exception('Index failed for source %s', rec.id)
|
||||
try:
|
||||
rec.write({
|
||||
'status': 'failed',
|
||||
'error': str(exc)[:500],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def unlink(self):
|
||||
@@ -111,6 +158,11 @@ class CoursePlanSource(models.Model):
|
||||
'mime_type': self.mime_type or '',
|
||||
'url': self.url or '',
|
||||
'has_inline_text': bool(self.inline_text),
|
||||
'resource_id': self.resource_id.id if self.resource_id else None,
|
||||
'resource_name': self.resource_id.name if self.resource_id else '',
|
||||
'resource_type': (
|
||||
self.resource_id.type if self.resource_id else ''
|
||||
),
|
||||
'status': self.status or 'pending',
|
||||
'error': self.error or '',
|
||||
'chunks_count': self.chunks_count or 0,
|
||||
|
||||
@@ -29,6 +29,21 @@ try:
|
||||
except ImportError:
|
||||
OpenAIService = None
|
||||
|
||||
# Markers that indicate the OpenAI account is unusable until the operator
|
||||
# fixes billing / keys / quota — i.e. retrying right now will never help.
|
||||
# We mirror the OpenAI service's own non-retryable list so a 429 caused by
|
||||
# `insufficient_quota` triggers the free fallback instead of bubbling up
|
||||
# as a 500 from the wizard's "Finish" button.
|
||||
_AI_PERMANENT_FAILURE_MARKERS = (
|
||||
"insufficient_quota",
|
||||
"invalid_api_key",
|
||||
"incorrect_api_key",
|
||||
"account_deactivated",
|
||||
"billing_hard_limit_reached",
|
||||
"openai not configured",
|
||||
"ai is disabled",
|
||||
)
|
||||
|
||||
# AgentRuntime is the LangGraph-backed engine. When the feature flag
|
||||
# ``encoach_ai.use_langgraph_runtime`` is true (default) and an agent with
|
||||
# the matching key is configured, the pipeline routes through the agent
|
||||
@@ -43,6 +58,20 @@ except ImportError: # pragma: no cover - optional dep
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_permanent_ai_failure(message):
|
||||
"""Return True iff the AI error indicates a non-transient condition.
|
||||
|
||||
These are operator-fixable problems (out of credit, wrong key, AI
|
||||
feature switched off) where retrying would just hang the wizard. In
|
||||
those cases the pipeline degrades to a deterministic skeleton plan
|
||||
so the user still gets a usable record.
|
||||
"""
|
||||
if not message:
|
||||
return False
|
||||
low = str(message).lower()
|
||||
return any(m in low for m in _AI_PERMANENT_FAILURE_MARKERS)
|
||||
|
||||
|
||||
# JSON schema we coax the LLM into following. Keeping this as a prompt
|
||||
# string (rather than an OpenAI function call) makes it portable if the
|
||||
# underlying `chat_json` implementation ever changes providers.
|
||||
@@ -270,10 +299,46 @@ class CoursePlanPipeline:
|
||||
max_tokens=4096,
|
||||
action="course_plan.generate",
|
||||
)
|
||||
|
||||
# If the LLM is unavailable (quota exhausted, missing key,
|
||||
# disabled, network error) we degrade to a deterministic stub
|
||||
# plan instead of raising. The wizard's "Finish" button needs to
|
||||
# always succeed in producing a record the user can open and
|
||||
# iterate on; a hard failure here leaves the frontend stuck on a
|
||||
# 5+ minute spinner while the OpenAI client retries.
|
||||
used_fallback = False
|
||||
ai_error = None
|
||||
if content is None or 'error' in content:
|
||||
raise RuntimeError(
|
||||
(content or {}).get('error', 'AI generation failed.')
|
||||
)
|
||||
ai_error = (content or {}).get('error', 'AI generation failed.')
|
||||
if _is_permanent_ai_failure(ai_error):
|
||||
_logger.warning(
|
||||
"Course plan AI unavailable (%s); using free fallback",
|
||||
ai_error,
|
||||
)
|
||||
content = self._build_fallback_plan_content(
|
||||
title=title,
|
||||
cefr=cefr,
|
||||
total_weeks=total_weeks,
|
||||
contact_hours=contact_hours,
|
||||
skills_division=skills_division,
|
||||
grammar_focus=grammar_focus,
|
||||
resources=resources,
|
||||
learner_profile=learner_profile,
|
||||
)
|
||||
used_fallback = True
|
||||
else:
|
||||
# Genuine transient failure — surface it to the caller so
|
||||
# they can retry. The frontend toasts the error message.
|
||||
raise RuntimeError(ai_error)
|
||||
|
||||
description = (content.get('description') or '').strip()
|
||||
if used_fallback:
|
||||
description = (
|
||||
description
|
||||
+ "\n\n[Auto-generated skeleton — OpenAI unavailable. "
|
||||
"Update the AI provider settings or restore billing, "
|
||||
"then click Regenerate.]"
|
||||
).strip()
|
||||
|
||||
plan_vals = {
|
||||
'name': title,
|
||||
@@ -283,14 +348,14 @@ class CoursePlanPipeline:
|
||||
'total_weeks': total_weeks,
|
||||
'contact_hours_per_week': contact_hours,
|
||||
'skills_division': skills_division,
|
||||
'description': (content.get('description') or '').strip(),
|
||||
'description': description,
|
||||
'objectives_json': json.dumps(content.get('objectives') or [], ensure_ascii=False),
|
||||
'outcomes_json': json.dumps(content.get('outcomes') or {}, ensure_ascii=False),
|
||||
'grammar_json': json.dumps(content.get('grammar') or [], ensure_ascii=False),
|
||||
'assessment_json': json.dumps(content.get('assessment') or {}, ensure_ascii=False),
|
||||
'resources_json': json.dumps(content.get('resources') or [], ensure_ascii=False),
|
||||
'brief_json': json.dumps(brief, ensure_ascii=False),
|
||||
'status': 'generated',
|
||||
'status': 'generated' if not used_fallback else 'draft',
|
||||
}
|
||||
if brief.get('course_id'):
|
||||
try:
|
||||
@@ -370,10 +435,23 @@ class CoursePlanPipeline:
|
||||
max_tokens=6000,
|
||||
action="course_plan.generate_week",
|
||||
)
|
||||
used_week_fallback = False
|
||||
if content is None or 'error' in content:
|
||||
raise RuntimeError(
|
||||
(content or {}).get('error', 'AI generation failed.')
|
||||
)
|
||||
ai_error = (content or {}).get('error', 'AI generation failed.')
|
||||
if _is_permanent_ai_failure(ai_error):
|
||||
_logger.warning(
|
||||
"Week materials AI unavailable (%s); using free fallback",
|
||||
ai_error,
|
||||
)
|
||||
content = self._build_fallback_week_content(
|
||||
plan_name=plan.name,
|
||||
cefr=(plan.cefr_level or "").lower(),
|
||||
week_number=week.week_number,
|
||||
items=items,
|
||||
)
|
||||
used_week_fallback = True
|
||||
else:
|
||||
raise RuntimeError(ai_error)
|
||||
|
||||
# Wipe any previous materials for this week so re-generating is
|
||||
# idempotent and we never accumulate duplicates.
|
||||
@@ -381,19 +459,28 @@ class CoursePlanPipeline:
|
||||
('plan_id', '=', plan.id), ('week_id', '=', week.id),
|
||||
])
|
||||
if existing:
|
||||
existing.unlink()
|
||||
# Keep instructor-curated static rows; only replace generated ones.
|
||||
existing.filtered(lambda m: not m.is_static).unlink()
|
||||
|
||||
Material = self.env['encoach.course.plan.material'].sudo()
|
||||
created = []
|
||||
skeleton_note = (
|
||||
"[Auto-generated skeleton — OpenAI unavailable. "
|
||||
"Update the AI provider settings or restore billing, "
|
||||
"then regenerate this week's materials.]"
|
||||
)
|
||||
for m in content.get('materials') or []:
|
||||
try:
|
||||
summary = (m.get('summary') or '').strip()
|
||||
if used_week_fallback:
|
||||
summary = (summary + "\n\n" + skeleton_note).strip()
|
||||
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(),
|
||||
'summary': summary,
|
||||
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
|
||||
'body_text': self._flatten_body(m.get('body') or {}),
|
||||
})
|
||||
@@ -440,10 +527,21 @@ class CoursePlanPipeline:
|
||||
payload=user_msg,
|
||||
extra_system=system_msg,
|
||||
)
|
||||
if final.get("error"):
|
||||
agent_error = final.get("error")
|
||||
if agent_error:
|
||||
# Permanent failures (no quota, bad key, AI off) will fail
|
||||
# the same way through the legacy chat_json path, so don't
|
||||
# double the wait — surface the error and let the caller
|
||||
# decide (generate_plan triggers the free fallback).
|
||||
if _is_permanent_ai_failure(agent_error):
|
||||
_logger.warning(
|
||||
"agent %s permanent failure (%s); skipping legacy fallback",
|
||||
agent_key, agent_error,
|
||||
)
|
||||
return {"error": agent_error}
|
||||
_logger.warning(
|
||||
"agent %s failed (%s); falling back to direct chat_json",
|
||||
agent_key, final.get("error"),
|
||||
agent_key, agent_error,
|
||||
)
|
||||
else:
|
||||
output = final.get("output")
|
||||
@@ -465,6 +563,186 @@ class CoursePlanPipeline:
|
||||
action=action,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Free fallback — used when OpenAI returns a permanent failure (no
|
||||
# quota, bad key, AI disabled). We synthesize a structurally-valid
|
||||
# plan so the wizard still completes and the user gets a record they
|
||||
# can edit or regenerate. The shape mirrors the JSON schema in
|
||||
# _PLAN_JSON_HINT exactly so downstream serializers don't notice.
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _build_fallback_plan_content(
|
||||
*, title, cefr, total_weeks, contact_hours, skills_division,
|
||||
grammar_focus, resources, learner_profile,
|
||||
):
|
||||
cefr_upper = (cefr or "a2").upper()
|
||||
skills_text = (skills_division or "").strip() or (
|
||||
"Reading & Writing balanced with Listening & Speaking"
|
||||
)
|
||||
outcomes = {
|
||||
"reading": [{"code": "RLO1", "description": f"Read level-appropriate ({cefr_upper}) texts and identify main ideas."}],
|
||||
"writing": [{"code": "WLO1", "description": "Plan and write short structured paragraphs on familiar topics."}],
|
||||
"listening": [{"code": "LLO1", "description": "Follow short dialogues and monologues at normal speed."}],
|
||||
"speaking": [{"code": "SLO1", "description": "Hold short conversations on personal and study-related topics."}],
|
||||
"vocabulary": [{"code": "VLO1", "description": "Use a level-appropriate active vocabulary across the four skills."}],
|
||||
"grammar": [{"code": "GLO1", "description": "Use the targeted grammar structures accurately in context."}],
|
||||
}
|
||||
grammar_blocks = [
|
||||
{"code": f"GT{i+1}", "label": label.strip() or f"Topic {i+1}",
|
||||
"sub_items": []}
|
||||
for i, label in enumerate((grammar_focus or [])[:6])
|
||||
] or [
|
||||
{"code": "GT1", "label": "Present tenses", "sub_items": ["present simple", "present continuous"]},
|
||||
{"code": "GT2", "label": "Past tenses", "sub_items": ["past simple", "past continuous"]},
|
||||
]
|
||||
weeks = []
|
||||
for w in range(1, max(1, int(total_weeks or 12)) + 1):
|
||||
weeks.append({
|
||||
"week_number": w,
|
||||
"date_label": f"Week {w}",
|
||||
"unit": f"Unit {((w - 1) // 2) + 1}",
|
||||
"focus": f"Skeleton focus for week {w} — replace via Regenerate.",
|
||||
"items": [
|
||||
{"skill": "reading", "outcome_codes": ["RLO1"], "remarks": ""},
|
||||
{"skill": "writing", "outcome_codes": ["WLO1"], "remarks": ""},
|
||||
{"skill": "listening", "outcome_codes": ["LLO1"], "remarks": ""},
|
||||
{"skill": "speaking", "outcome_codes": ["SLO1"], "remarks": ""},
|
||||
{"skill": "grammar", "outcome_codes": ["GLO1"], "remarks": ""},
|
||||
],
|
||||
})
|
||||
return {
|
||||
"description": (
|
||||
f"{cefr_upper} general course over {total_weeks} weeks, "
|
||||
f"approximately {contact_hours} contact hours per week. "
|
||||
f"Coverage: {skills_text}. "
|
||||
f"Profile: {learner_profile or 'mixed adult learners'}."
|
||||
),
|
||||
"objectives": [
|
||||
f"Develop integrated {cefr_upper}-level skills across reading, writing, listening and speaking.",
|
||||
"Build active vocabulary and accurate use of target grammar.",
|
||||
"Use language confidently in personal, social and study contexts.",
|
||||
],
|
||||
"outcomes": outcomes,
|
||||
"grammar": grammar_blocks,
|
||||
"assessment": {
|
||||
"continuous_assessment": {
|
||||
"total_weight": 50,
|
||||
"components": [
|
||||
{"name": "MTE", "weight": 30},
|
||||
{"name": "Continuous tasks", "weight": 20},
|
||||
],
|
||||
},
|
||||
"final_exam": {"total_weight": 50},
|
||||
},
|
||||
"resources": [
|
||||
{"type": "textbook", "citation": (resources[0] if resources else "TBD — replace via Regenerate")},
|
||||
],
|
||||
"weeks": weeks,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Free fallback for per-week materials. Mirrors the schema in
|
||||
# _WEEK_JSON_HINT — placeholder content per skill so the teacher
|
||||
# has a starter row they can edit or regenerate later.
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _build_fallback_week_content(*, plan_name, cefr, week_number, items):
|
||||
cefr_upper = (cefr or "a2").upper()
|
||||
# Only produce materials for the skills that the week's plan
|
||||
# actually contains, so the teacher's outline is preserved.
|
||||
wanted_skills = []
|
||||
seen = set()
|
||||
for it in items or []:
|
||||
s = (it.get("skill") or "").strip().lower()
|
||||
if s and s not in seen:
|
||||
wanted_skills.append(s)
|
||||
seen.add(s)
|
||||
if not wanted_skills:
|
||||
wanted_skills = ["reading", "writing", "listening", "speaking", "grammar", "vocabulary"]
|
||||
|
||||
templates = {
|
||||
"reading": {
|
||||
"material_type": "reading_text",
|
||||
"title": f"Week {week_number} reading — placeholder",
|
||||
"summary": f"Skeleton reading task at {cefr_upper}. Replace via Regenerate.",
|
||||
"body": {
|
||||
"text": (
|
||||
"Placeholder reading passage. Replace with a "
|
||||
f"{cefr_upper}-level text of around 400 words on "
|
||||
"a familiar personal or study-related topic."
|
||||
),
|
||||
"questions": [
|
||||
{"q": "What is the main idea?", "type": "short_answer", "answer": "TBD"},
|
||||
],
|
||||
},
|
||||
},
|
||||
"writing": {
|
||||
"material_type": "writing_prompt",
|
||||
"title": f"Week {week_number} writing — placeholder",
|
||||
"summary": "Skeleton writing task. Replace via Regenerate.",
|
||||
"body": {
|
||||
"prompt": "Write a short paragraph about your weekly routine.",
|
||||
"word_count": 150,
|
||||
"model_paragraph": "(Add a model paragraph after regenerating.)",
|
||||
},
|
||||
},
|
||||
"listening": {
|
||||
"material_type": "listening_script",
|
||||
"title": f"Week {week_number} listening — placeholder",
|
||||
"summary": "Skeleton listening script. Replace via Regenerate.",
|
||||
"body": {
|
||||
"script": (
|
||||
"(Placeholder script.) Two friends discuss their "
|
||||
"morning routines and weekend plans."
|
||||
),
|
||||
"comprehension_questions": [
|
||||
{"q": "Who is speaking?", "answer": "Two friends."},
|
||||
],
|
||||
},
|
||||
},
|
||||
"speaking": {
|
||||
"material_type": "speaking_prompt",
|
||||
"title": f"Week {week_number} speaking — placeholder",
|
||||
"summary": "Skeleton speaking prompts. Replace via Regenerate.",
|
||||
"body": {
|
||||
"prompts": [
|
||||
"Describe a typical day in your life.",
|
||||
"Talk about something you do at the weekend.",
|
||||
],
|
||||
"useful_language": ["usually", "often", "sometimes", "never"],
|
||||
},
|
||||
},
|
||||
"grammar": {
|
||||
"material_type": "grammar_lesson",
|
||||
"title": f"Week {week_number} grammar — placeholder",
|
||||
"summary": "Skeleton grammar mini-lesson. Replace via Regenerate.",
|
||||
"body": {
|
||||
"explanation": "Target structure for the week — replace via Regenerate.",
|
||||
"examples": ["I work every day.", "She doesn't drink coffee."],
|
||||
"practice": [
|
||||
{"q": "He ___ (work) in a bank.", "answer": "works"},
|
||||
],
|
||||
},
|
||||
},
|
||||
"vocabulary": {
|
||||
"material_type": "vocabulary_list",
|
||||
"title": f"Week {week_number} vocabulary — placeholder",
|
||||
"summary": "Skeleton vocabulary set. Replace via Regenerate.",
|
||||
"body": {
|
||||
"words": [
|
||||
{"term": "routine", "pos": "n.", "definition": "a regular sequence of activities", "example": "My morning routine is busy."},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
materials = []
|
||||
for skill in wanted_skills:
|
||||
t = templates.get(skill)
|
||||
if t is None:
|
||||
continue
|
||||
materials.append({"skill": skill, **t})
|
||||
return {"materials": materials}
|
||||
|
||||
@staticmethod
|
||||
def _flatten_body(body):
|
||||
"""Produce a plain-text dump of a material body for quick preview.
|
||||
|
||||
@@ -4,35 +4,30 @@ 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.
|
||||
PROVIDER FALLBACK CHAIN (Phase 24.1, Apr 2026)
|
||||
==============================================
|
||||
Every modality now tries providers in order: the explicitly-requested or
|
||||
admin-configured paid provider first, then a sequence of *free* fallbacks.
|
||||
A request that hits a billing/quota error (HTTP 402/429, OpenAI
|
||||
``insufficient_quota``, AWS Polly ``ThrottlingException``, ElevenLabs
|
||||
character-limit, etc.) is silently retried against the next provider in
|
||||
the chain — the request never fails just because the admin's API key has
|
||||
run out of credit.
|
||||
|
||||
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.
|
||||
* Image: ``openai (DALL-E 3) → pillow (offline placeholder) → unsplash``.
|
||||
* Audio: ``polly | elevenlabs → gtts → silent-stub``.
|
||||
* Video: ``ffmpeg (image+audio) → static (text-only image card)``.
|
||||
|
||||
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.
|
||||
The active provider per capability is read fresh from
|
||||
``ir.config_parameter`` on every call (see
|
||||
:mod:`encoach_ai.services.provider_router`) so toggling a provider in
|
||||
the admin UI takes effect on the very next request without restarting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -42,6 +37,12 @@ from typing import Optional
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
from odoo.addons.encoach_ai.services import provider_router
|
||||
from odoo.addons.encoach_ai.services.provider_router import (
|
||||
classify_provider_error,
|
||||
should_fallback,
|
||||
)
|
||||
|
||||
|
||||
# --- Helpers ----------------------------------------------------------------
|
||||
|
||||
@@ -63,18 +64,16 @@ def _attach_bytes(env, *, name, mime_type, data: bytes,
|
||||
})
|
||||
|
||||
|
||||
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."""
|
||||
"""Raise if generating ``planned_images`` would exceed the per-plan cap.
|
||||
|
||||
The budget only applies to *paid* image providers (DALL-E). Free
|
||||
fallbacks (Pillow, Unsplash) are unmetered.
|
||||
"""
|
||||
cap = int(_get_param(env, 'encoach_ai_course.image_budget_per_plan', '60'))
|
||||
if cap <= 0:
|
||||
return
|
||||
@@ -82,12 +81,14 @@ def _enforce_image_budget(env, plan, planned_images: int = 1) -> None:
|
||||
used = Media.search_count([
|
||||
('plan_id', '=', plan.id),
|
||||
('kind', '=', 'image'),
|
||||
('provider', '=', 'openai_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.'
|
||||
f'Paid image budget exceeded for this plan: {used} used, cap is '
|
||||
f'{cap}. Raise encoach_ai_course.image_budget_per_plan or delete '
|
||||
f'old DALL-E images. Free Pillow/Unsplash fallbacks remain available.'
|
||||
)
|
||||
|
||||
|
||||
@@ -130,7 +131,6 @@ def _build_image_prompt(material, *, plan) -> str:
|
||||
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
|
||||
@@ -144,11 +144,31 @@ def _build_image_prompt(material, *, plan) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _image_subtitle(material, *, plan):
|
||||
"""Short subtitle string used by the offline placeholder card."""
|
||||
cefr = (plan.cefr_level or '').upper() or '—'
|
||||
parts = [f'CEFR {cefr}']
|
||||
if material.week_number:
|
||||
parts.append(f'Week {material.week_number}')
|
||||
if material.material_type:
|
||||
parts.append(material.material_type.replace('_', ' ').title())
|
||||
return ' · '.join(parts)
|
||||
|
||||
|
||||
# --- Public service ---------------------------------------------------------
|
||||
|
||||
|
||||
class MediaService:
|
||||
"""Generate audio / image / video assets for a course-plan material."""
|
||||
"""Generate audio / image / video assets for a course-plan material.
|
||||
|
||||
All three public methods (:meth:`synthesize_audio`,
|
||||
:meth:`generate_image`, :meth:`compose_video`) walk a provider chain
|
||||
and degrade gracefully — they never raise to the controller as long
|
||||
as at least one free fallback is wired up. The persisted
|
||||
``encoach.course.plan.media`` row records *which* provider actually
|
||||
succeeded, plus any errors hit along the way (concatenated into
|
||||
``error`` for diagnostics).
|
||||
"""
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
@@ -157,8 +177,19 @@ class MediaService:
|
||||
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."""
|
||||
provider: str = 'auto') -> 'models.Model':
|
||||
"""Generate a narration MP3 for ``material`` and persist it.
|
||||
|
||||
The ``provider`` argument behaves like the admin setting:
|
||||
|
||||
* ``'auto'`` — try paid then free fallbacks
|
||||
* a specific name (``polly``, ``elevenlabs``, ``gtts``,
|
||||
``silent``) — pin that provider; the chain still falls back
|
||||
to the free providers if it errors out
|
||||
|
||||
Even if every provider fails, the row is marked ``failed`` with
|
||||
a helpful error rather than raising.
|
||||
"""
|
||||
Media = self.env['encoach.course.plan.media'].sudo()
|
||||
media = Media.create({
|
||||
'plan_id': material.plan_id.id,
|
||||
@@ -176,117 +207,252 @@ class MediaService:
|
||||
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]})
|
||||
|
||||
chain = provider_router.resolve_chain(
|
||||
self.env, 'audio', requested=provider if provider != 'auto' else None,
|
||||
)
|
||||
errors = []
|
||||
for prov in chain:
|
||||
try:
|
||||
result = self._call_audio_provider(
|
||||
prov, text=text, voice=voice,
|
||||
language=language, gender=gender,
|
||||
)
|
||||
content_type = result.get('content_type', 'audio/mpeg')
|
||||
ext = 'wav' if content_type == 'audio/wav' else 'mp3'
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{material.plan_id.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}.{ext}',
|
||||
mime_type=content_type,
|
||||
data=result['audio'],
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
'mime_type': content_type,
|
||||
'size_bytes': len(result['audio']),
|
||||
'voice': result.get('voice') or voice or '',
|
||||
'provider': prov,
|
||||
'status': 'ready',
|
||||
'error': '\n'.join(errors)[:500] if errors else False,
|
||||
})
|
||||
return media
|
||||
except Exception as exc:
|
||||
kind = classify_provider_error(exc)
|
||||
msg = f'[{prov}/{kind}] {str(exc)[:200]}'
|
||||
errors.append(msg)
|
||||
_logger.warning(
|
||||
'TTS provider %s failed (%s) for material %s — trying next',
|
||||
prov, kind, material.id,
|
||||
)
|
||||
if not should_fallback(exc) and prov != chain[-1]:
|
||||
# ``other`` errors (not quota/auth/network) suggest a
|
||||
# genuine input problem, not provider exhaustion. Keep
|
||||
# falling back anyway because the user just wants audio
|
||||
# produced — but log loudly so we notice in production.
|
||||
_logger.exception(
|
||||
'Unclassified TTS error from %s — continuing chain', prov,
|
||||
)
|
||||
media.write({
|
||||
'status': 'failed',
|
||||
'error': ('All audio providers failed: '
|
||||
+ ' | '.join(errors))[:500],
|
||||
})
|
||||
return media
|
||||
|
||||
def _call_tts(self, text, *, voice, language, gender, provider):
|
||||
def _call_audio_provider(self, provider, *, text, voice, language, gender):
|
||||
if provider == 'polly':
|
||||
from odoo.addons.encoach_ai.services.polly_service import PollyService
|
||||
return PollyService(self.env).synthesize(
|
||||
text, voice=voice, language=language, gender=gender,
|
||||
)
|
||||
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']
|
||||
res = ElevenLabsService(self.env).synthesize(
|
||||
text, voice_id=voice or None,
|
||||
)
|
||||
return {
|
||||
'audio': res.get('audio') or res.get('audio_bytes') or b'',
|
||||
'content_type': res.get('content_type', 'audio/mpeg'),
|
||||
'voice': res.get('voice') or voice or 'elevenlabs',
|
||||
'characters': len(text),
|
||||
}
|
||||
if provider == 'gtts':
|
||||
from odoo.addons.encoach_ai.services.free_tts import (
|
||||
synthesize_with_gtts,
|
||||
)
|
||||
return synthesize_with_gtts(text, language=language)
|
||||
if provider == 'silent':
|
||||
from odoo.addons.encoach_ai.services.free_tts import synthesize_silent
|
||||
# Pick a duration roughly proportional to the script so the
|
||||
# silent stub still gives the video composer enough length.
|
||||
seconds = max(1, min(30, len(text) // 12))
|
||||
return synthesize_silent(duration_seconds=seconds)
|
||||
raise RuntimeError(f'Unknown audio provider: {provider!r}')
|
||||
|
||||
# -- 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``."""
|
||||
quality: str = 'standard',
|
||||
provider: str = 'auto') -> 'models.Model':
|
||||
"""Generate an illustration for ``material`` with provider fallback."""
|
||||
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',
|
||||
'provider': provider,
|
||||
'title': f'{material.title} — illustration',
|
||||
'source_text': prompt[:3000],
|
||||
'style': style,
|
||||
'status': 'generating',
|
||||
})
|
||||
try:
|
||||
|
||||
chain = provider_router.resolve_chain(
|
||||
self.env, 'image', requested=provider if provider != 'auto' else None,
|
||||
)
|
||||
errors = []
|
||||
for prov in chain:
|
||||
try:
|
||||
# Only the paid OpenAI provider is metered against the budget.
|
||||
if prov in ('openai', 'openai_image'):
|
||||
_enforce_image_budget(self.env, plan, planned_images=1)
|
||||
result = self._call_image_provider(
|
||||
prov, prompt=prompt, size=size, style=style,
|
||||
quality=quality, material=material, plan=plan,
|
||||
)
|
||||
provider_label = 'openai_image' if prov in (
|
||||
'openai', 'openai_image') else prov
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{plan.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}.png',
|
||||
mime_type=result.get('mime_type', 'image/png'),
|
||||
data=result['image'],
|
||||
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': result.get('mime_type', 'image/png'),
|
||||
'size_bytes': len(result['image']),
|
||||
'width': w,
|
||||
'height': h,
|
||||
'provider': provider_label,
|
||||
'status': 'ready',
|
||||
'error': '\n'.join(errors)[:500] if errors else False,
|
||||
'cost_cents': (4 if quality == 'standard' else 8)
|
||||
if prov in ('openai', 'openai_image') else 0,
|
||||
})
|
||||
return media
|
||||
except Exception as exc:
|
||||
kind = classify_provider_error(exc)
|
||||
msg = f'[{prov}/{kind}] {str(exc)[:200]}'
|
||||
errors.append(msg)
|
||||
_logger.warning(
|
||||
'Image provider %s failed (%s) for material %s — trying next',
|
||||
prov, kind, material.id,
|
||||
)
|
||||
media.write({
|
||||
'status': 'failed',
|
||||
'error': ('All image providers failed: '
|
||||
+ ' | '.join(errors))[:500],
|
||||
})
|
||||
return media
|
||||
|
||||
def _call_image_provider(self, provider, *, prompt, size, style, quality,
|
||||
material, plan):
|
||||
if provider in ('openai', 'openai_image'):
|
||||
from odoo.addons.encoach_ai.services.openai_service import (
|
||||
OpenAIService,
|
||||
)
|
||||
svc = OpenAIService(self.env)
|
||||
result = svc.generate_image(
|
||||
res = OpenAIService(self.env).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,
|
||||
return {
|
||||
'image': res['image'],
|
||||
'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
|
||||
}
|
||||
if provider == 'pillow':
|
||||
from odoo.addons.encoach_ai.services.free_image import (
|
||||
render_placeholder,
|
||||
)
|
||||
png = render_placeholder(
|
||||
material.title or 'Course material',
|
||||
subtitle=_image_subtitle(material, plan=plan),
|
||||
size=size,
|
||||
seed=material.id,
|
||||
)
|
||||
return {'image': png, 'mime_type': 'image/png'}
|
||||
if provider == 'unsplash':
|
||||
return self._fetch_unsplash(prompt, size=size)
|
||||
if provider == 'mock':
|
||||
from odoo.addons.encoach_ai.services.free_image import (
|
||||
render_placeholder,
|
||||
)
|
||||
png = render_placeholder(
|
||||
'Mock provider',
|
||||
subtitle=material.title or '',
|
||||
size=size,
|
||||
seed=material.id,
|
||||
)
|
||||
return {'image': png, 'mime_type': 'image/png'}
|
||||
raise RuntimeError(f'Unknown image provider: {provider!r}')
|
||||
|
||||
def _fetch_unsplash(self, prompt, *, size='1024x1024'):
|
||||
"""Free Unsplash Source endpoint — no API key required.
|
||||
|
||||
Falls back to the offline Pillow placeholder if the network call
|
||||
fails so this provider, like all the others, is non-blocking.
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError('requests not installed') from exc
|
||||
# The "source" endpoint returns a redirect to a JPEG that matches
|
||||
# the keywords. We deliberately use only the first 5 keywords to
|
||||
# keep the URL short.
|
||||
words = ' '.join((prompt or '').split()[:5]).strip() or 'education'
|
||||
try:
|
||||
w, h = size.lower().split('x')
|
||||
except Exception:
|
||||
w, h = '1024', '1024'
|
||||
url = f'https://source.unsplash.com/{w}x{h}/?{words}'
|
||||
resp = requests.get(url, timeout=15, allow_redirects=True)
|
||||
if resp.status_code != 200 or not resp.content:
|
||||
raise RuntimeError(
|
||||
f'Unsplash returned HTTP {resp.status_code}'
|
||||
)
|
||||
return {'image': resp.content, 'mime_type': 'image/jpeg'}
|
||||
|
||||
# -- Video -----------------------------------------------------------
|
||||
def compose_video(self, material, *, audio_media=None,
|
||||
image_media=None) -> 'models.Model':
|
||||
image_media=None,
|
||||
provider: str = 'auto') -> '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.
|
||||
Strategy:
|
||||
|
||||
1. Try ``ffmpeg`` (real slideshow video) if ffmpeg is on PATH.
|
||||
2. Fall back to ``static`` — a 5-second MP4 generated purely
|
||||
from the placeholder image without external audio.
|
||||
|
||||
Like the audio/image methods, this never raises to the caller;
|
||||
it always returns a media row whose ``status`` reflects success
|
||||
or failure.
|
||||
"""
|
||||
Media = self.env['encoach.course.plan.media'].sudo()
|
||||
plan = material.plan_id
|
||||
@@ -295,93 +461,160 @@ class MediaService:
|
||||
'week_id': material.week_id.id if material.week_id else False,
|
||||
'material_id': material.id,
|
||||
'kind': 'video',
|
||||
'provider': 'ffmpeg',
|
||||
'provider': provider,
|
||||
'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"}'
|
||||
chain = provider_router.resolve_chain(
|
||||
self.env, 'video', requested=provider if provider != 'auto' else None,
|
||||
)
|
||||
errors = []
|
||||
for prov in chain:
|
||||
try:
|
||||
if prov == 'ffmpeg':
|
||||
return self._compose_video_ffmpeg(
|
||||
media, material, audio_media, image_media,
|
||||
)
|
||||
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,
|
||||
if prov == 'static':
|
||||
return self._compose_video_static(media, material)
|
||||
raise RuntimeError(f'Unknown video provider: {prov!r}')
|
||||
except Exception as exc:
|
||||
kind = classify_provider_error(exc)
|
||||
msg = f'[{prov}/{kind}] {str(exc)[:200]}'
|
||||
errors.append(msg)
|
||||
_logger.warning(
|
||||
'Video provider %s failed (%s) for material %s — trying next',
|
||||
prov, kind, material.id,
|
||||
)
|
||||
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]})
|
||||
media.write({
|
||||
'status': 'failed',
|
||||
'error': ('All video providers failed: '
|
||||
+ ' | '.join(errors))[:500],
|
||||
})
|
||||
return media
|
||||
|
||||
# ── Concrete video providers ────────────────────────────────────────
|
||||
|
||||
def _compose_video_ffmpeg(self, media, material, audio_media, image_media):
|
||||
"""Real slideshow MP4 via ffmpeg. Raises if ffmpeg not on PATH."""
|
||||
if shutil.which('ffmpeg') is None:
|
||||
raise RuntimeError('ffmpeg not found on PATH')
|
||||
|
||||
plan = material.plan_id
|
||||
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,
|
||||
'provider': 'ffmpeg',
|
||||
'status': 'ready',
|
||||
'error': False,
|
||||
})
|
||||
return media
|
||||
|
||||
def _compose_video_static(self, media, material):
|
||||
"""Last-resort: a tiny MP4-shaped image-only stub.
|
||||
|
||||
We don't pretend to render a true video without ffmpeg — instead
|
||||
we attach the placeholder PNG with an MP4 mime so the LMS can
|
||||
still display *something*. The media row is marked ``ready`` but
|
||||
flagged as ``static`` so admins can re-generate later.
|
||||
"""
|
||||
from odoo.addons.encoach_ai.services.free_image import (
|
||||
render_placeholder,
|
||||
)
|
||||
plan = material.plan_id
|
||||
png = render_placeholder(
|
||||
material.title or 'Course material',
|
||||
subtitle=_image_subtitle(material, plan=plan) + ' · static',
|
||||
size='1280x720',
|
||||
seed=material.id,
|
||||
)
|
||||
attach = _attach_bytes(
|
||||
self.env,
|
||||
name=f'plan-{plan.id}-week-{material.week_number}'
|
||||
f'-{material.material_type}-{material.id}-static.png',
|
||||
mime_type='image/png',
|
||||
data=png,
|
||||
res_model='encoach.course.plan.media',
|
||||
res_id=media.id,
|
||||
)
|
||||
media.write({
|
||||
'attachment_id': attach.id,
|
||||
'mime_type': 'image/png',
|
||||
'size_bytes': len(png),
|
||||
'duration_seconds': 0.0,
|
||||
'width': 1280,
|
||||
'height': 720,
|
||||
'provider': 'static',
|
||||
'status': 'ready',
|
||||
'error': 'ffmpeg not available — served as static placeholder image',
|
||||
})
|
||||
return media
|
||||
|
||||
@@ -17,6 +17,7 @@ error fields. That way one bad PDF doesn't block the rest.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
@@ -111,6 +112,69 @@ class SourceIndexer:
|
||||
if source.kind == 'text':
|
||||
return (source.inline_text or '').strip()
|
||||
|
||||
if source.kind == 'resource':
|
||||
# Dereference the library resource at extraction time. This
|
||||
# keeps the binary in one place (``encoach.resource``) so an
|
||||
# admin update — re-uploading a corrected PDF, fixing a URL,
|
||||
# changing the linked file — propagates to every plan that
|
||||
# grounds on it on the next reindex.
|
||||
res = source.resource_id
|
||||
if not res or not res.exists():
|
||||
raise ValueError(
|
||||
'Linked library resource is missing or was deleted.',
|
||||
)
|
||||
rtype = (res.type or '').lower()
|
||||
file_name = (res.name or '') + (
|
||||
f'.{rtype}' if rtype in ('pdf', 'docx') and not (res.name or '').lower().endswith(('.pdf', '.docx')) else ''
|
||||
)
|
||||
# Persist the resolved metadata on the source row so the UI
|
||||
# can render a meaningful "indexed N chunks from X.pdf" line
|
||||
# without having to rejoin the resource table on every read.
|
||||
updates = {}
|
||||
if not source.name:
|
||||
updates['name'] = res.name or f'Resource #{res.id}'
|
||||
if not source.file_name:
|
||||
updates['file_name'] = file_name
|
||||
if updates:
|
||||
source.write(updates)
|
||||
|
||||
if res.file:
|
||||
payload = base64.b64decode(res.file)
|
||||
if not payload:
|
||||
raise ValueError('Library resource has an empty file.')
|
||||
if rtype == 'pdf' or file_name.lower().endswith('.pdf'):
|
||||
return _extract_pdf(payload)
|
||||
if rtype == 'document' and file_name.lower().endswith(('.docx', '.doc')):
|
||||
return _extract_docx(payload)
|
||||
# Fall back to plain-text decoding for txt/md/csv/json.
|
||||
if file_name.lower().endswith((
|
||||
'.txt', '.md', '.markdown', '.csv', '.json', '.xml',
|
||||
'.log', '.rst',
|
||||
)):
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
# Best-effort: try PDF first, then DOCX, then UTF-8.
|
||||
for fn in (_extract_pdf, _extract_docx):
|
||||
try:
|
||||
text = fn(payload)
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
return payload.decode('utf-8', errors='replace').strip()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f'Cannot decode library resource binary: {exc}',
|
||||
) from exc
|
||||
|
||||
if res.url:
|
||||
_, text = _fetch_url(res.url)
|
||||
return text or ''
|
||||
|
||||
raise ValueError(
|
||||
'Library resource has neither a file nor a URL to index.',
|
||||
)
|
||||
|
||||
if source.kind == 'url':
|
||||
url = (source.url or '').strip()
|
||||
if not url:
|
||||
@@ -133,10 +197,24 @@ class SourceIndexer:
|
||||
'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
|
||||
# Whitelist plain-text-shaped uploads explicitly. Anything else
|
||||
# (xlsx, png, mp3, zip, …) must be rejected with a clear error
|
||||
# rather than silently UTF-8-decoded into garbage that we'd
|
||||
# then "successfully" embed and surface as a usable RAG source.
|
||||
if (mime.startswith('text/')
|
||||
or mime in ('application/json', 'application/xml',
|
||||
'application/csv')
|
||||
or name.endswith(('.txt', '.md', '.markdown', '.csv',
|
||||
'.json', '.xml', '.log', '.rst'))):
|
||||
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'Unsupported file type for RAG indexing: '
|
||||
f'mime={mime!r} name={source.file_name!r}. '
|
||||
f'Supported: PDF, DOCX/DOC, plain text (txt/md/csv/json/xml).'
|
||||
)
|
||||
|
||||
raise ValueError(f'Unknown source kind: {source.kind!r}')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user