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:
183
smoke_provider_fallback.py
Normal file
183
smoke_provider_fallback.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Smoke test for the AI provider fallback chain (Phase 24.1).
|
||||
|
||||
Exercises the new code paths added in this session:
|
||||
|
||||
1. provider_router.classify_provider_error — error taxonomy
|
||||
2. provider_router.resolve_chain — fallback ordering
|
||||
3. free_image.render_placeholder — offline Pillow card
|
||||
4. free_tts.synthesize_silent — offline silent MP3 stub
|
||||
5. MediaService.generate_image('pillow') — full fallback round-trip
|
||||
6. MediaService.synthesize_audio('silent') — silent fallback persists
|
||||
7. AI settings ir.config_parameter wiring — get/set via params
|
||||
|
||||
Run via:
|
||||
odoo-bin shell -c odoo.conf -d encoach_v2 < smoke_provider_fallback.py
|
||||
|
||||
External APIs are NEVER called by this script — the whole point is to
|
||||
prove the platform stays up when paid keys are missing or exhausted.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def hr(title):
|
||||
print(f"\n{'='*72}\n{title}\n{'='*72}")
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(f" FAIL {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def ok(msg):
|
||||
print(f" PASS {msg}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 1
|
||||
hr("1. provider_router.classify_provider_error")
|
||||
from odoo.addons.encoach_ai.services.provider_router import (
|
||||
classify_provider_error,
|
||||
resolve_chain,
|
||||
CAPABILITIES,
|
||||
get_active_provider,
|
||||
)
|
||||
|
||||
|
||||
class _OpenAIInsufficientQuota(Exception):
|
||||
pass
|
||||
|
||||
|
||||
cases = [
|
||||
(Exception('insufficient_quota: You exceeded your current quota'), 'quota'),
|
||||
(Exception('Error code: 429 - {"error": {"code": "rate_limit_exceeded"}}'), 'quota'),
|
||||
(Exception('HTTP 402 Payment Required'), 'quota'),
|
||||
(Exception('Invalid API key provided'), 'auth'),
|
||||
(Exception('AccessDenied: 403'), 'auth'),
|
||||
(Exception('Connection timeout after 30s'), 'network'),
|
||||
(Exception('Something completely unexpected'), 'other'),
|
||||
]
|
||||
for exc, expected in cases:
|
||||
got = classify_provider_error(exc)
|
||||
if got != expected:
|
||||
fail(f"expected {expected!r} got {got!r} for {exc}")
|
||||
ok(f"all {len(cases)} error-classification cases match")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 2
|
||||
hr("2. provider_router.resolve_chain")
|
||||
print(f" capabilities: {sorted(CAPABILITIES.keys())}")
|
||||
chain = resolve_chain(env, 'image')
|
||||
print(f" image chain (auto): {chain}")
|
||||
if 'pillow' not in chain:
|
||||
fail("pillow free fallback missing from image chain")
|
||||
ok("image chain contains free fallback")
|
||||
|
||||
chain_audio = resolve_chain(env, 'audio')
|
||||
print(f" audio chain (auto): {chain_audio}")
|
||||
if 'silent' not in chain_audio:
|
||||
fail("silent fallback missing from audio chain")
|
||||
ok("audio chain ends in silent stub")
|
||||
|
||||
chain_forced = resolve_chain(env, 'image', requested='pillow')
|
||||
print(f" image chain when requested=pillow: {chain_forced}")
|
||||
if chain_forced[0] != 'pillow':
|
||||
fail("explicit request was not pinned to front of chain")
|
||||
ok("explicit provider goes to front of chain")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 3
|
||||
hr("3. free_image.render_placeholder")
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.free_image import render_placeholder
|
||||
png = render_placeholder(
|
||||
'Week 1 — Daily Routines',
|
||||
subtitle='CEFR A2 · Reading',
|
||||
size='512x512',
|
||||
seed=42,
|
||||
)
|
||||
if not png or len(png) < 1000:
|
||||
fail(f"rendered PNG too small: {len(png)} bytes")
|
||||
if not png.startswith(b'\x89PNG'):
|
||||
fail("rendered bytes are not a valid PNG header")
|
||||
ok(f"placeholder rendered ({len(png):,} bytes, valid PNG)")
|
||||
except RuntimeError as exc:
|
||||
print(f" SKIP Pillow not installed: {exc}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 4
|
||||
hr("4. free_tts.synthesize_silent")
|
||||
from odoo.addons.encoach_ai.services.free_tts import synthesize_silent
|
||||
res = synthesize_silent(duration_seconds=2)
|
||||
if not res.get('audio') or len(res['audio']) < 100:
|
||||
fail(f"silent stub too small: {len(res.get('audio') or b'')}")
|
||||
if res.get('content_type') not in ('audio/mpeg', 'audio/wav'):
|
||||
fail(f"expected audio/mpeg|wav, got {res.get('content_type')}")
|
||||
ok(f"silent stub: {len(res['audio'])} bytes, voice={res['voice']}, "
|
||||
f"content_type={res['content_type']}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 5
|
||||
hr("5. MediaService.generate_image (forced pillow provider)")
|
||||
Plan = env['encoach.course.plan'].sudo()
|
||||
plan = Plan.search([], order='id desc', limit=1)
|
||||
if not plan:
|
||||
fail("No course plan in DB; seed one first")
|
||||
material = plan.material_ids.filtered(
|
||||
lambda m: m.material_type == 'reading_text'
|
||||
)[:1]
|
||||
if not material:
|
||||
fail("Plan has no reading_text material")
|
||||
print(f" using plan #{plan.id}, material #{material.id}: {material.title!r}")
|
||||
|
||||
from odoo.addons.encoach_ai_course.services.media_service import MediaService
|
||||
svc = MediaService(env)
|
||||
image_media = svc.generate_image(material, provider='pillow')
|
||||
print(f" image media #{image_media.id} status={image_media.status} "
|
||||
f"provider={image_media.provider} size={image_media.size_bytes}")
|
||||
if image_media.status != 'ready':
|
||||
fail(f"pillow image generation failed: {image_media.error}")
|
||||
if image_media.provider != 'pillow':
|
||||
fail(f"expected provider=pillow, got {image_media.provider}")
|
||||
ok(f"pillow fallback produced ready image of {image_media.size_bytes:,} bytes")
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 6
|
||||
hr("6. MediaService.synthesize_audio (forced silent provider)")
|
||||
mat_listening = plan.material_ids.filtered(
|
||||
lambda m: m.material_type == 'listening_script'
|
||||
)[:1]
|
||||
if not mat_listening:
|
||||
print(" SKIP no listening_script material on plan")
|
||||
else:
|
||||
audio_media = svc.synthesize_audio(mat_listening, provider='silent')
|
||||
print(f" audio media #{audio_media.id} status={audio_media.status} "
|
||||
f"provider={audio_media.provider}")
|
||||
if audio_media.status != 'ready':
|
||||
fail(f"silent audio generation failed: {audio_media.error}")
|
||||
if audio_media.provider != 'silent':
|
||||
fail(f"expected provider=silent, got {audio_media.provider}")
|
||||
ok(f"silent fallback produced ready audio of {audio_media.size_bytes:,} bytes")
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ Step 7
|
||||
hr("7. ir.config_parameter wiring (provider switch)")
|
||||
Param = env['ir.config_parameter'].sudo()
|
||||
original = Param.get_param('encoach.ai.image_provider', '')
|
||||
print(f" current encoach.ai.image_provider = {original!r}")
|
||||
Param.set_param('encoach.ai.image_provider', 'pillow')
|
||||
got_after = get_active_provider(env, 'image')
|
||||
print(f" after set: {got_after!r}")
|
||||
if got_after != 'pillow':
|
||||
fail(f"provider switch did not persist: got {got_after!r}")
|
||||
# Restore so we don't leave the DB in test state
|
||||
Param.set_param('encoach.ai.image_provider', original or 'auto')
|
||||
ok("provider switch read back correctly (no caching)")
|
||||
|
||||
|
||||
hr("DONE — All 7 fallback checks passed")
|
||||
print("Free providers verified end-to-end. Paid providers will continue")
|
||||
print("to work whenever credentials are configured; quota errors silently")
|
||||
print("degrade to the same free chain we just exercised.")
|
||||
Reference in New Issue
Block a user