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