diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 00000000..94788615 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,49 @@ +name: Deploy to Staging + +# Triggered on every push to main (after PR merge). +# Runs ONLY the deploy job — not tests — so the server +# always reflects the latest main without waiting for the +# full CI suite to finish. The existing ci.yml handles lint/tests. +on: + push: + branches: [main] + +concurrency: + group: deploy-backend + cancel-in-progress: true + +jobs: + deploy: + name: Deploy backend + frontend to staging + runs-on: self-hosted + + steps: + - name: Pull latest code + run: | + cd /opt/encoach/encoach_backend_new_v2 + git fetch origin + git reset --hard origin/main + echo "Now at: $(git log -1 --oneline)" + + - name: Rebuild and restart containers + run: | + cd /opt/encoach/encoach_backend_new_v2 + docker compose \ + -f docker-compose.yml \ + -f /opt/encoach/overrides/encoach.override.yml \ + up -d --build --remove-orphans + echo "Containers after deploy:" + docker ps --format "table {{.Names}}\t{{.Status}}" | grep -E "encoach-v4|encoach-frontend" + + - name: Smoke test + run: | + echo "Waiting 20s for Odoo to settle..." + sleep 20 + STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8069/api/health) + echo "/api/health => HTTP $STATUS" + if [ "$STATUS" != "200" ]; then + echo "WARNING: /api/health returned $STATUS (Odoo may still be starting)" + fi + FE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/) + echo "frontend / => HTTP $FE" + test "$FE" = "200" || exit 1 diff --git a/.gitignore b/.gitignore index 321926c2..dc5fa55e 100644 --- a/.gitignore +++ b/.gitignore @@ -88,9 +88,13 @@ addons_enterprise/ addons_extra/ new_project/enterprise-17/ -# Third-party modules (downloaded separately) +# Third-party modules +# OpenEduCat Community (LGPL-3) is vendored under `backend/openeducat_erp-19.0/` +# and must be tracked — `addons_path` in odoo.conf / odoo-docker.conf relies on +# it, and database dumps mark 12 openeducat_* modules as installed. Excluding +# this folder previously caused restores on the VPS to fail with "module not +# found" errors. new_project/openeducat_erp-19.0/ -backend/openeducat_erp-19.0/ new_project/openeducat_erp-19.0.zip new_project/openeducate_enterprise-17.zip new_project/encoach_frontend_new_v1-main.zip @@ -105,3 +109,6 @@ htmlcov/ # Poetry poetry.lock + +# Odoo DB backups (local only, not source-controlled) +backups/ diff --git a/backend/GE1 Course Outline_ Fall AY25-26.pdf b/backend/GE1 Course Outline_ Fall AY25-26.pdf new file mode 100644 index 00000000..e3941cef Binary files /dev/null and b/backend/GE1 Course Outline_ Fall AY25-26.pdf differ diff --git a/backend/custom_addons/encoach_ai/__manifest__.py b/backend/custom_addons/encoach_ai/__manifest__.py index c4929652..c46caf21 100644 --- a/backend/custom_addons/encoach_ai/__manifest__.py +++ b/backend/custom_addons/encoach_ai/__manifest__.py @@ -17,12 +17,17 @@ "author": "EnCoach", "depends": ["base", "encoach_core", "encoach_api"], "external_dependencies": { - "python": ["openai", "boto3"], + "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", "views/ai_settings_views.xml", "data/ai_defaults.xml", + "data/agents_defaults.xml", ], "installable": True, "application": True, diff --git a/backend/custom_addons/encoach_ai/controllers/__init__.py b/backend/custom_addons/encoach_ai/controllers/__init__.py index 4520844b..c4181619 100644 --- a/backend/custom_addons/encoach_ai/controllers/__init__.py +++ b/backend/custom_addons/encoach_ai/controllers/__init__.py @@ -3,3 +3,5 @@ from . import coach_controller from . import media_controller from . import prompt_controller from . import feedback_controller +from . import agents_controller +from . import ai_settings_controller diff --git a/backend/custom_addons/encoach_ai/controllers/agents_controller.py b/backend/custom_addons/encoach_ai/controllers/agents_controller.py new file mode 100644 index 00000000..bb6d28f4 --- /dev/null +++ b/backend/custom_addons/encoach_ai/controllers/agents_controller.py @@ -0,0 +1,244 @@ +"""Admin endpoints for configuring and exercising AI agents. + +Design notes: + +* Read endpoints require a valid JWT but not admin. The "AI Agents" + tab needs to be reachable by anyone who can see ``/admin/ai/prompts`` + today (analysts, teachers auditing prompt changes, etc.). +* Write endpoints — ``PATCH /api/ai/agents/`` and + ``POST /api/ai/agents//test`` — additionally require admin + privileges (``base.group_system``), matching the existing prompt + controller's policy. +* ``/test`` is deliberately synchronous and uncached: admins use it to + quickly verify a config change produces sane output. It caps the + LLM at 500 tokens to keep iteration cheap. +""" + +from __future__ import annotations + +import json +import logging + +from odoo import http +from odoo.http import request + +from odoo.addons.encoach_api.controllers.base import ( + _error_response, + _get_json_body, + _json_response, + jwt_required, +) + +_logger = logging.getLogger(__name__) + + +def _require_admin(): + if not request.env.user.has_group("base.group_system"): + return _error_response("Admin privileges required", 403) + return None + + +class EncoachAIAgentsController(http.Controller): + + # ------------------------------------------------------------------ + # GET /api/ai/agents + # ------------------------------------------------------------------ + @http.route("/api/ai/agents", type="http", auth="none", methods=["GET"], csrf=False) + @jwt_required + def list_agents(self, **kw): + try: + search = (kw.get("search") or "").strip() + domain = [] + if search: + domain = [ + "|", "|", + ("key", "ilike", search), + ("name", "ilike", search), + ("description", "ilike", search), + ] + Agent = request.env["encoach.ai.agent"].sudo() + records = Agent.search(domain, order="sequence, name") + items = [r.to_api_dict(include_prompt=False) for r in records] + return _json_response({ + "items": items, + "data": items, + "total": len(items), + }) + except Exception as exc: + _logger.exception("list agents failed") + return _error_response(str(exc), 500) + + # ------------------------------------------------------------------ + # GET /api/ai/agents/ + # ------------------------------------------------------------------ + @http.route( + "/api/ai/agents/", + type="http", auth="none", methods=["GET"], csrf=False, + ) + @jwt_required + def get_agent(self, agent_id, **kw): + try: + agent = request.env["encoach.ai.agent"].sudo().browse(int(agent_id)) + if not agent.exists(): + return _error_response("Agent not found", 404) + data = agent.to_api_dict(include_prompt=True) + data["tools"] = [t.to_api_dict() for t in agent.tool_ids] + return _json_response(data) + except Exception as exc: + _logger.exception("get agent failed") + return _error_response(str(exc), 500) + + # ------------------------------------------------------------------ + # PATCH /api/ai/agents/ (admin-only) + # ------------------------------------------------------------------ + @http.route( + "/api/ai/agents/", + type="http", auth="none", methods=["PATCH", "PUT"], csrf=False, + ) + @jwt_required + def update_agent(self, agent_id, **kw): + err = _require_admin() + if err is not None: + return err + try: + agent = request.env["encoach.ai.agent"].sudo().browse(int(agent_id)) + if not agent.exists(): + return _error_response("Agent not found", 404) + body = _get_json_body() or {} + vals: dict = {} + # Whitelist every settable field so callers can't flip `active` or + # rewrite `key` without knowing they're allowed to. + for f in ( + "name", "description", "system_prompt", "prompt_key", + "model", "fallback_model", "response_format", + "graph_type", "quality_checks", + ): + if f in body: + vals[f] = body[f] or "" + for f in ("temperature",): + if f in body: + try: + vals[f] = float(body[f]) + except (TypeError, ValueError): + pass + for f in ("max_tokens", "max_revisions", "sequence"): + if f in body: + try: + vals[f] = int(body[f]) + except (TypeError, ValueError): + pass + if "active" in body: + vals["active"] = bool(body["active"]) + if "tool_keys" in body and isinstance(body["tool_keys"], list): + tool_ids = request.env["encoach.ai.tool"].sudo().search( + [("key", "in", [str(k) for k in body["tool_keys"]])] + ).ids + vals["tool_ids"] = [(6, 0, tool_ids)] + with request.env.cr.savepoint(): + agent.write(vals) + return _json_response(agent.to_api_dict(include_prompt=True)) + except Exception as exc: + _logger.exception("update agent failed") + return _error_response(str(exc), 400) + + # ------------------------------------------------------------------ + # POST /api/ai/agents//test (admin-only) + # ------------------------------------------------------------------ + @http.route( + "/api/ai/agents//test", + type="http", auth="none", methods=["POST"], csrf=False, + ) + @jwt_required + def test_agent(self, agent_id, **kw): + err = _require_admin() + if err is not None: + return err + try: + agent = request.env["encoach.ai.agent"].sudo().browse(int(agent_id)) + if not agent.exists(): + return _error_response("Agent not found", 404) + body = _get_json_body() or {} + variables = body.get("variables") or {} + payload = body.get("payload") + language = body.get("language") or request.env.user.lang or "en" + + from odoo.addons.encoach_ai.services.agent_runtime import AgentRuntime + runtime = AgentRuntime(request.env, agent, language=language) + # Small-budget test: cap max_tokens so iteration stays cheap. + original_max = agent.max_tokens + if original_max > 800: + agent.sudo().write({"max_tokens": 800}) + try: + final = runtime.invoke(variables=variables, payload=payload) + finally: + if agent.max_tokens != original_max: + agent.sudo().write({"max_tokens": original_max}) + + output = final.get("output") + return _json_response({ + "error": final.get("error") or "", + "output": output, + "output_raw": (final.get("output_raw") or "")[:6000], + "tool_results": (final.get("tool_results") or [])[:20], + "retrieval_hits": len(final.get("retrieval") or []), + "revisions_used": final.get("revisions_used") or 0, + "quality_issues": final.get("quality_issues") or [], + "iterations": final.get("iterations") or 0, + }) + except Exception as exc: + _logger.exception("test agent failed") + return _error_response(str(exc), 500) + + # ------------------------------------------------------------------ + # GET /api/ai/agents/tools + # ------------------------------------------------------------------ + @http.route( + "/api/ai/agents/tools", + type="http", auth="none", methods=["GET"], csrf=False, + ) + @jwt_required + def list_tools(self, **kw): + try: + tools = request.env["encoach.ai.tool"].sudo().search([], order="category, sequence, name") + items = [t.to_api_dict() for t in tools] + return _json_response({"items": items, "data": items, "total": len(items)}) + except Exception as exc: + _logger.exception("list tools failed") + return _error_response(str(exc), 500) + + # ------------------------------------------------------------------ + # PATCH /api/ai/agents/tools/ (admin-only; currently toggle active) + # ------------------------------------------------------------------ + @http.route( + "/api/ai/agents/tools/", + type="http", auth="none", methods=["PATCH", "PUT"], csrf=False, + ) + @jwt_required + def update_tool(self, tool_id, **kw): + err = _require_admin() + if err is not None: + return err + try: + tool = request.env["encoach.ai.tool"].sudo().browse(int(tool_id)) + if not tool.exists(): + return _error_response("Tool not found", 404) + body = _get_json_body() or {} + vals: dict = {} + if "active" in body: + vals["active"] = bool(body["active"]) + for f in ("name", "description", "category"): + if f in body: + vals[f] = body[f] or "" + if "schema" in body: + # Accept a parsed dict OR raw JSON string. + raw = body["schema"] + if isinstance(raw, (dict, list)): + vals["schema_json"] = json.dumps(raw) + else: + vals["schema_json"] = str(raw) + with request.env.cr.savepoint(): + tool.write(vals) + return _json_response(tool.to_api_dict()) + except Exception as exc: + _logger.exception("update tool failed") + return _error_response(str(exc), 400) diff --git a/backend/custom_addons/encoach_ai/controllers/ai_controller.py b/backend/custom_addons/encoach_ai/controllers/ai_controller.py index 18655c9f..316c8fb0 100644 --- a/backend/custom_addons/encoach_ai/controllers/ai_controller.py +++ b/backend/custom_addons/encoach_ai/controllers/ai_controller.py @@ -24,6 +24,25 @@ def _get_json(): return {} +def _request_language(): + """Return the caller's UI language from ``Accept-Language``. + + The frontend ``api-client`` forwards the active i18n language (e.g. ``ar`` + or ``en``) via this header so AI-generated natural-language strings can + be returned in the same language as the UI chrome. + """ + try: + return request.httprequest.headers.get("Accept-Language", "") or "" + except Exception: + return "" + + +def _openai_for_request(): + """Construct an OpenAIService bound to the caller's UI language.""" + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + return OpenAIService(request.env, language=_request_language()) + + class AIController(http.Controller): """Handles /api/ai/* endpoints consumed by frontend AI components.""" @@ -37,7 +56,7 @@ class AIController(http.Controller): return _json_response({"answer": "", "suggestions": []}) try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) result = ai.search_with_rag(query, context=body.get("context", "")) return _json_response(result) except Exception as e: @@ -69,7 +88,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) result = ai.generate_insights( body.get("data", {}), insight_type=body.get("type", "general"), @@ -85,7 +104,7 @@ class AIController(http.Controller): def ai_alerts(self, **kw): try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) context = request.params.get("context", "dashboard") result = ai.generate_insights( {"context": context, "request": "alerts"}, @@ -103,7 +122,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) narrative = ai.generate_report_narrative( body.get("report_type", "performance"), body.get("data", {}), @@ -119,7 +138,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) result = ai.batch_optimize( body.get("items", []), optimization_type=body.get("type", "schedule"), @@ -135,7 +154,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) skill = body.get("skill", "writing") if skill == "speaking": result = ai.grade_speaking( @@ -160,7 +179,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) result = ai.generate_content_dedup( body.get("content_type", "reading_passage"), body.get("brief", {}), @@ -204,7 +223,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) messages = [ {"role": "system", "content": ( "You are an educational taxonomy expert. Suggest topics for the given domain and level. " @@ -224,7 +243,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) messages = [ {"role": "system", "content": ( "Create a personalized learning plan. Return JSON: " @@ -246,7 +265,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) messages = [ {"role": "system", "content": ( "Generate a course outline. Return JSON: {\"chapters\": " @@ -264,7 +283,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) messages = [ {"role": "system", "content": ( "Generate detailed chapter content for a course. Return JSON: " @@ -283,7 +302,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) messages = [ {"role": "system", "content": ( "Create an assessment rubric. Return JSON: {\"rubric\": " @@ -350,7 +369,7 @@ class AIController(http.Controller): try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) if not ai.client: raise RuntimeError("OpenAI not configured") @@ -480,7 +499,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) has_ai = bool(ai.client) except Exception: ai, has_ai = None, False @@ -1353,7 +1372,7 @@ class AIController(http.Controller): except KeyError: return _json_response({"error": "encoach.exam.custom model not available"}, 500) - initial_status = "published" if skip_approval else "draft" + initial_status = "published" if skip_approval else "pending_approval" exam_vals = { "title": title, "label": label, @@ -1539,11 +1558,46 @@ class AIController(http.Controller): quality_summary["failed"], quality_summary["warned"], ) + # ── Route through the approval workflow ──────────────────────── + # If the admin selected an approval workflow AND did not click + # "skip approval", create a real ``encoach.approval.request`` that + # lands in the first stage approver's queue. QA flagged that + # submitted modules were invisible to the assigned approver — + # before this block the exam was just left in ``draft`` with + # ``approval_workflow_id`` set but no request record routing it. + approval_request_id = False + if not skip_approval and workflow_id: + try: + Workflow = request.env["encoach.approval.workflow"].sudo() + workflow = Workflow.browse(workflow_id) + if workflow.exists() and workflow.stage_ids: + first_stage = workflow.stage_ids.sorted("sequence")[:1] + approval_req = request.env["encoach.approval.request"].sudo().create({ + "workflow_id": workflow.id, + "res_model": "encoach.exam.custom", + "res_id": exam.id, + "state": "in_progress" if first_stage else "draft", + "requester_id": request.env.user.id, + "current_stage_id": first_stage.id if first_stage else False, + }) + approval_request_id = approval_req.id + _logger.info( + "created approval.request %s for exam %s (workflow %s, stage %s)", + approval_req.id, exam.id, workflow.id, + first_stage.id if first_stage else None, + ) + except Exception: + _logger.exception( + "failed to create approval request for exam %s workflow %s", + exam.id, workflow_id, + ) + return _json_response({ "exam_id": exam.id, "status": exam.status, "template_id": template_id, "total_questions": total_questions, + "approval_request_id": approval_request_id, "quality": quality_summary, "schema_validation": { "verdict": schema_report["verdict"], @@ -1618,7 +1672,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) messages = [ {"role": "system", "content": ( "You are an educational materials expert. Suggest learning materials " @@ -1639,7 +1693,7 @@ class AIController(http.Controller): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) + ai = OpenAIService(request.env, language=_request_language()) result = ai.generate_content( body.get("content_type", "explanation"), {"topic_id": topic_id, **body}, diff --git a/backend/custom_addons/encoach_ai/controllers/ai_settings_controller.py b/backend/custom_addons/encoach_ai/controllers/ai_settings_controller.py new file mode 100644 index 00000000..611d8448 --- /dev/null +++ b/backend/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/backend/custom_addons/encoach_ai/controllers/coach_controller.py b/backend/custom_addons/encoach_ai/controllers/coach_controller.py index 340d001f..73abcb32 100644 --- a/backend/custom_addons/encoach_ai/controllers/coach_controller.py +++ b/backend/custom_addons/encoach_ai/controllers/coach_controller.py @@ -23,12 +23,25 @@ def _get_json(): return {} +def _request_language(): + """Read the caller's UI language from the ``Accept-Language`` header. + + The frontend ``api-client`` automatically attaches this header from the + active i18n language so AI-generated text can be localized. Falls back + to English if the header is missing or malformed. + """ + try: + return request.httprequest.headers.get("Accept-Language", "") or "" + except Exception: + return "" + + class CoachController(http.Controller): """Handles /api/coach/* endpoints consumed by frontend AI coaching components.""" def _get_coach(self): from odoo.addons.encoach_ai.services.coach_service import CoachService - return CoachService(request.env) + return CoachService(request.env, language=_request_language()) # ── POST /api/coach/chat — AiAssistantDrawer.tsx ── @http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False) diff --git a/backend/custom_addons/encoach_ai/data/agents_defaults.xml b/backend/custom_addons/encoach_ai/data/agents_defaults.xml new file mode 100644 index 00000000..bc042dfb --- /dev/null +++ b/backend/custom_addons/encoach_ai/data/agents_defaults.xml @@ -0,0 +1,410 @@ + + + + + + + + + resources.search + Search resources + retrieval + Semantic search over the LMS resource library. Returns resource ids, titles and snippets. Use this BEFORE generating content so the agent reuses existing, approved materials instead of hallucinating. + {"type":"object","properties":{"query":{"type":"string","description":"Natural language search query"},"skill":{"type":"string","enum":["reading","writing","listening","speaking","grammar","vocabulary"]},"cefr_level":{"type":"string","enum":["pre_a1","a1","a2","b1","b2","c1","c2"]},"limit":{"type":"integer","default":5,"minimum":1,"maximum":20}},"required":["query"]} + 10 + + + + rubric.fetch + Fetch rubric + reference + Return the grading rubric and criterion descriptors for a given rubric id or skill. Always call before grading so the LLM uses the approved rubric, not its own defaults. + {"type":"object","properties":{"rubric_id":{"type":"integer"},"skill":{"type":"string","enum":["reading","writing","listening","speaking"]}}} + 20 + + + + outcomes.fetch + Fetch course outcomes + reference + Return the registered learning outcomes for a course or CEFR level. Use it when generating course plans to stay aligned with the programme specification. + {"type":"object","properties":{"course_id":{"type":"integer"},"cefr_level":{"type":"string","enum":["pre_a1","a1","a2","b1","b2","c1","c2"]}}} + 30 + + + + student.profile + Get student gap profile + reference + Return the student's CEFR band, strengths and gaps so content can be personalised. Required input for personalised exercise generation and tutor follow-ups. + {"type":"object","properties":{"student_id":{"type":"integer"}},"required":["student_id"]} + 40 + + + + + quality.cefr_check + CEFR readability check + quality + Check whether a text reads at the target CEFR level using Flesch-Kincaid. Returns ok=false with specific issues when the passage is too easy or too hard for the requested band. + {"type":"object","properties":{"text":{"type":"string"},"target_cefr":{"type":"string","enum":["a1","a2","b1","b2","c1","c2"]}},"required":["text","target_cefr"]} + 50 + + + + quality.ai_detect + AI-content detection + quality + Probability the text was written by an AI (via GPTZero). Used during submission review — not usually during generation. + {"type":"object","properties":{"text":{"type":"string"}},"required":["text"]} + 60 + + + + quality.content_gate + Unified content gate + quality + Run the project's combined content-source gate (CEFR + toxicity + length checks). Returns ok=false with the first failing rule. + {"type":"object","properties":{"text":{"type":"string"},"cefr_level":{"type":"string"}},"required":["text"]} + 70 + + + + + course_plan.save + Save course plan + persistence + + Persist an AI-generated course plan header and its weekly rows. Only call once you're confident the JSON is valid and has been reviewed. + {"type":"object","properties":{"plan_vals":{"type":"object"},"weeks":{"type":"array","items":{"type":"object"}}},"required":["plan_vals"]} + 80 + + + + course_plan.save_materials + Save weekly teaching materials + persistence + + Persist the generated per-week teaching materials against an existing course plan and week. + {"type":"object","properties":{"plan_id":{"type":"integer"},"week_id":{"type":"integer"},"materials":{"type":"array","items":{"type":"object"}}},"required":["plan_id","week_id","materials"]} + 90 + + + + + media.synthesize_audio + Synthesize narration audio + media + + Render an MP3 narration of a material's text using AWS Polly (default) or ElevenLabs. Used for listening_script and speaking_prompt materials. Returns the media id, status and a /web/content URL when ready. + {"type":"object","properties":{"material_id":{"type":"integer"},"voice":{"type":"string"},"language":{"type":"string","default":"en-GB"},"gender":{"type":"string","enum":["female","male"]},"provider":{"type":"string","enum":["polly","elevenlabs"]}},"required":["material_id"]} + 120 + + + + media.generate_image + Generate illustration (DALL-E) + media + + Generate a single PNG illustration with OpenAI DALL-E 3 for a course-plan material. Used for reading hero images and vocabulary flashcards. Honours the per-plan image budget (encoach_ai_course.image_budget_per_plan). + {"type":"object","properties":{"material_id":{"type":"integer"},"prompt":{"type":"string"},"size":{"type":"string","enum":["1024x1024","1024x1792","1792x1024"]},"style":{"type":"string","enum":["natural","vivid"]},"quality":{"type":"string","enum":["standard","hd"]}},"required":["material_id"]} + 130 + + + + media.compose_video + Compose slideshow video (ffmpeg) + media + + Combine an existing audio narration with a still image into an MP4 (1280x720) using a local ffmpeg subprocess. Auto-creates audio and/or image first if the material lacks them. Falls back gracefully when ffmpeg is missing on the server. + {"type":"object","properties":{"material_id":{"type":"integer"}},"required":["material_id"]} + 140 + + + + + scoring.grade_writing + Grade writing response + scoring + Grade a writing response against a rubric using the platform's standard writing examiner prompt. + {"type":"object","properties":{"rubric":{"type":"string"},"task":{"type":"string"},"response":{"type":"string"}},"required":["rubric","response"]} + 100 + + + + scoring.grade_speaking + Grade speaking transcript + scoring + Grade a speaking transcript against a rubric using the platform's standard speaking examiner prompt. + {"type":"object","properties":{"rubric":{"type":"string"},"transcript":{"type":"string"}},"required":["rubric","transcript"]} + 110 + + + + + + + course_planner + Course Planner + Generates a full course plan (description, objectives, per-skill outcomes, grammar scope, assessment weights, week-by-week delivery) from a short brief. Used by the Smart Wizard and /api/ai/course-plan. + gpt-4o + gpt-4o-mini + 0.4 + 4096 + json + plan_review_revise + 1 + quality.cefr_check + 10 + You are an expert English language curriculum designer. You produce structured, institution-grade course outlines suitable for a general foundation English programme. + +Rules: +- Output MUST be a single valid JSON object matching the schema the user supplies. +- Use CEFR can-do statements when writing outcomes; cite the CEFR level in objectives. +- Distribute the weeks so grammar and skills build cumulatively, not randomly. +- Keep outcome codes short and stable (RLO1, WLO1, LLO1, SLO1, GLO1, VLO1) and reuse them in weeks[*].items[*].outcome_codes. +- Never wrap the JSON in prose, markdown, or code fences. + + + + + + course_week_materials + Week Teaching Materials + Given a course plan and a week number, produces classroom-ready materials (reading passage, listening script, speaking prompt, writing prompt, grammar mini-lesson, vocabulary list) aligned to the registered outcomes. + gpt-4o + gpt-4o-mini + 0.6 + 6000 + json + plan_review_revise + 1 + quality.cefr_check + 20 + You are an expert EFL teacher creating ready-to-use classroom materials. + +Rules: +- Every material MUST target only the outcome codes supplied for that week. +- Keep reading passages within the CEFR band's word-count window (A1~80, A2~150, B1~250, B2~400, C1~600, C2~800 words). +- Listening scripts must be natural dialogue/monologue, 3-4 minutes, with 4-6 comprehension questions. +- Speaking prompts include useful-language chunks the learner can recycle. +- Grammar lesson: one clear rule + 3 examples + 5 practice items with answer keys. +- Vocabulary: 8-12 entries with part of speech, CEFR-appropriate definition, and an example sentence in context. +- Output valid JSON only; no prose or markdown around it. + + + + + + exam_generator + Exam Generator + Generates exam questions (MCQ, short answer, cloze, speaking prompts, writing tasks) matching a structure and blueprint. + gpt-4o + gpt-4o-mini + 0.5 + 6000 + json + plan_review_revise + 1 + quality.cefr_check + 30 + You are a senior EFL / IELTS examiner generating authentic, validly constructed exam questions. + +Rules: +- Follow the exam structure blueprint exactly: same number of sections, same question types, same scoring weights. +- Every MCQ has exactly one correct answer and three plausible distractors; avoid "all of the above". +- Reading/listening stems reference only content present in the accompanying passage/transcript. +- Never produce content outside the requested CEFR band. +- Output is a single JSON object; no explanations around it. + + + + + + exercise_generator + Personalised Exercise Generator + Produces targeted practice items (gap-fill, reordering, short response) using a learner's gap profile to focus on their weakest outcomes. + gpt-4o-mini + gpt-4o + 0.7 + 3000 + json + react + 0 + + 40 + You are a remedial English tutor generating short, focused practice items for one learner. + +Workflow: +1. Call `student.profile` with the student_id you are given. Read their CEFR band and gap_json. +2. Optionally call `resources.search` to find an anchor text at the right level. +3. Then produce 6-10 practice items that target the biggest gaps. Prefer item types the learner has been scoring low on. +4. Each item has: prompt, correct_answer, distractors (for MCQ), brief rationale, target_outcome_code. +5. Output a JSON object {"items": [...]}. + +Never fabricate gap data — if student.profile fails, ask for a student_id in your final message and emit an empty items list. + + + + + + lms_tutor + LMS Tutor + Chat assistant students talk to from inside a lesson. Can look up their profile, search the library, fetch outcomes, and answer questions about their course. + gpt-4o-mini + gpt-4o + 0.6 + 1500 + text + react + 0 + + 50 + You are a friendly, encouraging English tutor inside the EnCoach LMS. You speak to learners directly. + +Principles: +- Adapt vocabulary and sentence length to the learner's CEFR level. +- When the learner asks about their progress, call `student.profile` first. +- When they ask about a topic, prefer searching `resources.search` for approved materials before inventing examples. +- Be concrete: give one example, one practice question, one next step. +- Never invent scores or progress data; if a tool fails, tell the learner you'll flag the issue to their teacher. + + + + + + writing_grader + Writing Grader + Grades a writing submission against its rubric and produces band scores, feedback, and targeted suggestions. + gpt-4o + gpt-4o-mini + 0.2 + 1800 + json + simple + 0 + + 60 + You are a calibrated IELTS / EFL writing examiner. + +Rules: +- Score every criterion in the rubric exactly once. +- Use only band values the rubric advertises (0-9 for IELTS, 0-100 or A1-C2 for other rubrics — follow what the user sends). +- Feedback must quote one line of evidence from the student's text before each judgement. +- Suggestions must be specific ("replace X with Y") not generic ("improve grammar"). +- Output JSON: {"scores": {"criterion_code": number}, "overall_band": number, "feedback": string, "suggestions": [string]}. + + + + + + speaking_grader + Speaking Evaluator + Grades a speaking transcript against its rubric and produces band scores, feedback on fluency / pronunciation, and next-step drills. + gpt-4o + gpt-4o-mini + 0.2 + 1800 + json + simple + 0 + + 70 + You are a calibrated IELTS Speaking examiner judging a transcript. + +Rules: +- Only score what the transcript supports; pronunciation judgements must be flagged as indirect. +- Quote a line of the transcript before each criterion judgement. +- Suggestions prescribe one concrete drill (e.g. "practice minimal pairs /iː/ vs /ɪ/ for 2 weeks"). +- Output JSON: {"scores": {"criterion_code": number}, "overall_band": number, "feedback": string, "suggestions": [string]}. + + + + + + course_media_director + Course Media Director + Given a generated week of teaching materials, decides which media (audio narration, illustrations, slideshow video) each material needs and orchestrates their generation through the media tools. + gpt-4o-mini + gpt-4o + 0.3 + 2000 + text + react + 0 + + 80 + You are the multimedia director for an English language course. Given the materials of one week, you decide what media each material needs, then call the matching tools. + +Default policy: +- listening_script -> media.synthesize_audio (mandatory) + media.generate_image (optional, scene illustration) + media.compose_video (optional, slideshow) +- speaking_prompt -> media.synthesize_audio for the model answer (optional) +- reading_text -> media.generate_image for a hero illustration (optional) +- vocabulary_list -> media.generate_image for the first 1-3 terms (use vocab term in the prompt) +- writing_prompt / grammar_lesson -> usually no media + +Rules: +- Always check if a material already has ready media of a given kind before regenerating; if so, skip. +- Stop after issuing the planned tool calls; never invent material ids. +- When done, emit a short text summary listing what was generated (media_id, status, error if any). + + + + + + encoach_ai_course.image_budget_per_plan + 60 + + + + + encoach_ai.use_langgraph_runtime + True + + diff --git a/backend/custom_addons/encoach_ai/models/__init__.py b/backend/custom_addons/encoach_ai/models/__init__.py index 3f35177f..2b9db145 100644 --- a/backend/custom_addons/encoach_ai/models/__init__.py +++ b/backend/custom_addons/encoach_ai/models/__init__.py @@ -2,4 +2,5 @@ from . import ai_settings from . import ai_log from . import ai_prompt from . import ai_feedback +from . import ai_agent from . import constants diff --git a/backend/custom_addons/encoach_ai/models/ai_agent.py b/backend/custom_addons/encoach_ai/models/ai_agent.py new file mode 100644 index 00000000..df925b62 --- /dev/null +++ b/backend/custom_addons/encoach_ai/models/ai_agent.py @@ -0,0 +1,358 @@ +"""LangGraph-backed AI agents and the tools they can invoke. + +Architecture +------------ + +The platform already has: + +* ``encoach.ai.prompt`` — versioned prompt *templates* with rendering. +* ``encoach.ai.log`` — per-call telemetry. +* ``OpenAIService`` — thin wrapper around the OpenAI Python SDK. + +This module adds the **agent layer** that sits on top of those primitives: + +* :py:class:`EncoachAIAgent` — a named, configurable agent (e.g. + ``course_planner``, ``exam_generator``). Each agent has a system prompt, + model choice, temperature budget, a graph topology (``simple``, + ``plan_review_revise`` or ``rag``) and an M2M list of tools it is + allowed to call. + +* :py:class:`EncoachAITool` — a catalogue row describing one callable + capability. The *implementation* lives in + ``encoach_ai.services.agent_tools`` keyed by :py:attr:`key`; this model + only stores the metadata (JSON Schema, human description, whether it + mutates data, which audiences may use it). Admins toggle tools on or + off per agent through the UI without touching Python code. + +The runtime itself (LangGraph state machine, tool-routing loop, log +emission) is in :py:mod:`encoach_ai.services.agent_runtime`. Keeping the +models here and the execution engine there makes the runtime easy to +swap / upgrade without touching the DB schema. +""" + +from __future__ import annotations + +import json +import logging +import re + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + +# Keys follow the same shape as prompt keys: ``lowercase.dotted.identifiers``. +_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)*$") + +GRAPH_TYPES = [ + # Single LLM call, no tools. Lowest latency, used for deterministic tasks. + ("simple", "Simple (single LLM call)"), + # Plan → self-critique → optionally revise once. Used for long-form + # generation (course plans, exam papers) where we want quality checks. + ("plan_review_revise", "Plan → Review → Revise"), + # Retrieval-augmented: runs the ``search_resources`` tool first, injects + # the hits as context, then calls the LLM. Used for curriculum-aware + # generation that must cite existing materials. + ("rag", "Retrieval-augmented (RAG)"), + # LLM decides which tools to call via OpenAI function-calling, in a + # loop. Used for LMS tutor / study assistant / grading workflows that + # may need to fetch rubrics, student profiles, etc. + ("react", "ReAct (tool-calling loop)"), +] + +TOOL_CATEGORIES = [ + ("retrieval", "Retrieval"), + ("persistence", "Persistence"), + ("quality", "Quality & gating"), + ("scoring", "Scoring & grading"), + ("reference", "Reference lookup"), + ("media", "Media generation"), + ("other", "Other"), +] + +# Models we're OK advertising in the admin UI. Keep small — the whole point +# of the agent layer is to encapsulate model choice. +MODEL_CHOICES = [ + ("gpt-4o", "GPT-4o (quality)"), + ("gpt-4o-mini", "GPT-4o mini (cheap / fast)"), + ("gpt-4.1", "GPT-4.1"), + ("gpt-4.1-mini", "GPT-4.1 mini"), + ("gpt-3.5-turbo", "GPT-3.5 turbo (legacy)"), +] + + +# ============================================================================= +# Tool catalogue +# ============================================================================= +class EncoachAITool(models.Model): + _name = "encoach.ai.tool" + _description = "AI Agent Tool" + _order = "category, sequence, name" + + key = fields.Char( + required=True, + index=True, + help="Stable dotted identifier (e.g. 'resources.search'). The Python " + "handler is resolved from this key via the agent_tools registry.", + ) + name = fields.Char(required=True, translate=True) + description = fields.Text( + required=True, translate=True, + help="Shown to the LLM when the tool is exposed as a callable. " + "Keep it concrete: explain *when* to use the tool, not *how* it works.", + ) + category = fields.Selection( + TOOL_CATEGORIES, required=True, default="other", index=True, + ) + schema_json = fields.Text( + required=True, + default="{}", + help="JSON Schema (Draft-07 subset) describing the tool's parameters. " + "Passed verbatim to OpenAI function-calling.", + ) + mutates = fields.Boolean( + default=False, + help="If True, the tool writes to the database. Used by the runtime " + "to wrap calls in a savepoint so a failed LLM step can't leave half-" + "created records behind.", + ) + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True, index=True) + + _sql_constraints = [ + ("tool_key_uniq", "unique(key)", "Tool key must be unique."), + ] + + # ------------------------------------------------------------------ + @api.constrains("key") + def _check_key(self): + for rec in self: + if not _KEY_RE.match(rec.key or ""): + raise ValidationError( + f"Invalid tool key {rec.key!r}. Use lowercase dotted identifiers." + ) + + @api.constrains("schema_json") + def _check_schema(self): + for rec in self: + try: + parsed = json.loads(rec.schema_json or "{}") + except Exception as exc: + raise ValidationError(f"schema_json must be valid JSON: {exc}") + if not isinstance(parsed, dict): + raise ValidationError("schema_json must be a JSON object.") + + # ------------------------------------------------------------------ + def to_api_dict(self): + self.ensure_one() + try: + schema = json.loads(self.schema_json or "{}") + except Exception: + schema = {} + return { + "id": self.id, + "key": self.key, + "name": self.name, + "description": self.description, + "category": self.category, + "schema": schema, + "mutates": bool(self.mutates), + "active": bool(self.active), + } + + def to_openai_tool(self): + """Render this row in the shape OpenAI function-calling expects.""" + self.ensure_one() + try: + params = json.loads(self.schema_json or "{}") + except Exception: + params = {} + # Ensure the JSON Schema is OpenAI-compatible (an object with + # ``properties``). Fall back to an empty object if the author + # forgot to wrap parameters. + if "type" not in params: + params = {"type": "object", "properties": params.get("properties", {})} + return { + "type": "function", + "function": { + "name": self.key.replace(".", "__"), + "description": self.description or self.name, + "parameters": params, + }, + } + + +# ============================================================================= +# Agent +# ============================================================================= +class EncoachAIAgent(models.Model): + _name = "encoach.ai.agent" + _description = "AI Agent" + _order = "sequence, name" + + key = fields.Char( + required=True, + index=True, + help="Stable dotted identifier the platform uses to resolve this " + "agent (e.g. 'course_planner'). Pipelines look the agent up by key " + "via AgentRuntime.from_key().", + ) + name = fields.Char(required=True, translate=True) + description = fields.Text(translate=True) + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True, index=True) + + # ------------------------------------------------------------------ + # Prompt & model wiring + # ------------------------------------------------------------------ + system_prompt = fields.Text( + required=True, + translate=False, + help="System message sent to the LLM. Referenced variables can be " + "filled in by the caller via AgentRuntime.invoke(variables=...).", + ) + prompt_key = fields.Char( + help="Optional: key of an encoach.ai.prompt record. When set, the " + "active version of that prompt *overrides* system_prompt at runtime. " + "Use this when you want prompt authors to iterate without touching " + "the agent config.", + ) + model = fields.Selection( + MODEL_CHOICES, required=True, default="gpt-4o", + help="OpenAI chat model used for this agent's LLM calls.", + ) + fallback_model = fields.Selection( + MODEL_CHOICES, default="gpt-4o-mini", + help="Model tried automatically if the primary model fails with a " + "5xx / rate-limit error. Leave blank to disable the fallback.", + ) + temperature = fields.Float( + default=0.4, + help="0.0 = deterministic, 1.0 = very creative. 0.3-0.5 is a sane " + "default for structured-JSON generation.", + ) + max_tokens = fields.Integer(default=4096) + response_format = fields.Selection( + [("text", "Text"), ("json", "JSON object")], + default="json", + help="`json` enables OpenAI's JSON mode and asks the LLM to return " + "parseable JSON. Use `text` for free-form output (tutor chat, " + "coach feedback).", + ) + + # ------------------------------------------------------------------ + # Graph & tools + # ------------------------------------------------------------------ + graph_type = fields.Selection( + GRAPH_TYPES, required=True, default="simple", index=True, + help="Which LangGraph topology to use when invoking this agent.", + ) + max_revisions = fields.Integer( + default=1, + help="For `plan_review_revise`: cap on how many times the agent may " + "revise its own draft before emitting the final answer.", + ) + quality_checks = fields.Char( + default="", + help="Comma-separated list of quality-gate tool keys to run after " + "generation (e.g. 'quality.cefr_check,quality.ai_detect'). Used by " + "the `plan_review_revise` topology.", + ) + tool_ids = fields.Many2many( + "encoach.ai.tool", + "encoach_ai_agent_tool_rel", + "agent_id", "tool_id", + string="Tools", + help="Tools this agent is allowed to call. For `react` graphs, " + "these are exposed to the LLM as OpenAI function-calling tools; " + "for `rag` / `plan_review_revise`, only tools whose key matches a " + "pre-defined hook (e.g. `resources.search` for RAG, " + "`quality.*` for review) are executed.", + ) + + tool_count = fields.Integer(compute="_compute_tool_count", store=False) + + _sql_constraints = [ + ("agent_key_uniq", "unique(key)", "Agent key must be unique."), + ] + + # ------------------------------------------------------------------ + @api.depends("tool_ids") + def _compute_tool_count(self): + for rec in self: + rec.tool_count = len(rec.tool_ids) + + @api.constrains("key") + def _check_key(self): + for rec in self: + if not _KEY_RE.match(rec.key or ""): + raise ValidationError( + f"Invalid agent key {rec.key!r}. " + "Use lowercase dotted identifiers (e.g. 'course_planner')." + ) + + @api.constrains("temperature") + def _check_temperature(self): + for rec in self: + if rec.temperature < 0.0 or rec.temperature > 2.0: + raise ValidationError("Temperature must be between 0.0 and 2.0") + + # ------------------------------------------------------------------ + # Lookup helpers + # ------------------------------------------------------------------ + @api.model + def get_by_key(self, key): + """Return the active agent for ``key`` or an empty recordset.""" + return self.sudo().search( + [("key", "=", key), ("active", "=", True)], limit=1, + ) + + def resolved_system_prompt(self, variables=None): + """Resolve the prompt to send: versioned prompt (if set) or inline. + + ``variables`` are passed to ``encoach.ai.prompt.render`` when the + agent is bound to a prompt key. + """ + self.ensure_one() + variables = variables or {} + if self.prompt_key: + prompt = self.env["encoach.ai.prompt"].sudo().get_active(self.prompt_key) + if prompt: + try: + return prompt.render(variables) + except UserError: + # Missing variables — fall back to the inline prompt so + # the caller still gets *something* and logs tell us + # exactly which variable was missing. + _logger.warning( + "Prompt %s v%s has unfilled variables; falling back " + "to inline system_prompt for agent %s", + prompt.key, prompt.version, self.key, + ) + return self.system_prompt or "" + + def to_api_dict(self, *, include_prompt=True): + self.ensure_one() + data = { + "id": self.id, + "key": self.key, + "name": self.name, + "description": self.description or "", + "model": self.model, + "fallback_model": self.fallback_model or "", + "temperature": self.temperature, + "max_tokens": self.max_tokens, + "response_format": self.response_format, + "graph_type": self.graph_type, + "max_revisions": self.max_revisions, + "quality_checks": [ + k.strip() for k in (self.quality_checks or "").split(",") if k.strip() + ], + "prompt_key": self.prompt_key or "", + "tool_count": len(self.tool_ids), + "tool_keys": self.tool_ids.mapped("key"), + "active": bool(self.active), + } + if include_prompt: + data["system_prompt"] = self.system_prompt or "" + return data diff --git a/backend/custom_addons/encoach_ai/models/ai_feedback.py b/backend/custom_addons/encoach_ai/models/ai_feedback.py index ea5cf00d..4a9bef2c 100644 --- a/backend/custom_addons/encoach_ai/models/ai_feedback.py +++ b/backend/custom_addons/encoach_ai/models/ai_feedback.py @@ -89,7 +89,7 @@ class EncoachAIFeedback(models.Model): "encoach.entity", ondelete="set null", index=True, ) course_id = fields.Many2one( - "encoach.course", ondelete="set null", index=True, + "op.course", ondelete="set null", index=True, ) # ------------------------------------------------------------------ diff --git a/backend/custom_addons/encoach_ai/security/ir.model.access.csv b/backend/custom_addons/encoach_ai/security/ir.model.access.csv index 958f4e13..22dad3c5 100644 --- a/backend/custom_addons/encoach_ai/security/ir.model.access.csv +++ b/backend/custom_addons/encoach_ai/security/ir.model.access.csv @@ -5,3 +5,7 @@ access_ai_prompt_admin,encoach.ai.prompt admin,model_encoach_ai_prompt,base.grou access_ai_prompt_user,encoach.ai.prompt user,model_encoach_ai_prompt,base.group_user,1,0,0,0 access_ai_feedback_admin,encoach.ai.feedback admin,model_encoach_ai_feedback,base.group_system,1,1,1,1 access_ai_feedback_user,encoach.ai.feedback user,model_encoach_ai_feedback,base.group_user,1,1,1,0 +access_ai_agent_admin,encoach.ai.agent admin,model_encoach_ai_agent,base.group_system,1,1,1,1 +access_ai_agent_user,encoach.ai.agent user,model_encoach_ai_agent,base.group_user,1,0,0,0 +access_ai_tool_admin,encoach.ai.tool admin,model_encoach_ai_tool,base.group_system,1,1,1,1 +access_ai_tool_user,encoach.ai.tool user,model_encoach_ai_tool,base.group_user,1,0,0,0 diff --git a/backend/custom_addons/encoach_ai/services/__init__.py b/backend/custom_addons/encoach_ai/services/__init__.py index 2e7b8ce7..76a75aec 100644 --- a/backend/custom_addons/encoach_ai/services/__init__.py +++ b/backend/custom_addons/encoach_ai/services/__init__.py @@ -7,3 +7,8 @@ from .elai_service import ElaiService from .coach_service import CoachService 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/backend/custom_addons/encoach_ai/services/agent_runtime.py b/backend/custom_addons/encoach_ai/services/agent_runtime.py new file mode 100644 index 00000000..a34837fd --- /dev/null +++ b/backend/custom_addons/encoach_ai/services/agent_runtime.py @@ -0,0 +1,531 @@ +"""LangGraph-based agent runtime. + +This is the core AI engine for EnCoach — every pipeline that used to call +``OpenAIService`` directly can instead go through an +:py:class:`AgentRuntime` loaded from a named :py:class:`encoach.ai.agent` +row. That buys us: + +* A single place to reason about retries, logging, tool execution and + self-review, instead of the same boilerplate inside every pipeline. +* Admins editing prompts, model choice, temperature or enabled tools in + the UI without redeploys. +* A consistent shape (``invoke(variables, payload)``) across course + planning, exam generation, LMS tutor, grading, etc. + +Graph topologies +---------------- + +Each agent picks one of four graphs. They're all built on the same +:py:class:`AgentState` TypedDict so upgrading an agent from one topology +to another only means flipping a selection field. + +``simple`` + ``START → llm → END``. The workhorse — used for deterministic + JSON generation (course plan header, exam question batches). + +``plan_review_revise`` + ``START → llm → review → [revise → llm]? → END``. ``review`` runs + every configured quality tool (``quality.cefr_check`` etc.); if any + returns ``ok=False`` we ask the LLM to revise once, capped by + ``max_revisions`` on the agent. Keeps structured outputs (reading + passages, listening scripts) inside their CEFR band. + +``rag`` + ``START → retrieve → llm → END``. Runs ``resources.search`` before + the LLM and injects the hits as extra system context. Used for + curriculum-aware generation that must cite real library material. + +``react`` + Classic tool-calling loop. The LLM is given the OpenAI-format tool + list and can call tools in a loop until it emits a final answer. + Used for the LMS tutor / study assistant. + +We depend on ``langgraph`` for the orchestration (state machine, +conditional edges) but still call OpenAI through the existing +:py:class:`OpenAIService` so the API key wiring, retry behaviour and +``encoach.ai.log`` rows keep working unchanged. +""" + +from __future__ import annotations + +import json +import logging +import time +from typing import Any, TypedDict + +from odoo.tools import config as odoo_config # noqa: F401 (future use) + +from . import agent_tools +from .openai_service import OpenAIService + +_logger = logging.getLogger(__name__) + + +# ============================================================================= +# State +# ============================================================================= +class AgentState(TypedDict, total=False): + """Shared state passed between every node of the graph.""" + + messages: list[dict] # chat history sent to the LLM + output: Any # parsed final answer (dict or string) + output_raw: str # the raw LLM text (for logging) + tool_calls: list[dict] # pending tool_calls emitted by the LLM + tool_results: list[dict] # results of executed tools, appended + quality_issues: list[str] # issues collected by the review node + revisions_used: int # revision counter (capped by max_revisions) + variables: dict # caller-supplied variables (for prompt rendering) + 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 + + +# ============================================================================= +# AgentRuntime +# ============================================================================= +class AgentRuntime: + """Wraps an :py:class:`encoach.ai.agent` row with a compiled LangGraph.""" + + MAX_REACT_ITERATIONS = 6 # hard cap on tool-calling loops + + # ------------------------------------------------------------------ + # 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 + self.ai = OpenAIService(env, language=language) + self._graph = None # lazily compiled + + @classmethod + def from_key(cls, env, key: str, *, language: str | None = None): + """Factory: load the active agent with ``key`` and build its runtime. + + Returns ``None`` if no agent is configured for that key — callers + can decide whether to fall back to their legacy direct-SDK path. + """ + agent = env["encoach.ai.agent"].sudo().get_by_key(key) + if not agent: + return None + return cls(env, agent, language=language) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def invoke(self, variables: dict | None = None, payload: Any = None, + *, extra_system: str = "") -> AgentState: + """Run the agent's graph end-to-end and return the terminal state. + + ``variables`` are substituted into the system prompt (if the agent + is bound to a prompt_key). ``payload`` becomes the first user + message; pass a dict to have it rendered as JSON, or a string to + pass it through verbatim. ``extra_system`` lets callers tack on + a context block without touching the agent's stored prompt. + """ + t0 = time.time() + graph = self._compile() + initial = self._initial_state(variables or {}, payload, extra_system) + try: + # LangGraph is sync here — we're already inside an Odoo + # request worker so sticking with sync keeps the control + # flow simple. + final: AgentState = graph.invoke(initial) + except Exception as exc: + _logger.exception("agent %s crashed", self.agent.key) + final = {**initial, "error": str(exc)} + + latency_ms = int((time.time() - t0) * 1000) + self._log(final, latency_ms) + return final + + # ------------------------------------------------------------------ + # Graph construction + # ------------------------------------------------------------------ + def _compile(self): + if self._graph is not None: + return self._graph + try: + from langgraph.graph import StateGraph, START, END + except ImportError as exc: + raise RuntimeError( + "LangGraph is not installed. " + "Add `langgraph>=0.2.0` to backend/requirements.txt and pip install." + ) from exc + + g = StateGraph(AgentState) + + if self.agent.graph_type == "simple": + g.add_node("llm", self._node_llm) + g.add_edge(START, "llm") + g.add_edge("llm", END) + + elif self.agent.graph_type == "rag": + g.add_node("retrieve", self._node_retrieve) + g.add_node("llm", self._node_llm) + g.add_edge(START, "retrieve") + g.add_edge("retrieve", "llm") + g.add_edge("llm", END) + + elif self.agent.graph_type == "plan_review_revise": + g.add_node("llm", self._node_llm) + g.add_node("review", self._node_review) + g.add_edge(START, "llm") + g.add_edge("llm", "review") + g.add_conditional_edges( + "review", + self._route_after_review, + {"revise": "llm", "done": END}, + ) + + elif self.agent.graph_type == "react": + g.add_node("llm", self._node_llm_tools) + g.add_node("tools", self._node_tools) + g.add_edge(START, "llm") + g.add_conditional_edges( + "llm", + self._route_after_llm_tools, + {"tools": "tools", "done": END}, + ) + g.add_edge("tools", "llm") + + else: + raise ValueError(f"Unknown graph_type: {self.agent.graph_type}") + + self._graph = g.compile() + return self._graph + + # ------------------------------------------------------------------ + # Initial state + # ------------------------------------------------------------------ + def _initial_state(self, variables: dict, payload: Any, extra_system: str) -> AgentState: + system_prompt = self.agent.resolved_system_prompt(variables) + messages: list[dict] = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + if extra_system: + messages.append({"role": "system", "content": extra_system}) + if payload is not None: + user_content = ( + payload if isinstance(payload, str) + else json.dumps(payload, ensure_ascii=False) + ) + messages.append({"role": "user", "content": user_content}) + return { + "messages": messages, + "output": None, + "output_raw": "", + "tool_calls": [], + "tool_results": [], + "quality_issues": [], + "revisions_used": 0, + "variables": variables, + "retrieval": [], + "iterations": 0, + "error": "", + } + + # ------------------------------------------------------------------ + # Nodes + # ------------------------------------------------------------------ + def _node_llm(self, state: AgentState) -> AgentState: + """Plain LLM call respecting the agent's model + response_format.""" + messages = list(state.get("messages") or []) + model = self.agent.model + action = f"agent.{self.agent.key}" + try: + if self.agent.response_format == "json": + content = self.ai.chat_json( + messages, + model=model, + temperature=self.agent.temperature, + max_tokens=self.agent.max_tokens, + action=action, + ) + raw = json.dumps(content, ensure_ascii=False) + else: + raw = self.ai.chat( + messages, + model=model, + temperature=self.agent.temperature, + max_tokens=self.agent.max_tokens, + action=action, + ) + content = raw + except Exception as exc: + # Try the fallback model exactly once before giving up. + if self.agent.fallback_model and model != self.agent.fallback_model: + _logger.warning( + "agent %s primary model %s failed (%s); retrying with %s", + self.agent.key, model, exc, self.agent.fallback_model, + ) + try: + if self.agent.response_format == "json": + content = self.ai.chat_json( + messages, model=self.agent.fallback_model, + temperature=self.agent.temperature, + max_tokens=self.agent.max_tokens, + action=f"{action}.fallback", + ) + raw = json.dumps(content, ensure_ascii=False) + else: + raw = self.ai.chat( + messages, model=self.agent.fallback_model, + temperature=self.agent.temperature, + max_tokens=self.agent.max_tokens, + action=f"{action}.fallback", + ) + content = raw + except Exception as exc2: + return {**state, "error": str(exc2)} + else: + return {**state, "error": str(exc)} + + new_messages = messages + [{"role": "assistant", "content": raw}] + return { + **state, + "messages": new_messages, + "output": content, + "output_raw": raw, + } + + def _node_retrieve(self, state: AgentState) -> AgentState: + """RAG node: call resources.search with the user's payload as query.""" + variables = state.get("variables") or {} + query = "" + # The last user message is our best default query. + for m in reversed(state.get("messages") or []): + if m.get("role") == "user": + query = m.get("content") or "" + break + query = variables.get("query") or query + hits = agent_tools.invoke(self.env, "resources.search", { + "query": query[:1000], + "limit": 5, + }) + items = hits.get("items") or [] + context = self._format_retrieval(items) + messages = list(state.get("messages") or []) + if context: + # Insert the context block *after* the system prompt(s). + last_sys = -1 + for i, m in enumerate(messages): + if m.get("role") == "system": + last_sys = i + insert_at = last_sys + 1 if last_sys >= 0 else 0 + messages.insert(insert_at, { + "role": "system", + "content": ( + "Relevant content from the library (use it when accurate, " + "cite ids; do not fabricate):\n\n" + context + ), + }) + return {**state, "messages": messages, "retrieval": items} + + def _node_review(self, state: AgentState) -> AgentState: + """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 + # meaningful (most just want prose). + text = json.dumps(state["output"], ensure_ascii=False) + issues: list[str] = [] + checks = [ + k.strip() for k in (self.agent.quality_checks or "").split(",") + if k.strip() + ] + variables = state.get("variables") or {} + target_cefr = ( + variables.get("cefr_level") + or variables.get("target_cefr") + or "b1" + ) + for key in checks: + res = agent_tools.invoke(self.env, key, { + "text": text, + "target_cefr": target_cefr, + "cefr_level": target_cefr, + }) + if res.get("ok") is False: + issues.extend(res.get("issues") or [res.get("error") or key]) + + 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: + # Pure router: only inspect state, never mutate. The decision + # was prepared in ``_node_review``. + if state.get("error"): + return "done" + return "revise" if state.get("should_revise") else "done" + + # ReAct / tool-calling ------------------------------------------------- + def _node_llm_tools(self, state: AgentState) -> AgentState: + """ReAct step: ask the LLM, exposing all enabled tools.""" + if state.get("iterations", 0) >= self.MAX_REACT_ITERATIONS: + return {**state, "error": "react_iteration_limit_exceeded"} + + client = self.ai.client + if client is None: + return {**state, "error": "openai_not_configured"} + + tools = [t.to_openai_tool() for t in self.agent.tool_ids] + try: + resp = client.chat.completions.create( + model=self.agent.model, + messages=state.get("messages") or [], + temperature=self.agent.temperature, + max_tokens=self.agent.max_tokens, + tools=tools or None, + tool_choice="auto" if tools else None, + timeout=self.ai.request_timeout, + ) + except Exception as exc: + return {**state, "error": str(exc)} + + choice = resp.choices[0].message + assistant_msg: dict[str, Any] = { + "role": "assistant", + "content": choice.content or "", + } + tool_calls = [] + if getattr(choice, "tool_calls", None): + # Preserve the OpenAI-shaped tool_calls list on the message so + # the next round references them by id. + assistant_msg["tool_calls"] = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments, + }, + } + for tc in choice.tool_calls + ] + for tc in choice.tool_calls: + try: + args = json.loads(tc.function.arguments or "{}") + except Exception: + args = {} + tool_calls.append({ + "id": tc.id, + "name": tc.function.name, + "args": args, + }) + + new_messages = list(state.get("messages") or []) + [assistant_msg] + return { + **state, + "messages": new_messages, + "tool_calls": tool_calls, + "output": choice.content or state.get("output"), + "output_raw": choice.content or state.get("output_raw") or "", + "iterations": state.get("iterations", 0) + 1, + } + + def _route_after_llm_tools(self, state: AgentState) -> str: + if state.get("error"): + return "done" + if state.get("tool_calls"): + return "tools" + return "done" + + def _node_tools(self, state: AgentState) -> AgentState: + """Execute every queued tool_call and append results to the chat.""" + allowed = {t.key: t for t in self.agent.tool_ids} + messages = list(state.get("messages") or []) + results: list[dict] = list(state.get("tool_results") or []) + for call in state.get("tool_calls") or []: + # Tools are stored with dotted keys but OpenAI flattens dots to + # double-underscores (because function names must match [A-Za-z0-9_]). + key = (call.get("name") or "").replace("__", ".") + if key not in allowed: + result = {"error": f"tool_not_allowed:{key}"} + else: + result = agent_tools.invoke(self.env, key, call.get("args") or {}) + results.append({"tool": key, "args": call.get("args"), "result": result}) + messages.append({ + "role": "tool", + "tool_call_id": call.get("id"), + "name": call.get("name") or key, + "content": json.dumps(result, ensure_ascii=False, default=str)[:6000], + }) + return { + **state, + "messages": messages, + "tool_calls": [], + "tool_results": results, + } + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + @staticmethod + def _format_retrieval(items: list[dict]) -> str: + parts = [] + for r in items or []: + label = f"[{r.get('type','?')}#{r.get('id','?')}]" + title = r.get("title") or "" + snippet = (r.get("snippet") or "")[:400] + parts.append(f"{label} {title}\n{snippet}") + return "\n---\n".join(parts) + + def _log(self, final: AgentState, latency_ms: int): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "openai", + "action": f"agent.{self.agent.key}", + "model_used": self.agent.model, + "latency_ms": latency_ms, + "status": "error" if final.get("error") else "success", + "error_message": final.get("error") or "", + "input_preview": json.dumps(final.get("variables") or {})[:500], + "output_preview": (final.get("output_raw") or "")[:500], + }) + except Exception: + _logger.warning("agent %s log write failed", self.agent.key, exc_info=True) diff --git a/backend/custom_addons/encoach_ai/services/agent_tools.py b/backend/custom_addons/encoach_ai/services/agent_tools.py new file mode 100644 index 00000000..fbaf8665 --- /dev/null +++ b/backend/custom_addons/encoach_ai/services/agent_tools.py @@ -0,0 +1,418 @@ +"""Python implementations of the tools the agent runtime can invoke. + +Every :py:class:`encoach.ai.tool` row in the DB points to one of the +handler functions registered here via :py:func:`register`. The DB row +holds the metadata (description, JSON Schema, admin toggle); the real +logic is Python so it can import Odoo models, run transactions and +reuse existing services (vector search, quality gates, etc.). + +Tools follow a strict contract: + +* Signature: ``handler(env, **params) -> dict``. +* The returned dict must be JSON-serialisable. It becomes the tool + message the LLM sees next, so keys should be descriptive. +* Tools that write to the DB must set ``mutates=True`` on their catalogue + row so the runtime wraps the call in a savepoint. +* Tools never raise: catch exceptions and return ``{"error": str(exc)}`` + so the agent can reason about failures and the top-level call keeps + going instead of aborting the whole graph. + +Adding a new tool +----------------- + +1. Write a handler below, decorated with ``@register("namespace.name")``. +2. Add a seed row in ``data/agents_defaults.xml`` with the same key. +3. Bind it to one or more agents in the same seed file. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Callable + +_logger = logging.getLogger(__name__) + +# Registry: tool key → handler callable. +_REGISTRY: dict[str, Callable[..., dict]] = {} + + +def register(key: str): + """Decorator to register a tool handler under ``key``.""" + + def decorator(func: Callable[..., dict]) -> Callable[..., dict]: + if key in _REGISTRY: + _logger.warning("Agent tool %r is being re-registered", key) + _REGISTRY[key] = func + return func + + return decorator + + +def get_handler(key: str) -> Callable[..., dict] | None: + return _REGISTRY.get(key) + + +def list_keys() -> list[str]: + return sorted(_REGISTRY.keys()) + + +def invoke(env, key: str, params: dict | None = None) -> dict: + """Resolve and invoke the handler for ``key`` with ``params``. + + All tool failures are normalised to ``{"error": "..."}`` so callers + (LangGraph nodes) don't need to care about exceptions. A missing + handler returns ``{"error": "unknown_tool"}``. + """ + handler = _REGISTRY.get(key) + if not handler: + return {"error": f"unknown_tool:{key}"} + try: + return handler(env, **(params or {})) + except TypeError as exc: + # Bad arguments from the LLM — make the complaint readable. + return {"error": f"bad_arguments: {exc}"} + except Exception as exc: + _logger.exception("agent tool %s failed", key) + return {"error": str(exc)} + + +# ============================================================================= +# Built-in tools +# ============================================================================= +# +# Each handler is deliberately small: they're thin adapters over services +# we already have. The LLM gets a compact JSON payload to reason over. + + +# --- Retrieval ---------------------------------------------------------------- +@register("resources.search") +def _search_resources(env, query: str = "", skill: str = "", cefr_level: str = "", + limit: int = 5, plan_id: int | None = None, + **_: Any) -> dict: + """Semantic search over the LMS resource library. + + When ``plan_id`` is provided, retrieval is scoped to that plan's + indexed reference sources only (uploaded PDFs, URLs, inline text). + Without ``plan_id`` we search the global resource library. + + Returns titles + short snippets so the agent can cite existing + materials instead of inventing new ones every run. + """ + from odoo.addons.encoach_vector.services.embedding_service import ( + EmbeddingService, + ) + try: + svc = EmbeddingService(env) + if plan_id: + results = svc.search( + query or "", + content_type="course_plan_source", + entity_id=int(plan_id), + limit=int(limit or 5), + ) + else: + results = svc.search(query or "", limit=int(limit or 5)) + except Exception as exc: + _logger.debug("resource vector search unavailable: %s", exc) + results = [] + out = [] + for r in results or []: + out.append({ + "id": r.get("content_id") or r.get("id"), + "type": r.get("content_type") or "", + "title": (r.get("metadata") or {}).get("title", ""), + "snippet": (r.get("text") or "")[:400], + "similarity": r.get("similarity"), + }) + return {"query": query, "plan_id": plan_id, "count": len(out), "items": out} + + +@register("rubric.fetch") +def _fetch_rubric(env, rubric_id: int | None = None, skill: str = "", **_: Any) -> dict: + """Return rubric criteria for a given id, or the newest rubric for a skill.""" + Rubric = env["encoach.rubric"].sudo() if "encoach.rubric" in env else None + if Rubric is None: + return {"error": "rubric_model_missing"} + rec = None + if rubric_id: + rec = Rubric.browse(int(rubric_id)) + if not rec.exists(): + rec = None + if rec is None and skill: + rec = Rubric.search( + [("skill", "=", skill)], order="create_date desc", limit=1, + ) + if not rec: + return {"error": "rubric_not_found"} + criteria = [] + for crit in getattr(rec, "criterion_ids", rec.browse([])): + criteria.append({ + "code": getattr(crit, "code", "") or "", + "name": crit.name or "", + "weight": getattr(crit, "weight", 0) or 0, + "descriptors": getattr(crit, "descriptors", "") or "", + }) + return { + "id": rec.id, + "name": rec.name, + "skill": getattr(rec, "skill", "") or "", + "criteria": criteria, + } + + +@register("outcomes.fetch") +def _fetch_course_outcomes(env, course_id: int | None = None, + cefr_level: str = "", **_: Any) -> dict: + """Return learning outcomes for a course (or CEFR level).""" + LO = env["encoach.learning.objective"].sudo() \ + if "encoach.learning.objective" in env else None + if LO is None: + return {"error": "learning_objective_model_missing"} + domain = [] + if course_id: + domain.append(("course_id", "=", int(course_id))) + if cefr_level: + domain.append(("cefr_level", "=", cefr_level)) + records = LO.search(domain, limit=200) + return { + "count": len(records), + "items": [{ + "id": r.id, + "code": getattr(r, "code", "") or "", + "skill": getattr(r, "skill", "") or "", + "cefr_level": getattr(r, "cefr_level", "") or "", + "description": r.name or getattr(r, "description", "") or "", + } for r in records], + } + + +@register("student.profile") +def _fetch_student_profile(env, student_id: int, **_: Any) -> dict: + """Return a compact gap-profile the agent can use to personalise content.""" + SP = env["encoach.student.profile"].sudo() \ + if "encoach.student.profile" in env else None + if SP is None: + return {"error": "student_profile_model_missing"} + rec = SP.search([("student_id", "=", int(student_id))], limit=1) + if not rec: + return {"error": "profile_not_found"} + return { + "student_id": int(student_id), + "cefr_level": getattr(rec, "cefr_level", "") or "", + "strengths_json": getattr(rec, "strengths_json", "") or "", + "gaps_json": getattr(rec, "gaps_json", "") or "", + } + + +# --- Quality gates ------------------------------------------------------------ +@register("quality.cefr_check") +def _cefr_check(env, text: str = "", target_cefr: str = "b1", **_: Any) -> dict: + """Grade the readability of ``text`` against a target CEFR band.""" + try: + import textstat # noqa: F401 + fk = textstat.flesch_kincaid_grade(text or "") + fre = textstat.flesch_reading_ease(text or "") + except Exception: + fk, fre = None, None + # Rough mapping — deliberately conservative; the LLM uses it as a hint. + band_map = {"a1": (1, 3), "a2": (3, 5), "b1": (5, 7), + "b2": (7, 9), "c1": (9, 12), "c2": (12, 20)} + ok = True + issues = [] + if fk is not None: + lo, hi = band_map.get((target_cefr or "b1").lower(), (5, 7)) + if fk < lo - 0.5: + ok = False + issues.append(f"Text reads below {target_cefr.upper()} (FK={fk:.1f})") + elif fk > hi + 0.5: + ok = False + issues.append(f"Text reads above {target_cefr.upper()} (FK={fk:.1f})") + return { + "ok": ok, + "target_cefr": target_cefr, + "flesch_kincaid": fk, + "flesch_reading_ease": fre, + "issues": issues, + } + + +@register("quality.ai_detect") +def _ai_detect(env, text: str = "", **_: Any) -> dict: + """Return AI-detection probability if GPTZero is configured, else neutral.""" + try: + # Try to reuse whatever GPTZero wrapper the platform already has. + from odoo.addons.encoach_ai.services.gptzero_service import ( + GPTZeroService, # type: ignore + ) + svc = GPTZeroService(env) + return svc.score(text) + except Exception as exc: + _logger.debug("gptzero unavailable: %s", exc) + return {"ok": True, "ai_probability": None, "note": "detector_unavailable"} + + +@register("quality.content_gate") +def _content_gate(env, text: str = "", cefr_level: str = "b1", **_: Any) -> dict: + """Run the project's unified content-source gate (if installed).""" + try: + from odoo.addons.encoach_quality_gate.services.content_source_gate import ( + ContentSourceGate, # type: ignore + ) + gate = ContentSourceGate(env) + return gate.check(text, cefr_level=cefr_level) + except Exception as exc: + _logger.debug("content_gate unavailable: %s", exc) + return {"ok": True, "note": "gate_unavailable"} + + +# --- Persistence -------------------------------------------------------------- +@register("course_plan.save") +def _save_course_plan(env, plan_vals: dict, weeks: list | None = None, **_: Any) -> dict: + """Persist an AI-generated course plan. Used by the course_planner agent. + + ``plan_vals`` is the dict the LLM produced (after the runtime's JSON + normalisation). ``weeks`` is the list of per-week rows. The handler is + idempotent-friendly: it always creates new rows (agents are expected + to decide whether to reuse an existing plan before calling this). + """ + Plan = env["encoach.course.plan"].sudo() if "encoach.course.plan" in env else None + Week = env["encoach.course.plan.week"].sudo() \ + if "encoach.course.plan.week" in env else None + if Plan is None or Week is None: + return {"error": "course_plan_models_missing"} + plan = Plan.create(plan_vals or {}) + created_weeks = 0 + for w in weeks or []: + try: + Week.create({**w, "plan_id": plan.id}) + created_weeks += 1 + except Exception as exc: + _logger.warning("agent tool course_plan.save: bad week row %r: %s", w, exc) + return {"plan_id": plan.id, "weeks_created": created_weeks} + + +@register("course_plan.save_materials") +def _save_week_materials(env, plan_id: int, week_id: int, + materials: list | None = None, **_: Any) -> dict: + Material = env["encoach.course.plan.material"].sudo() \ + if "encoach.course.plan.material" in env else None + if Material is None: + return {"error": "material_model_missing"} + created = 0 + for m in materials or []: + try: + Material.create({ + **m, + "plan_id": int(plan_id), + "week_id": int(week_id), + "body_json": json.dumps(m.get("body") or {}, ensure_ascii=False) + if "body" in m else m.get("body_json", ""), + }) + created += 1 + except Exception as exc: + _logger.warning("agent tool save_materials: bad row %r: %s", m, exc) + return {"plan_id": plan_id, "week_id": week_id, "materials_created": created} + + +# --- Scoring (best-effort wrappers over existing services) -------------------- +@register("scoring.grade_writing") +def _grade_writing(env, rubric: str = "", task: str = "", response: str = "", + **_: Any) -> dict: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + svc = OpenAIService(env) + try: + return svc.grade_writing(rubric, task, response) + except Exception as exc: + return {"error": str(exc)} + + +@register("scoring.grade_speaking") +def _grade_speaking(env, rubric: str = "", transcript: str = "", **_: Any) -> dict: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + svc = OpenAIService(env) + try: + return svc.grade_speaking(rubric, transcript) + except Exception as exc: + return {"error": str(exc)} + + +# --- Media generation tools --------------------------------------------------- +# +# These bridge an agent decision (e.g. "this listening lesson needs an MP3") +# into the MediaService implementation. They are mutating tools — the seed +# row in agents_defaults.xml has ``mutates=True`` so the runtime wraps them +# in a savepoint. + +@register("media.synthesize_audio") +def _media_synthesize_audio(env, material_id: int, voice: str = "", + language: str = "en-GB", + gender: str = "female", + provider: str = "polly", **_: Any) -> dict: + Material = env["encoach.course.plan.material"].sudo() \ + if "encoach.course.plan.material" in env else None + if Material is None: + return {"error": "course_plan_material_model_missing"} + rec = Material.browse(int(material_id)) + if not rec.exists(): + return {"error": "material_not_found"} + from odoo.addons.encoach_ai_course.services.media_service import MediaService + svc = MediaService(env) + media = svc.synthesize_audio( + rec, voice=voice or None, language=language or "en-GB", + gender=gender or "female", provider=provider or "polly", + ) + return { + "media_id": media.id, + "status": media.status, + "download_url": media.download_url, + "error": media.error or None, + } + + +@register("media.generate_image") +def _media_generate_image(env, material_id: int, prompt: str = "", + size: str = "1024x1024", + style: str = "natural", + quality: str = "standard", **_: Any) -> dict: + Material = env["encoach.course.plan.material"].sudo() \ + if "encoach.course.plan.material" in env else None + if Material is None: + return {"error": "course_plan_material_model_missing"} + rec = Material.browse(int(material_id)) + if not rec.exists(): + return {"error": "material_not_found"} + from odoo.addons.encoach_ai_course.services.media_service import MediaService + svc = MediaService(env) + media = svc.generate_image( + rec, custom_prompt=prompt or None, + size=size or "1024x1024", + style=style or "natural", + quality=quality or "standard", + ) + return { + "media_id": media.id, + "status": media.status, + "download_url": media.download_url, + "error": media.error or None, + } + + +@register("media.compose_video") +def _media_compose_video(env, material_id: int, **_: Any) -> dict: + Material = env["encoach.course.plan.material"].sudo() \ + if "encoach.course.plan.material" in env else None + if Material is None: + return {"error": "course_plan_material_model_missing"} + rec = Material.browse(int(material_id)) + if not rec.exists(): + return {"error": "material_not_found"} + from odoo.addons.encoach_ai_course.services.media_service import MediaService + svc = MediaService(env) + media = svc.compose_video(rec) + return { + "media_id": media.id, + "status": media.status, + "download_url": media.download_url, + "error": media.error or None, + } diff --git a/backend/custom_addons/encoach_ai/services/coach_service.py b/backend/custom_addons/encoach_ai/services/coach_service.py index 8b0bf2e4..5bf10cf4 100644 --- a/backend/custom_addons/encoach_ai/services/coach_service.py +++ b/backend/custom_addons/encoach_ai/services/coach_service.py @@ -9,10 +9,10 @@ _logger = logging.getLogger(__name__) class CoachService: """High-level AI coaching: chat, tips, explanations, writing help, study plans.""" - def __init__(self, env): + def __init__(self, env, *, language=None): from .openai_service import OpenAIService self.env = env - self.ai = OpenAIService(env) + self.ai = OpenAIService(env, language=language) def _log(self, action, latency_ms=0, status="success", error=None, inp=None, out=None): try: diff --git a/backend/custom_addons/encoach_ai/services/free_image.py b/backend/custom_addons/encoach_ai/services/free_image.py new file mode 100644 index 00000000..e2a8f0fd --- /dev/null +++ b/backend/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/backend/custom_addons/encoach_ai/services/free_tts.py b/backend/custom_addons/encoach_ai/services/free_tts.py new file mode 100644 index 00000000..58ca1242 --- /dev/null +++ b/backend/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('= 0 else 0 + messages.insert(insert_at, lang_msg) + 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", @@ -50,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: @@ -66,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: @@ -82,6 +213,7 @@ class OpenAIService: if not self.client: raise RuntimeError("OpenAI not configured — set API key in AI Settings") model = model or self.model + messages = self._inject_language(messages) t0 = time.time() try: def _call(): @@ -108,6 +240,7 @@ class OpenAIService: if not self.client: raise RuntimeError("OpenAI not configured — set API key in AI Settings") model = model or self.model + messages = self._inject_language(messages) t0 = time.time() try: def _call(): @@ -133,6 +266,60 @@ class OpenAIService: """Use the fast/cheap model for classification, tagging, simple tasks.""" return self.chat(messages, model=self.fast_model, **kwargs) + def generate_image(self, prompt, *, size="1024x1024", style="natural", + quality="standard", model=None): + """Generate an image with DALL-E 3 and return raw PNG bytes. + + Returns: + dict {"image": bytes, "revised_prompt": str, "model": str, + "size": str, "style": str}. + + Raises ``RuntimeError`` if OpenAI is not configured. Network / + moderation failures bubble up as the SDK's exceptions; callers + should catch and record them. + """ + self._check_enabled() + if not self.client: + raise RuntimeError("OpenAI not configured — set API key in AI Settings") + image_model = model or self._get_param( + "encoach_ai.openai_image_model", "dall-e-3", + ) + t0 = time.time() + try: + resp = self.client.images.generate( + model=image_model, + prompt=prompt or "", + n=1, + size=size, + style=style, + quality=quality, + response_format="b64_json", + timeout=self.request_timeout, + ) + data = resp.data[0] + import base64 as _b64 + img_bytes = _b64.b64decode(data.b64_json) + self._log( + "generate_image", image_model, None, + int((time.time() - t0) * 1000), + inp=(prompt or "")[:500], + out=f"{len(img_bytes)}B {size} {style}", + ) + return { + "image": img_bytes, + "revised_prompt": getattr(data, "revised_prompt", "") or "", + "model": image_model, + "size": size, + "style": style, + } + except Exception as exc: + self._log( + "generate_image", image_model, None, + int((time.time() - t0) * 1000), + status="error", error=str(exc), + ) + raise + def grade_writing(self, rubric, task_text, response_text): """Grade a writing response using GPT with a rubric.""" messages = [ diff --git a/backend/custom_addons/encoach_ai/services/provider_router.py b/backend/custom_addons/encoach_ai/services/provider_router.py new file mode 100644 index 00000000..5f33f104 --- /dev/null +++ b/backend/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/backend/custom_addons/encoach_ai_course/controllers/__init__.py b/backend/custom_addons/encoach_ai_course/controllers/__init__.py index 904effd7..50bdc7a9 100644 --- a/backend/custom_addons/encoach_ai_course/controllers/__init__.py +++ b/backend/custom_addons/encoach_ai_course/controllers/__init__.py @@ -1 +1,2 @@ from . import ai_course +from . import course_plan diff --git a/backend/custom_addons/encoach_ai_course/controllers/course_plan.py b/backend/custom_addons/encoach_ai_course/controllers/course_plan.py new file mode 100644 index 00000000..edb5acae --- /dev/null +++ b/backend/custom_addons/encoach_ai_course/controllers/course_plan.py @@ -0,0 +1,853 @@ +"""REST endpoints for AI course-plan generation and browsing. + +All endpoints sit under ``/api/ai/course-plan`` so they don't collide +with the existing ``/api/ai-course/...`` English / IELTS generation +endpoints. Every route is JWT-guarded via the shared ``@jwt_required`` +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, + _paginate, +) +from odoo.addons.encoach_ai_course.services.course_plan_pipeline import ( + CoursePlanPipeline, +) +from odoo.addons.encoach_ai_course.services.deliverables import ( + compute_deliverables, +) +from odoo.addons.encoach_ai_course.services.media_service import MediaService + +_logger = logging.getLogger(__name__) + + +def _request_language(): + """Return the UI language sent by the frontend as a short ISO code.""" + try: + raw = ( + request.httprequest.headers.get('X-UI-Language') + or request.httprequest.headers.get('Accept-Language') + or 'en' + ) + except Exception: + raw = 'en' + 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 + # ------------------------------------------------------------------ + @http.route('/api/ai/course-plan', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def generate_plan(self, **kw): + try: + 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), 403 if isinstance(exc, PermissionError) else 500) + + # ------------------------------------------------------------------ + # GET /api/ai/course-plan + # ------------------------------------------------------------------ + @http.route('/api/ai/course-plan', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def list_plans(self, **kw): + try: + params = request.httprequest.args + 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({ + 'page': params.get('page', 0), + 'size': params.get('size', 20), + }) + total = Plan.search_count(domain) + records = Plan.search( + domain, offset=offset, limit=limit, + order='create_date desc, id desc', + ) + return _json_response({ + 'items': [r.to_api_dict(include_weeks=False) for r in records], + 'page': {'page': page, 'size': limit, 'total': total}, + }) + except Exception as exc: + _logger.exception('course-plan.list failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + # ------------------------------------------------------------------ + # GET /api/ai/course-plan/ + # ------------------------------------------------------------------ + @http.route('/api/ai/course-plan/', type='http', + auth='none', methods=['GET'], csrf=False) + @jwt_required + def get_plan(self, plan_id, **kw): + try: + 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), 403 if isinstance(exc, PermissionError) else 500) + + # ------------------------------------------------------------------ + # DELETE /api/ai/course-plan/ + # ------------------------------------------------------------------ + @http.route('/api/ai/course-plan/', type='http', + auth='none', methods=['DELETE'], csrf=False) + @jwt_required + def delete_plan(self, plan_id, **kw): + try: + 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), 403 if isinstance(exc, PermissionError) else 500) + + # ------------------------------------------------------------------ + # POST /api/ai/course-plan//weeks//materials + # ------------------------------------------------------------------ + @http.route('/api/ai/course-plan//weeks//materials', + type='http', auth='none', methods=['POST'], csrf=False) + @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(), + ) + materials = pipeline.generate_week_materials(plan_id, week_number) + return _json_response({ + 'items': [m.to_api_dict() for m in materials], + 'count': len(materials), + }) + except ValueError as exc: + return _error_response(str(exc), 404) + except Exception as exc: + _logger.exception('course-plan.generate_week_materials failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + # ------------------------------------------------------------------ + # GET /api/ai/course-plan//weeks//materials + # ------------------------------------------------------------------ + @http.route('/api/ai/course-plan//weeks//materials', + type='http', auth='none', methods=['GET'], csrf=False) + @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)), + ], limit=1) + if not week: + return _error_response('Week not found', 404) + return _json_response({ + 'items': [m.to_api_dict() for m in week.material_ids], + 'count': len(week.material_ids), + }) + except Exception as exc: + _logger.exception('course-plan.list_week_materials failed') + return _error_response(str(exc), 403 if isinstance(exc, PermissionError) else 500) + + # ================================================================== + # PHASE A — Reference sources (RAG grounding) + # ================================================================== + + @http.route('/api/ai/course-plan//sources', + type='http', auth='none', methods=['GET'], csrf=False) + @jwt_required + def list_sources(self, plan_id, **kw): + try: + 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), 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 = self._get_plan_scoped(plan_id) + + ct = request.httprequest.content_type or '' + files = request.httprequest.files + uploaded = files.get('file') + if 'application/json' in ct: + body = _get_json_body() or {} + else: + body = {**kw} + + kind = (body.get('kind') or '').strip() or ( + 'file' if uploaded else + ('url' if (body.get('url') or '').strip() else 'text') + ) + name = (body.get('name') or '').strip() + auto_index = body.get('auto_index') + if isinstance(auto_index, str): + auto_index = auto_index.lower() not in ('0', 'false', 'no', 'off') + elif auto_index is None: + auto_index = True + vals = { + 'plan_id': plan.id, + 'kind': kind, + 'auto_index': bool(auto_index), + } + if kind == 'file': + if not uploaded: + return _error_response('No file uploaded', 400) + payload = uploaded.read() + vals['file'] = base64.b64encode(payload) + vals['file_name'] = uploaded.filename or 'source' + vals['mime_type'] = uploaded.mimetype or '' + vals['name'] = name or uploaded.filename or 'source' + elif kind == 'url': + url = (body.get('url') or '').strip() + if not url: + return _error_response('URL is required', 400) + vals['url'] = url + vals['name'] = name or url + else: + text = (body.get('inline_text') or body.get('text') or '').strip() + if not text: + return _error_response('Inline text is required', 400) + vals['inline_text'] = text + vals['name'] = name or 'Inline text' + + 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), 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), 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), 403 if isinstance(exc, PermissionError) else 500) + + # ================================================================== + # PHASE B — Deliverables preview / progress + # ================================================================== + + @http.route('/api/ai/course-plan//deliverables', + type='http', auth='none', methods=['GET'], csrf=False) + @jwt_required + def get_deliverables(self, plan_id, **kw): + try: + 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), 403 if isinstance(exc, PermissionError) else 500) + + # ================================================================== + # PHASE C — Multimedia generation per material + # ================================================================== + + def _resolve_material(self, material_id): + 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 + def gen_audio(self, material_id, **kw): + try: + material = self._resolve_material(material_id) + if not material: + return _error_response('Material not found', 404) + body = _get_json_body() or {} + svc = MediaService(request.env) + media = svc.synthesize_audio( + material, + voice=body.get('voice'), + language=body.get('language') or 'en-GB', + gender=body.get('gender') or 'female', + 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') + 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) + @jwt_required + def gen_image(self, material_id, **kw): + try: + material = self._resolve_material(material_id) + if not material: + return _error_response('Material not found', 404) + body = _get_json_body() or {} + svc = MediaService(request.env) + media = svc.generate_image( + material, + custom_prompt=body.get('prompt'), + size=body.get('size') or '1024x1024', + style=body.get('style') or 'natural', + quality=body.get('quality') or 'standard', + ) + return _json_response({'data': media.to_api_dict()}) + except Exception as exc: + _logger.exception('course-plan.gen_image 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/video', + type='http', auth='none', methods=['POST'], csrf=False) + @jwt_required + def gen_video(self, material_id, **kw): + try: + material = self._resolve_material(material_id) + if not material: + return _error_response('Material not found', 404) + svc = MediaService(request.env) + media = svc.compose_video(material) + return _json_response({'data': media.to_api_dict()}) + except Exception as exc: + _logger.exception('course-plan.gen_video 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', + type='http', auth='none', methods=['GET'], csrf=False) + @jwt_required + def list_material_media(self, material_id, **kw): + try: + material = self._resolve_material(material_id) + if not material: + return _error_response('Material not found', 404) + return _json_response({ + 'items': [m.to_api_dict() for m in material.media_ids], + 'count': len(material.media_ids), + }) + except Exception as exc: + _logger.exception('course-plan.list_material_media failed') + 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 + # ```` / ``