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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
292
custom_addons/encoach_ai/controllers/ai_settings_controller.py
Normal file
292
custom_addons/encoach_ai/controllers/ai_settings_controller.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
150
custom_addons/encoach_ai/services/free_image.py
Normal file
150
custom_addons/encoach_ai/services/free_image.py
Normal 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()
|
||||
128
custom_addons/encoach_ai/services/free_tts.py
Normal file
128
custom_addons/encoach_ai/services/free_tts.py
Normal 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,
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
187
custom_addons/encoach_ai/services/provider_router.py
Normal file
187
custom_addons/encoach_ai/services/provider_router.py
Normal 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')
|
||||
Reference in New Issue
Block a user