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

@@ -18,6 +18,10 @@
"depends": ["base", "encoach_core", "encoach_api"],
"external_dependencies": {
"python": ["openai", "boto3", "langgraph", "langchain_core"],
# Soft deps used only by free media fallbacks; the platform still
# boots and works fine without them — see services/free_image.py
# and services/free_tts.py for graceful import-failure handling.
# Add to a real requirements file: ``pip install Pillow gTTS``.
},
"data": [
"security/ir.model.access.csv",

View File

@@ -4,3 +4,4 @@ from . import media_controller
from . import prompt_controller
from . import feedback_controller
from . import agents_controller
from . import ai_settings_controller

View File

@@ -0,0 +1,292 @@
"""Admin endpoints for AI provider selection and API-key management.
* ``GET /api/ai/settings/providers`` — current provider per capability,
redacted view of which API keys are present (booleans only — keys are
*never* echoed back), and the list of allowed providers per capability.
* ``PATCH /api/ai/settings/providers`` — write provider choices and/or
API keys to ``ir.config_parameter``. Settings take effect on the very
next request (no caching), so admins can flip providers without an
Odoo restart.
The controller is admin-gated:
* The caller must be authenticated (``@jwt_required``).
* The caller must have ``user_type == 'admin'`` *or* be in the
``base.group_system`` group. Anything else returns 403.
API-key fields are write-only over the wire. Sending an empty string
clears the key; omitting the field leaves it unchanged. The GET response
returns ``{"openai_key_set": true | false, ...}`` markers so the UI can
render a "saved · click to replace" state without ever leaking the
secret value.
"""
from __future__ import annotations
import json
import logging
from odoo import http
from odoo.http import request, Response
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body,
)
from odoo.addons.encoach_ai.services import provider_router
_logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration tables
# ---------------------------------------------------------------------------
# Provider keys per capability — mirrors provider_router.CAPABILITIES but
# adds UI labels and the "kind" so the frontend can render appropriate
# icons / disclaimers.
_PROVIDER_OPTIONS = {
'text': [
{'value': 'openai', 'label': 'OpenAI (GPT-4o)', 'kind': 'paid'},
{'value': 'mock', 'label': 'Mock (deterministic stub)', 'kind': 'free'},
],
'image': [
{'value': 'auto', 'label': 'Auto (paid → free fallback)', 'kind': 'auto'},
{'value': 'openai', 'label': 'OpenAI (DALL-E 3)', 'kind': 'paid'},
{'value': 'pillow', 'label': 'Pillow placeholder (offline)', 'kind': 'free'},
{'value': 'unsplash', 'label': 'Unsplash Source (free, network)', 'kind': 'free'},
{'value': 'mock', 'label': 'Mock card', 'kind': 'free'},
],
'audio': [
{'value': 'auto', 'label': 'Auto (paid → free fallback)', 'kind': 'auto'},
{'value': 'polly', 'label': 'AWS Polly (neural)', 'kind': 'paid'},
{'value': 'elevenlabs', 'label': 'ElevenLabs (multilingual)', 'kind': 'paid'},
{'value': 'gtts', 'label': 'gTTS (free, network)', 'kind': 'free'},
{'value': 'silent', 'label': 'Silent stub (offline)', 'kind': 'free'},
],
'video': [
{'value': 'auto', 'label': 'Auto', 'kind': 'auto'},
{'value': 'ffmpeg', 'label': 'ffmpeg slideshow (image+audio)', 'kind': 'free'},
{'value': 'static', 'label': 'Static placeholder image', 'kind': 'free'},
],
}
# API-key params managed by this endpoint. Keys are write-only — the
# response only ever returns ``<name>_set: bool``.
_KEY_PARAMS = {
'openai_api_key': 'encoach_ai.openai_api_key',
'aws_access_key': 'encoach_ai.aws_access_key',
'aws_secret_key': 'encoach_ai.aws_secret_key',
'aws_region': 'encoach_ai.aws_region',
'elevenlabs_api_key': 'encoach_ai.elevenlabs_api_key',
'gptzero_api_key': 'encoach_ai.gptzero_api_key',
# Paymob (payments) — included so all platform secrets live in one UI
'paymob_api_key': 'encoach.paymob.api_key',
'paymob_integration_id': 'encoach.paymob.integration_id',
'paymob_iframe_id': 'encoach.paymob.iframe_id',
'paymob_hmac_secret': 'encoach.paymob.hmac_secret',
}
# These params don't carry secrets so we expose their plaintext values.
_PLAIN_PARAMS = {
'aws_region': 'encoach_ai.aws_region',
'openai_model': 'encoach_ai.openai_model',
'openai_fast_model': 'encoach_ai.openai_fast_model',
'elevenlabs_model': 'encoach_ai.elevenlabs_model',
'request_timeout': 'encoach_ai.request_timeout',
'max_retries': 'encoach_ai.max_retries',
'enabled': 'encoach_ai.enabled',
}
# ---------------------------------------------------------------------------
# Authorization
# ---------------------------------------------------------------------------
def _is_admin(env):
"""Return True if the calling user is an EnCoach admin or system admin."""
user = env.user
if not user or not user.id:
return False
if user.has_group('base.group_system'):
return True
user_type = getattr(user, 'user_type', None)
return user_type == 'admin'
# ---------------------------------------------------------------------------
# Serialization helpers
# ---------------------------------------------------------------------------
def _read_state(env):
"""Build the full settings payload (no secrets in the output)."""
Param = env['ir.config_parameter'].sudo()
providers = {}
for cap, options in _PROVIDER_OPTIONS.items():
providers[cap] = {
'active': provider_router.get_active_provider(env, cap),
'options': options,
'paid_with_credentials': provider_router.get_paid_provider_keys(
env, cap,
),
}
keys_set = {}
for short, full_param in _KEY_PARAMS.items():
# ``aws_region`` happens to live in both maps — it's not a secret,
# so we surface its value in ``plain`` and *also* mark it as set so
# the UI can show the field consistently.
val = Param.get_param(full_param)
keys_set[short] = bool(val)
plain = {short: Param.get_param(p, '')
for short, p in _PLAIN_PARAMS.items()}
return {
'providers': providers,
'keys_set': keys_set,
'plain': plain,
}
def _is_clear_marker(value):
"""A trimmed, empty string explicitly clears the param."""
return isinstance(value, str) and value.strip() == ''
# ---------------------------------------------------------------------------
# Controller
# ---------------------------------------------------------------------------
class AISettingsController(http.Controller):
"""REST surface for the AI Provider Settings admin page."""
@http.route('/api/ai/settings/providers',
type='http', auth='none', methods=['GET', 'OPTIONS'],
csrf=False)
@jwt_required
def get_providers(self, **kw):
if not _is_admin(request.env):
return _error_response('Admin access required', 403)
try:
return _json_response({'data': _read_state(request.env)})
except Exception as exc:
_logger.exception('ai_settings.get failed')
return _error_response(str(exc), 500)
@http.route('/api/ai/settings/providers',
type='http', auth='none', methods=['PATCH', 'POST', 'PUT'],
csrf=False)
@jwt_required
def patch_providers(self, **kw):
if not _is_admin(request.env):
return _error_response('Admin access required', 403)
try:
body = _get_json_body() or {}
Param = request.env['ir.config_parameter'].sudo()
# 1. Update active provider per capability — validate that the
# chosen value is one of the offered options to keep junk
# out of ir.config_parameter.
provider_updates = body.get('providers') or {}
invalid = []
for cap, value in provider_updates.items():
if cap not in _PROVIDER_OPTIONS:
invalid.append(f'unknown capability: {cap}')
continue
allowed = {o['value'] for o in _PROVIDER_OPTIONS[cap]}
if value not in allowed:
invalid.append(f'{cap}: {value!r} not in {sorted(allowed)}')
continue
Param.set_param(provider_router.CAPABILITIES[cap]['param'], value)
if invalid:
return _error_response(
'Invalid provider selections: ' + '; '.join(invalid), 400,
)
# 2. Update API keys — write-only. An empty string clears the
# param; omitting the field leaves it untouched.
key_updates = body.get('keys') or {}
for short, value in key_updates.items():
if short not in _KEY_PARAMS:
continue
full_param = _KEY_PARAMS[short]
if value is None:
continue
if _is_clear_marker(value):
Param.set_param(full_param, '')
else:
# Trim whitespace to defend against a copy-paste with
# a trailing newline that would silently break SDK auth.
Param.set_param(full_param, str(value).strip())
# 3. Plain-value updates (model names, region, timeout, ...).
plain_updates = body.get('plain') or {}
for short, value in plain_updates.items():
if short not in _PLAIN_PARAMS:
continue
Param.set_param(_PLAIN_PARAMS[short], '' if value is None
else str(value))
# Audit log so admins can see who flipped providers.
try:
_logger.info(
'ai_settings.update by user_id=%s providers=%s '
'keys_changed=%s plain_changed=%s',
request.env.user.id,
list(provider_updates.keys()),
[k for k in key_updates.keys() if k in _KEY_PARAMS],
list(plain_updates.keys()),
)
except Exception:
pass
return _json_response({'data': _read_state(request.env)})
except Exception as exc:
_logger.exception('ai_settings.patch failed')
return _error_response(str(exc), 500)
@http.route('/api/ai/settings/providers/test',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def test_provider(self, **kw):
"""Quick "is this configured?" probe for the UI's Test button.
Body: ``{"capability": "image" | "audio" | "text"}``.
Returns the resolved provider chain plus a flag for each entry
indicating whether credentials are present. We deliberately do
NOT make a real network call — we just resolve the chain and
check for credentials so the test is instant and free.
"""
if not _is_admin(request.env):
return _error_response('Admin access required', 403)
try:
body = _get_json_body() or {}
capability = body.get('capability') or 'image'
if capability not in provider_router.CAPABILITIES:
return _error_response(
f'Unknown capability: {capability}', 400,
)
chain = provider_router.resolve_chain(request.env, capability)
paid_with_creds = set(
provider_router.get_paid_provider_keys(request.env, capability),
)
entries = []
for prov in chain:
if prov in provider_router.CAPABILITIES[capability]['paid']:
ok = prov in paid_with_creds
note = 'credentials configured' if ok else 'no API key'
else:
ok = True # free providers are always available
note = 'free fallback'
entries.append({'provider': prov, 'ok': ok, 'note': note})
return _json_response({
'capability': capability,
'active': provider_router.get_active_provider(
request.env, capability),
'chain': entries,
})
except Exception as exc:
_logger.exception('ai_settings.test failed')
return _error_response(str(exc), 500)

View File

@@ -9,3 +9,6 @@ from . import cefr_mapper # canonical CEFR / band / theta mapper (P0.9)
from . import question_validator # schema + quality gate for AI-generated questions (P1.6/P1.1)
from . import agent_tools # registry of tool handlers used by AgentRuntime
from .agent_runtime import AgentRuntime # LangGraph-backed core agent runtime
from . import provider_router # capability -> active-provider resolver
from . import free_image # offline Pillow-based image placeholder
from . import free_tts # gTTS + silent-MP3 audio fallbacks

View File

@@ -78,6 +78,7 @@ class AgentState(TypedDict, total=False):
retrieval: list[dict] # hits from the retrieval node (RAG)
iterations: int # guard against runaway ReAct loops
error: str # populated on fatal failure
should_revise: bool # set by review node when a revise pass is queued
# =============================================================================
@@ -92,6 +93,13 @@ class AgentRuntime:
# Factories
# ------------------------------------------------------------------
def __init__(self, env, agent, *, language: str | None = None):
# INVARIANT: every AgentRuntime is per-request and constructs a
# fresh OpenAIService, which reads ir.config_parameter on every
# __init__. Combined with MediaService → provider_router (also
# uncached), this guarantees that flipping a provider in the
# admin UI takes effect on the very next request — no Odoo
# restart, no cache invalidation. Don't introduce any
# module-level or class-level provider caches here.
self.env = env
self.agent = agent
self.language = language
@@ -323,7 +331,21 @@ class AgentRuntime:
return {**state, "messages": messages, "retrieval": items}
def _node_review(self, state: AgentState) -> AgentState:
"""Run every configured quality tool against the LLM's output."""
"""Run quality tools against the LLM output and (maybe) queue a revision.
We do all state mutations here — adding the critique message and
bumping ``revisions_used`` — and only signal the router with a
boolean ``should_revise``. LangGraph routing functions are
treated as pure: any state changes made there are discarded, so
keeping the router pure prevents an infinite revise loop where
the counter never actually increments.
"""
# If the LLM step already errored out, don't waste a quality
# check on the empty/garbage output and don't try to "revise" —
# another LLM call would just hit the same permanent failure.
if state.get("error"):
return {**state, "quality_issues": [], "should_revise": False}
text = state.get("output_raw") or ""
if isinstance(state.get("output"), dict):
# Flatten the dict to text so the quality tools see something
@@ -348,29 +370,38 @@ class AgentRuntime:
})
if res.get("ok") is False:
issues.extend(res.get("issues") or [res.get("error") or key])
return {**state, "quality_issues": issues}
revisions_used = state.get("revisions_used") or 0
max_rev = max(0, int(self.agent.max_revisions or 0))
if issues and revisions_used < max_rev:
critique = (
"Your previous draft was rejected for the following reasons:\n- "
+ "\n- ".join(issues)
+ "\n\nProduce an improved version that addresses every issue. "
"Keep the same JSON schema if one was requested."
)
messages = list(state.get("messages") or []) + [
{"role": "system", "content": critique}
]
return {
**state,
"messages": messages,
"quality_issues": issues,
"revisions_used": revisions_used + 1,
"should_revise": True,
}
return {
**state,
"quality_issues": issues,
"should_revise": False,
}
def _route_after_review(self, state: AgentState) -> str:
issues = state.get("quality_issues") or []
if not issues:
# Pure router: only inspect state, never mutate. The decision
# was prepared in ``_node_review``.
if state.get("error"):
return "done"
if (state.get("revisions_used") or 0) >= max(0, self.agent.max_revisions):
return "done"
# Queue up a revision: add a system message with the critique and
# bump the counter. We return via "revise" which loops back to
# the LLM node.
critique = (
"Your previous draft was rejected for the following reasons:\n- "
+ "\n- ".join(issues)
+ "\n\nProduce an improved version that addresses every issue. "
"Keep the same JSON schema if one was requested."
)
messages = list(state.get("messages") or []) + [
{"role": "system", "content": critique}
]
state["messages"] = messages
state["revisions_used"] = (state.get("revisions_used") or 0) + 1
return "revise"
return "revise" if state.get("should_revise") else "done"
# ReAct / tool-calling -------------------------------------------------
def _node_llm_tools(self, state: AgentState) -> AgentState:

View File

@@ -0,0 +1,150 @@
"""Offline image placeholder generator using Pillow.
Used as a free fallback when DALL-E (or any paid image API) is missing
credentials, returns a billing/quota error, or is otherwise unavailable.
The resulting PNG is a clean education-themed gradient card with the
title overlaid — good enough to keep the LMS UI populated until a real
image is generated.
Pillow is the only runtime dependency (already a transitive dep of Odoo
through ``reportlab`` on most installs). If Pillow is not importable we
raise so the caller knows to skip this provider and try the next one.
"""
from __future__ import annotations
import io
import logging
import random
_logger = logging.getLogger(__name__)
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError: # pragma: no cover — Pillow is a soft dep
Image = None
ImageDraw = None
ImageFont = None
# Soft education palettes (top-left, bottom-right gradient pairs)
_PALETTES = [
((59, 130, 246), (147, 197, 253)), # blue
((16, 185, 129), (110, 231, 183)), # emerald
((245, 158, 11), (252, 211, 77)), # amber
((139, 92, 246), (196, 181, 253)), # violet
((236, 72, 153), (249, 168, 212)), # pink
((20, 184, 166), (153, 246, 228)), # teal
((99, 102, 241), (165, 180, 252)), # indigo
((220, 38, 38), (252, 165, 165)), # rose
]
def _parse_size(size):
try:
w, h = (int(p) for p in str(size).lower().split('x'))
except Exception:
return 1024, 1024
return max(64, min(2048, w)), max(64, min(2048, h))
def _wrap(draw, text, font, max_width, max_lines=6):
words = (text or '').split()
lines, current = [], ''
for word in words:
candidate = (current + ' ' + word).strip()
if draw.textlength(candidate, font=font) <= max_width:
current = candidate
else:
if current:
lines.append(current)
current = word
if len(lines) >= max_lines:
return lines
if current and len(lines) < max_lines:
lines.append(current)
return lines
def _load_font(preferred, size):
"""Try a list of fonts; fall back to the bundled default at any size."""
for name in preferred:
try:
return ImageFont.truetype(name, size=size)
except Exception:
continue
return ImageFont.load_default()
def render_placeholder(title, *, subtitle=None, size='1024x1024', seed=None):
"""Return PNG bytes for a placeholder card.
Args:
title: Main title text rendered large and centred.
subtitle: Optional smaller line below the title (e.g. CEFR level,
week label) — pass ``None`` to skip.
size: ``"WIDTHxHEIGHT"`` string, e.g. ``"1024x1024"``.
seed: Optional seed for palette selection so the same title yields
the same gradient repeatably.
"""
if Image is None:
raise RuntimeError(
'Pillow not installed — pip install Pillow to enable the free '
'image fallback.'
)
w, h = _parse_size(size)
rng = random.Random(seed if seed is not None else (title or '').lower())
top, bottom = rng.choice(_PALETTES)
img = Image.new('RGB', (w, h), top)
px = img.load()
for y in range(h):
t = y / max(1, h - 1)
r = int(top[0] * (1 - t) + bottom[0] * t)
g = int(top[1] * (1 - t) + bottom[1] * t)
b = int(top[2] * (1 - t) + bottom[2] * t)
for x in range(w):
px[x, y] = (r, g, b)
draw = ImageDraw.Draw(img)
title_font = _load_font(
['DejaVuSans-Bold.ttf', 'Arial Bold.ttf', 'arial.ttf'],
int(h * 0.07),
)
sub_font = _load_font(
['DejaVuSans.ttf', 'Arial.ttf', 'arial.ttf'],
int(h * 0.035),
)
margin = int(w * 0.08)
max_text_w = w - 2 * margin
title_lines = _wrap(draw, title or 'Untitled', title_font, max_text_w)
line_h = int(h * 0.085)
total_h = line_h * len(title_lines)
y = (h - total_h) // 2
for line in title_lines:
line_w = draw.textlength(line, font=title_font)
x = (w - line_w) // 2
draw.text((x + 2, y + 2), line, font=title_font, fill=(0, 0, 0))
draw.text((x, y), line, font=title_font, fill=(255, 255, 255))
y += line_h
if subtitle:
sub_w = draw.textlength(subtitle, font=sub_font)
sx = (w - sub_w) // 2
sy = y + int(h * 0.02)
draw.text((sx + 1, sy + 1), subtitle, font=sub_font, fill=(0, 0, 0))
draw.text((sx, sy), subtitle, font=sub_font, fill=(255, 255, 255))
# Subtle EnCoach watermark badge so generated assets are easy to spot
badge_font = _load_font(['DejaVuSans.ttf', 'Arial.ttf'], int(h * 0.022))
badge = 'EnCoach · placeholder'
bw = draw.textlength(badge, font=badge_font)
draw.text(
(w - margin - bw, h - margin),
badge, font=badge_font, fill=(255, 255, 255),
)
buf = io.BytesIO()
img.save(buf, format='PNG', optimize=True)
return buf.getvalue()

View File

@@ -0,0 +1,128 @@
"""Free / offline text-to-speech fallbacks.
Two providers, ordered most-useful-first:
1. ``gtts`` — Google Translate TTS. Free and surprisingly natural, but
requires outbound network access. We try this first when
the paid provider is exhausted.
2. ``silent`` — A pre-encoded silent MP3 (~1 second). Used as a last
resort so that downstream consumers (notably the video
composer) still receive valid audio bytes and don't crash.
The shape of the return dict matches what ``PollyService.synthesize``
returns (``audio``, ``content_type``, ``voice``, ``characters``) so
callers can swap providers transparently.
"""
from __future__ import annotations
import io
import logging
import struct
_logger = logging.getLogger(__name__)
try: # pragma: no cover — gTTS is a soft dep
from gtts import gTTS
except ImportError:
gTTS = None
def _build_silent_wav(duration_seconds: float = 1.0,
sample_rate: int = 8000) -> bytes:
"""Construct a valid PCM WAV byte string with all-zero samples.
WAV is trivially constructable from primitives so we can produce it
without any external library. ffmpeg accepts WAV as readily as MP3,
so the rest of the pipeline is unaffected by the format choice.
"""
n_samples = max(1, int(duration_seconds * sample_rate))
samples = b'\x00\x00' * n_samples # 16-bit mono silence
data_size = len(samples)
fmt_chunk = (
b'fmt '
+ struct.pack('<I', 16) # PCM fmt chunk size
+ struct.pack('<H', 1) # PCM
+ struct.pack('<H', 1) # mono
+ struct.pack('<I', sample_rate)
+ struct.pack('<I', sample_rate * 2) # byte rate
+ struct.pack('<H', 2) # block align
+ struct.pack('<H', 16) # bits per sample
)
data_chunk = b'data' + struct.pack('<I', data_size) + samples
return (
b'RIFF'
+ struct.pack('<I', 36 + data_size)
+ b'WAVE'
+ fmt_chunk
+ data_chunk
)
# Map our internal language codes to (gTTS lang, gTTS tld) tuples. The
# tld controls accent (co.uk vs com vs com.au) so picking carefully here
# gives the listening exam a more authentic accent.
_GTTS_LANG_MAP = {
'en-GB': ('en', 'co.uk'),
'en-US': ('en', 'com'),
'en-AU': ('en', 'com.au'),
'en-IN': ('en', 'co.in'),
'en': ('en', 'co.uk'),
'ar': ('ar', 'com'),
'ar-EG': ('ar', 'com'),
'ar-SA': ('ar', 'com'),
'fr': ('fr', 'fr'),
'fr-FR': ('fr', 'fr'),
'es': ('es', 'es'),
'de': ('de', 'de'),
'tr': ('tr', 'com'),
'fa': ('fa', 'com'),
'ur': ('ur', 'com'),
'hi': ('hi', 'co.in'),
'zh': ('zh-CN', 'com'),
'ja': ('ja', 'com'),
}
def synthesize_with_gtts(text, *, language='en-GB'):
"""Synthesize ``text`` to MP3 bytes using gTTS.
Raises ``RuntimeError`` if gTTS is not installed; the caller is
expected to catch and try the next provider in the chain.
"""
if gTTS is None:
raise RuntimeError(
'gTTS not installed — pip install gTTS to enable the free '
'audio fallback.'
)
short = (text or '')[:4500]
if not short.strip():
return synthesize_silent()
lang, tld = _GTTS_LANG_MAP.get(language, ('en', 'co.uk'))
buf = io.BytesIO()
tts = gTTS(text=short, lang=lang, tld=tld, slow=False)
tts.write_to_fp(buf)
return {
'audio': buf.getvalue(),
'content_type': 'audio/mpeg',
'voice': f'gtts-{lang}-{tld}',
'characters': len(short),
}
def synthesize_silent(duration_seconds=1):
"""Return a minimal valid silent audio stub.
Returns a PCM WAV (which ffmpeg accepts identically to MP3) of the
requested duration. Used when even gTTS is unreachable and we just
need *some* valid audio so the video composer doesn't fail and the
media row can still be marked ``ready``.
"""
payload = _build_silent_wav(duration_seconds=duration_seconds)
return {
'audio': payload,
'content_type': 'audio/wav',
'voice': 'silent-stub',
'characters': 0,
}

View File

@@ -64,7 +64,18 @@ class OpenAIService:
import os
api_key = os.environ.get("OPENAI_API_KEY", "")
if _openai_mod and api_key:
self.client = _openai_mod.OpenAI(api_key=api_key, timeout=self.request_timeout)
# The SDK retries internally up to 2 times by default with exponential
# backoff, but we already do that ourselves in `_retry_with_backoff`.
# Stacking both meant a single quota error could trigger 9+ retries
# over several minutes before the controller could return — leaving
# the frontend's `Generate plan` button hanging. We disable the
# SDK's retries and let our own loop (which knows about
# insufficient_quota) be the single source of truth.
self.client = _openai_mod.OpenAI(
api_key=api_key,
timeout=self.request_timeout,
max_retries=0,
)
else:
self.client = None
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
@@ -114,6 +125,16 @@ class OpenAIService:
return messages
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
# Skip writing if the request transaction has already been
# aborted/rolled back (typically when an upstream caller caught
# the AI exception, re-raised, and the surrounding `try/except`
# in our route handler is about to return 500). Trying to insert
# in that state raises `psycopg2.InterfaceError: cursor already
# closed` and pollutes the log with a misleading second stack
# trace that hides the real upstream failure.
cr = getattr(self.env, "cr", None)
if cr is None or getattr(cr, "closed", False):
return
try:
self.env["encoach.ai.log"].sudo().create({
"service": "openai",
@@ -128,15 +149,41 @@ class OpenAIService:
"input_preview": (inp or "")[:500],
"output_preview": (out or "")[:500],
})
except Exception:
_logger.warning("Failed to log AI call", exc_info=True)
except Exception as exc:
# Most common case is psycopg2.InterfaceError when the txn
# has already been rolled back by a higher-level handler.
# Don't include `exc_info=True` for that one — it's noise.
err_str = str(exc).lower()
if "cursor already closed" in err_str or "current transaction is aborted" in err_str:
_logger.debug("Skipping AI log write — txn already aborted (%s)", action)
else:
_logger.warning("Failed to log AI call", exc_info=True)
def _check_enabled(self):
if not self.enabled:
raise RuntimeError("AI is disabled — enable in Settings > AI Configuration")
# Errors that will never resolve by retrying. These are user / billing
# / configuration conditions: retrying just wastes wall-clock time and
# leaves the frontend hanging on the wizard "Finish" button.
_NON_RETRYABLE_MARKERS = (
"insufficient_quota",
"invalid_api_key",
"incorrect_api_key",
"account_deactivated",
"billing_hard_limit_reached",
"model_not_found",
"context_length_exceeded",
)
def _retry_with_backoff(self, fn, action, model):
"""Execute fn with exponential backoff retries."""
"""Execute ``fn`` with exponential backoff retries.
Permanent failures (quota exhausted, bad API key, etc.) are raised
on the first attempt; transient ones (true rate-limit, 5xx) are
retried up to ``self.max_retries``. The OpenAI SDK is configured
with ``max_retries=0`` so this loop is the only retry layer.
"""
last_exc = None
for attempt in range(self.max_retries):
try:
@@ -144,6 +191,12 @@ class OpenAIService:
except Exception as exc:
last_exc = exc
err_str = str(exc).lower()
if any(m in err_str for m in self._NON_RETRYABLE_MARKERS):
_logger.warning(
"AI permanent failure for %s (no retry): %s",
action, exc,
)
raise
is_rate_limit = "rate" in err_str or "429" in err_str
is_server_error = "500" in err_str or "502" in err_str or "503" in err_str
if not (is_rate_limit or is_server_error) or attempt == self.max_retries - 1:

View File

@@ -0,0 +1,187 @@
"""Resolve the active AI provider per capability.
Settings are read fresh from ``ir.config_parameter`` on every call — there
is intentionally NO module-level cache so flipping a provider in the admin
UI takes effect on the next request without restarting Odoo.
Public surface:
* :func:`get_active_provider(env, capability)` — returns the provider key
configured for ``capability`` (one of ``text|image|audio|video``).
* :func:`classify_provider_error(exc)` — turns an arbitrary exception from
any third-party SDK into one of ``quota|auth|network|other`` so callers
can decide whether to fall back automatically.
* :class:`ProviderQuotaError`, :class:`ProviderAuthError` — typed errors
that callers may raise when they want a strongly-typed signal.
The capability tables also enumerate the **allowed free providers** per
capability — these are the ones the fallback chain uses when the paid
provider returns a quota or auth error.
"""
from __future__ import annotations
import logging
_logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Capabilities & provider names
# ---------------------------------------------------------------------------
#
# ``auto`` means "pick the first paid provider that's configured, else fall
# back to the first free provider that works". Admins who want to *force* a
# specific provider should pick its name explicitly in the UI.
CAPABILITIES = {
'text': {
'param': 'encoach.ai.text_provider',
'default': 'openai',
'paid': ['openai'],
'free': ['mock'],
},
'image': {
'param': 'encoach.ai.image_provider',
'default': 'auto',
'paid': ['openai'],
'free': ['pillow', 'unsplash', 'mock'],
},
'audio': {
'param': 'encoach.ai.audio_provider',
'default': 'auto',
'paid': ['polly', 'elevenlabs'],
'free': ['gtts', 'silent'],
},
'video': {
'param': 'encoach.ai.video_provider',
'default': 'auto',
'paid': [],
'free': ['ffmpeg', 'static'],
},
}
# ---------------------------------------------------------------------------
# Typed errors
# ---------------------------------------------------------------------------
class ProviderQuotaError(RuntimeError):
"""Raised when a provider returns a billing/quota error.
Examples include OpenAI ``insufficient_quota``, AWS Polly
``ThrottlingException``, ElevenLabs character-limit rejection, and any
HTTP 402/429. Callers should treat this as a soft failure and try the
next provider in the fallback chain.
"""
class ProviderAuthError(RuntimeError):
"""Raised when a provider refuses authentication (missing/invalid key)."""
# ---------------------------------------------------------------------------
# Resolution
# ---------------------------------------------------------------------------
def get_active_provider(env, capability):
"""Read the currently-active provider key for ``capability``.
Always reads from ``ir.config_parameter`` — never cached.
"""
cap = CAPABILITIES[capability]
return env['ir.config_parameter'].sudo().get_param(
cap['param'], cap['default'],
) or cap['default']
def get_paid_provider_keys(env, capability):
"""Return the list of paid providers for the capability that have an
API key configured. Used to decide whether to skip straight to free
fallbacks when ``auto`` is selected but no paid keys exist.
"""
Param = env['ir.config_parameter'].sudo()
available = []
for provider in CAPABILITIES[capability]['paid']:
if _provider_has_credentials(Param, provider):
available.append(provider)
return available
def _provider_has_credentials(Param, provider):
if provider == 'openai':
return bool(Param.get_param('encoach_ai.openai_api_key'))
if provider == 'polly':
return bool(Param.get_param('encoach_ai.aws_access_key')) and bool(
Param.get_param('encoach_ai.aws_secret_key')
)
if provider == 'elevenlabs':
return bool(Param.get_param('encoach_ai.elevenlabs_api_key'))
return False
def resolve_chain(env, capability, *, requested=None):
"""Return an ordered list of providers to try for ``capability``.
Args:
capability: ``'text' | 'image' | 'audio' | 'video'``.
requested: optional explicit provider override (e.g. body param).
When supplied it goes to the front of the chain.
"""
cap = CAPABILITIES[capability]
chain = []
selected = (requested or get_active_provider(env, capability) or '').strip()
if selected and selected != 'auto':
chain.append(selected)
# Paid providers with credentials, then free fallbacks
if selected == 'auto' or not selected:
for p in get_paid_provider_keys(env, capability):
if p not in chain:
chain.append(p)
for p in cap['free']:
if p not in chain:
chain.append(p)
return chain
# ---------------------------------------------------------------------------
# Error classification
# ---------------------------------------------------------------------------
_QUOTA_TOKENS = (
'insufficient_quota', 'quota', 'billing',
'429', '402',
'rate limit', 'rate_limit', 'rate-limit',
'throttlingexception', 'thresholdexceeded',
'limit_exceeded', 'character limit', 'too many requests',
)
_AUTH_TOKENS = (
'invalid_api_key', 'incorrect api key', 'authentication',
'unauthorized', 'access denied', '401', '403',
'authenticationerror', 'permissiondenied', 'invalid api key',
'missing api key',
)
def classify_provider_error(exc):
"""Map any provider exception to one of: ``quota|auth|network|other``."""
msg = (str(exc) or '').lower()
name = type(exc).__name__.lower()
blob = msg + ' ' + name
if any(t in blob for t in _QUOTA_TOKENS):
return 'quota'
if any(t in blob for t in _AUTH_TOKENS):
return 'auth'
if 'timeout' in blob or 'connection' in blob or 'network' in msg:
return 'network'
return 'other'
def should_fallback(exc):
"""Whether the caller should try the next provider in the chain."""
return classify_provider_error(exc) in ('quota', 'auth', 'network')