diff --git a/custom_addons/encoach_ai/__manifest__.py b/custom_addons/encoach_ai/__manifest__.py index 99083fa5..c46caf21 100644 --- a/custom_addons/encoach_ai/__manifest__.py +++ b/custom_addons/encoach_ai/__manifest__.py @@ -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", diff --git a/custom_addons/encoach_ai/controllers/__init__.py b/custom_addons/encoach_ai/controllers/__init__.py index 3de2fd74..c4181619 100644 --- a/custom_addons/encoach_ai/controllers/__init__.py +++ b/custom_addons/encoach_ai/controllers/__init__.py @@ -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 diff --git a/custom_addons/encoach_ai/controllers/ai_settings_controller.py b/custom_addons/encoach_ai/controllers/ai_settings_controller.py new file mode 100644 index 00000000..611d8448 --- /dev/null +++ b/custom_addons/encoach_ai/controllers/ai_settings_controller.py @@ -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 ``_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) diff --git a/custom_addons/encoach_ai/services/__init__.py b/custom_addons/encoach_ai/services/__init__.py index f343a2a4..76a75aec 100644 --- a/custom_addons/encoach_ai/services/__init__.py +++ b/custom_addons/encoach_ai/services/__init__.py @@ -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 diff --git a/custom_addons/encoach_ai/services/agent_runtime.py b/custom_addons/encoach_ai/services/agent_runtime.py index 0c965e30..a34837fd 100644 --- a/custom_addons/encoach_ai/services/agent_runtime.py +++ b/custom_addons/encoach_ai/services/agent_runtime.py @@ -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: diff --git a/custom_addons/encoach_ai/services/free_image.py b/custom_addons/encoach_ai/services/free_image.py new file mode 100644 index 00000000..e2a8f0fd --- /dev/null +++ b/custom_addons/encoach_ai/services/free_image.py @@ -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() diff --git a/custom_addons/encoach_ai/services/free_tts.py b/custom_addons/encoach_ai/services/free_tts.py new file mode 100644 index 00000000..58ca1242 --- /dev/null +++ b/custom_addons/encoach_ai/services/free_tts.py @@ -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(' 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: diff --git a/custom_addons/encoach_ai/services/provider_router.py b/custom_addons/encoach_ai/services/provider_router.py new file mode 100644 index 00000000..5f33f104 --- /dev/null +++ b/custom_addons/encoach_ai/services/provider_router.py @@ -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') diff --git a/custom_addons/encoach_ai_course/controllers/course_plan.py b/custom_addons/encoach_ai_course/controllers/course_plan.py index 5877badb..edb5acae 100644 --- a/custom_addons/encoach_ai_course/controllers/course_plan.py +++ b/custom_addons/encoach_ai_course/controllers/course_plan.py @@ -7,12 +7,14 @@ decorator and returns JSON. """ import base64 +import json import logging from odoo import http from odoo.http import request from odoo.addons.encoach_api.controllers.base import ( jwt_required, + validate_token, _json_response, _error_response, _get_json_body, @@ -42,7 +44,65 @@ def _request_language(): return str(raw).split(',')[0].split(';')[0].split('-')[0].strip().lower() or 'en' +def _entity_scope(): + """Return (entity_ids, is_superadmin) for current JWT user.""" + user = request.env.user.sudo() + is_super = bool(user.has_group('base.group_system')) + entity_ids = user.entity_ids.ids if hasattr(user, 'entity_ids') else [] + return entity_ids, is_super + + +def _default_entity_id_from_scope(): + entity_ids, is_super = _entity_scope() + if entity_ids: + return entity_ids[0] + if is_super: + return False + raise PermissionError('User is not linked to any entity') + + +def _ensure_entity_access(entity_id): + if not entity_id: + raise PermissionError('entity_id is required') + entity_ids, is_super = _entity_scope() + if is_super: + return int(entity_id) + if not entity_ids: + raise PermissionError('User is not linked to any entity') + if int(entity_id) not in entity_ids: + raise PermissionError('Entity access denied') + return int(entity_id) + + class CoursePlanController(http.Controller): + def _plan_domain(self, extra=None): + domain = list(extra or []) + entity_ids, is_super = _entity_scope() + if is_super: + return domain + if not entity_ids: + return domain + [('id', '=', 0)] + return domain + [('entity_id', 'in', entity_ids)] + + def _assert_plan_access(self, plan): + if not plan or not plan.exists(): + raise ValueError('Plan not found') + entity_ids, is_super = _entity_scope() + if is_super: + return + if not plan.entity_id or plan.entity_id.id not in entity_ids: + raise PermissionError('Entity access denied') + + def _get_plan_scoped(self, plan_id): + plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) + self._assert_plan_access(plan) + return plan + + def _assert_material_access(self, material): + if not material or not material.exists(): + raise ValueError('Material not found') + self._assert_plan_access(material.plan_id) + # ------------------------------------------------------------------ # POST /api/ai/course-plan # ------------------------------------------------------------------ @@ -54,15 +114,21 @@ class CoursePlanController(http.Controller): body = _get_json_body() if not (body.get('title') or '').strip(): return _error_response('title is required', 400) + if body.get('entity_id'): + entity_id = _ensure_entity_access(int(body['entity_id'])) + else: + entity_id = _default_entity_id_from_scope() pipeline = CoursePlanPipeline( request.env, language=_request_language(), ) plan = pipeline.generate_plan(body) + if entity_id: + plan.sudo().write({'entity_id': entity_id}) return _json_response({'data': plan.to_api_dict(include_weeks=True)}) except Exception as exc: _logger.exception('course-plan.generate failed') - return _error_response(str(exc), 500) + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) # ------------------------------------------------------------------ # GET /api/ai/course-plan @@ -73,10 +139,12 @@ class CoursePlanController(http.Controller): def list_plans(self, **kw): try: params = request.httprequest.args - domain = [] + domain = self._plan_domain([]) search = (params.get('search') or '').strip() if search: domain.append(('name', 'ilike', search)) + if params.get('entity_id'): + domain.append(('entity_id', '=', _ensure_entity_access(int(params.get('entity_id'))))) Plan = request.env['encoach.course.plan'].sudo() offset, limit, page = _paginate({ @@ -94,7 +162,7 @@ class CoursePlanController(http.Controller): }) except Exception as exc: _logger.exception('course-plan.list failed') - return _error_response(str(exc), 500) + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) # ------------------------------------------------------------------ # GET /api/ai/course-plan/ @@ -104,15 +172,15 @@ class CoursePlanController(http.Controller): @jwt_required def get_plan(self, plan_id, **kw): try: - plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) - if not plan.exists(): - return _error_response('Plan not found', 404) + plan = self._get_plan_scoped(plan_id) return _json_response({ 'data': plan.to_api_dict(include_weeks=True, include_materials=True), }) + except ValueError as exc: + return _error_response(str(exc), 404) except Exception as exc: _logger.exception('course-plan.get failed') - return _error_response(str(exc), 500) + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) # ------------------------------------------------------------------ # DELETE /api/ai/course-plan/ @@ -122,14 +190,14 @@ class CoursePlanController(http.Controller): @jwt_required def delete_plan(self, plan_id, **kw): try: - plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) - if not plan.exists(): - return _error_response('Plan not found', 404) + plan = self._get_plan_scoped(plan_id) plan.unlink() return _json_response({'success': True}) + except ValueError as exc: + return _error_response(str(exc), 404) except Exception as exc: _logger.exception('course-plan.delete failed') - return _error_response(str(exc), 500) + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) # ------------------------------------------------------------------ # POST /api/ai/course-plan//weeks//materials @@ -139,6 +207,7 @@ class CoursePlanController(http.Controller): @jwt_required def generate_week_materials(self, plan_id, week_number, **kw): try: + self._get_plan_scoped(plan_id) pipeline = CoursePlanPipeline( request.env, language=_request_language(), ) @@ -151,7 +220,7 @@ class CoursePlanController(http.Controller): return _error_response(str(exc), 404) except Exception as exc: _logger.exception('course-plan.generate_week_materials failed') - return _error_response(str(exc), 500) + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) # ------------------------------------------------------------------ # GET /api/ai/course-plan//weeks//materials @@ -161,6 +230,7 @@ class CoursePlanController(http.Controller): @jwt_required def list_week_materials(self, plan_id, week_number, **kw): try: + self._get_plan_scoped(plan_id) week = request.env['encoach.course.plan.week'].sudo().search([ ('plan_id', '=', int(plan_id)), ('week_number', '=', int(week_number)), @@ -173,7 +243,7 @@ class CoursePlanController(http.Controller): }) except Exception as exc: _logger.exception('course-plan.list_week_materials failed') - return _error_response(str(exc), 500) + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) # ================================================================== # PHASE A — Reference sources (RAG grounding) @@ -184,25 +254,23 @@ class CoursePlanController(http.Controller): @jwt_required def list_sources(self, plan_id, **kw): try: - plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) - if not plan.exists(): - return _error_response('Plan not found', 404) + plan = self._get_plan_scoped(plan_id) return _json_response({ 'items': [s.to_api_dict() for s in plan.source_ids], 'count': len(plan.source_ids), }) + except ValueError as exc: + return _error_response(str(exc), 404) except Exception as exc: _logger.exception('course-plan.list_sources failed') - return _error_response(str(exc), 500) + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) @http.route('/api/ai/course-plan//sources', type='http', auth='none', methods=['POST'], csrf=False) @jwt_required def create_source(self, plan_id, **kw): try: - plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) - if not plan.exists(): - return _error_response('Plan not found', 404) + plan = self._get_plan_scoped(plan_id) ct = request.httprequest.content_type or '' files = request.httprequest.files @@ -250,37 +318,110 @@ class CoursePlanController(http.Controller): rec = request.env['encoach.course.plan.source'].sudo().create(vals) return _json_response({'data': rec.to_api_dict()}) + except ValueError as exc: + return _error_response(str(exc), 404) except Exception as exc: _logger.exception('course-plan.create_source failed') - return _error_response(str(exc), 500) + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + # ------------------------------------------------------------------ + # POST /api/ai/course-plan//sources/from-resources + # ------------------------------------------------------------------ + # Attach one or more existing library resources (``encoach.resource``, + # the items shown under /admin/resources) as RAG sources for a plan. + # + # Body shape: ``{ "resource_ids": [, ...] }``. We dedupe against + # already-linked resources so re-clicking "Attach" is a no-op rather + # than producing duplicate index entries. Each new row auto-indexes + # via the ``encoach.course.plan.source`` create() hook. + @http.route('/api/ai/course-plan//sources/from-resources', + type='http', auth='none', methods=['POST'], csrf=False) + @jwt_required + def attach_library_resources(self, plan_id, **kw): + try: + plan = self._get_plan_scoped(plan_id) + + body = _get_json_body() or {} + raw_ids = body.get('resource_ids') or body.get('ids') or [] + if not isinstance(raw_ids, list): + return _error_response('resource_ids must be a list', 400) + try: + resource_ids = [int(x) for x in raw_ids if x is not None] + except (TypeError, ValueError): + return _error_response('resource_ids must contain integers', 400) + if not resource_ids: + return _error_response('No resource ids provided', 400) + + Resource = request.env['encoach.resource'].sudo() + Source = request.env['encoach.course.plan.source'].sudo() + + already_linked = set( + plan.source_ids.filtered('resource_id').mapped('resource_id.id') + ) + attached, skipped, missing = [], [], [] + for rid in resource_ids: + if rid in already_linked: + skipped.append(rid) + continue + res = Resource.browse(rid) + if not res.exists(): + missing.append(rid) + continue + rec = Source.create({ + 'plan_id': plan.id, + 'kind': 'resource', + 'resource_id': res.id, + 'name': res.name or f'Resource #{res.id}', + 'file_name': res.name or '', + 'mime_type': '', + }) + attached.append(rec.to_api_dict()) + + return _json_response({ + 'attached': attached, + 'skipped_existing': skipped, + 'missing': missing, + 'count': len(attached), + }) + except ValueError as exc: + return _error_response(str(exc), 404) + except Exception as exc: + _logger.exception('course-plan.attach_library_resources failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) @http.route('/api/ai/course-plan//sources//index', type='http', auth='none', methods=['POST'], csrf=False) @jwt_required def reindex_source(self, plan_id, source_id, **kw): try: + self._get_plan_scoped(plan_id) rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id)) if not rec.exists() or rec.plan_id.id != int(plan_id): return _error_response('Source not found', 404) rec.action_index() return _json_response({'data': rec.to_api_dict()}) + except ValueError as exc: + return _error_response(str(exc), 404) except Exception as exc: _logger.exception('course-plan.reindex_source failed') - return _error_response(str(exc), 500) + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) @http.route('/api/ai/course-plan//sources/', type='http', auth='none', methods=['DELETE'], csrf=False) @jwt_required def delete_source(self, plan_id, source_id, **kw): try: + self._get_plan_scoped(plan_id) rec = request.env['encoach.course.plan.source'].sudo().browse(int(source_id)) if not rec.exists() or rec.plan_id.id != int(plan_id): return _error_response('Source not found', 404) rec.unlink() return _json_response({'success': True}) + except ValueError as exc: + return _error_response(str(exc), 404) except Exception as exc: _logger.exception('course-plan.delete_source failed') - return _error_response(str(exc), 500) + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) # ================================================================== # PHASE B — Deliverables preview / progress @@ -291,13 +432,13 @@ class CoursePlanController(http.Controller): @jwt_required def get_deliverables(self, plan_id, **kw): try: - plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id)) - if not plan.exists(): - return _error_response('Plan not found', 404) + plan = self._get_plan_scoped(plan_id) return _json_response(compute_deliverables(plan)) + except ValueError as exc: + return _error_response(str(exc), 404) except Exception as exc: _logger.exception('course-plan.deliverables failed') - return _error_response(str(exc), 500) + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) # ================================================================== # PHASE C — Multimedia generation per material @@ -307,8 +448,48 @@ class CoursePlanController(http.Controller): rec = request.env['encoach.course.plan.material'].sudo().browse(int(material_id)) if not rec.exists(): return None + self._assert_material_access(rec) return rec + @http.route('/api/ai/course-plan/material/', + type='http', auth='none', methods=['PATCH'], csrf=False) + @jwt_required + def update_material(self, material_id, **kw): + """Edit generated material metadata/content without regenerating.""" + try: + material = self._resolve_material(material_id) + if not material: + return _error_response('Material not found', 404) + body = _get_json_body() or {} + vals = {} + if 'title' in body: + new_title = (body.get('title') or '').strip() + if not new_title: + return _error_response('title cannot be empty', 400) + vals['title'] = new_title + if 'summary' in body: + vals['summary'] = (body.get('summary') or '').strip() + if 'is_static' in body: + vals['is_static'] = bool(body.get('is_static')) + if 'share_date' in body: + vals['share_date'] = body.get('share_date') or False + if 'body' in body: + vals['body_json'] = json.dumps(body.get('body') or {}, ensure_ascii=False) + if 'body_text' in body: + body_text = (body.get('body_text') or '').strip() + vals['body_text'] = body_text + if 'body' not in body and body_text: + # Keep non-technical editing simple: if only plain text is + # provided, mirror it into a minimal JSON structure. + vals['body_json'] = json.dumps({'text': body_text}, ensure_ascii=False) + if vals: + material.sudo().write(vals) + return _json_response({'data': material.to_api_dict()}) + except Exception as exc: + _logger.exception('course-plan.update_material failed') + code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500 + return _error_response(str(exc), code) + @http.route('/api/ai/course-plan/material//media/audio', type='http', auth='none', methods=['POST'], csrf=False) @jwt_required @@ -324,12 +505,13 @@ class CoursePlanController(http.Controller): voice=body.get('voice'), language=body.get('language') or 'en-GB', gender=body.get('gender') or 'female', - provider=body.get('provider') or 'polly', + provider=body.get('provider') or 'auto', ) return _json_response({'data': media.to_api_dict()}) except Exception as exc: _logger.exception('course-plan.gen_audio failed') - return _error_response(str(exc), 500) + code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500 + return _error_response(str(exc), code) @http.route('/api/ai/course-plan/material//media/image', type='http', auth='none', methods=['POST'], csrf=False) @@ -351,7 +533,8 @@ class CoursePlanController(http.Controller): return _json_response({'data': media.to_api_dict()}) except Exception as exc: _logger.exception('course-plan.gen_image failed') - return _error_response(str(exc), 500) + code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500 + return _error_response(str(exc), code) @http.route('/api/ai/course-plan/material//media/video', type='http', auth='none', methods=['POST'], csrf=False) @@ -366,7 +549,8 @@ class CoursePlanController(http.Controller): return _json_response({'data': media.to_api_dict()}) except Exception as exc: _logger.exception('course-plan.gen_video failed') - return _error_response(str(exc), 500) + code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500 + return _error_response(str(exc), code) @http.route('/api/ai/course-plan/material//media', type='http', auth='none', methods=['GET'], csrf=False) @@ -382,7 +566,63 @@ class CoursePlanController(http.Controller): }) except Exception as exc: _logger.exception('course-plan.list_material_media failed') - return _error_response(str(exc), 500) + code = 404 if isinstance(exc, ValueError) else 403 if isinstance(exc, PermissionError) else 500 + return _error_response(str(exc), code) + + # ------------------------------------------------------------------ + # GET /api/ai/course-plan/media//raw + # ------------------------------------------------------------------ + # Streams the binary backing a media row. Used as the ``src`` for + # ```` / ``