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:
Yamen Ahmad
2026-04-26 02:34:52 +04:00
parent afd1662a60
commit cd47d01f53
26 changed files with 2795 additions and 338 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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}')