feat(ai): LangGraph as core runtime + AI Agents/Tools console + full-demo seed
Core AI runtime - New encoach.ai.agent + encoach.ai.tool models with M2M tool binding, graph topology (simple|plan_review_revise|rag|react), model + fallback, temperature, max_tokens, response_format, max_revisions, quality checks and system prompt fields. - services/agent_runtime.py compiles a langgraph.StateGraph per agent and caches the build per (key, write_date). Emits a structured trace (output, tool_calls, retrieval_hits, revisions, quality_issues, ms, model_used, fallback_used) and auto-falls-back on rate-limit/5xx. - services/agent_tools.py registers 11 tool handlers wrapping existing services: resources.search, rubric.fetch, outcomes.fetch, student.profile, quality.cefr_check, quality.ai_detect, quality.content_gate, course_plan.save (mutates), course_plan.save_materials (mutates), scoring.grade_writing, scoring.grade_speaking. - 7 default agents seeded via data/agents_defaults.xml: course_planner, course_week_materials, exam_generator, exercise_generator, lms_tutor, writing_grader, speaking_grader. - Feature flag encoach_ai.use_langgraph_runtime (default True). - encoach_ai_course pipeline now routes through AgentRuntime when on, legacy SDK path kept as fallback. Admin UI - /admin/ai/prompts is now a tabbed Agents | Tools | Prompts console. - AIAgentsPanel: card grid + config dialog (model/temp/graph/tools/ system prompt) + built-in Test Runner showing live trace. - AIToolsPanel: registry table with category badges, mutates flag, schema viewer, edit dialog. - New /api/ai/agents* and /api/ai/tools* controller (list/get/update/ test, list-tools, toggle-tool). - Sidebar label nav.aiPrompts -> nav.aiAgents (AI Agents and Tools). - EN + AR (RTL) translations for ~80 new keys. Smart Wizard pages - /admin/quick-setup hub + CourseWizard, CoursePlanWizard, RubricWizard, ExamStructureWizard step-by-step flows. - /admin/course-plans list + detail pages. - /teacher/quick-setup mirror. Full demo seed + 8-role E2E - seed_full_demo.py adds the 5 missing user_types (approver, corporate, mastercorporate, agent, developer), activates a 2-stage exam-approval workflow with one pending request, creates a GE1-aligned 12-week B1 course plan with 6 detailed Week-1 materials (reading 400w, writing, listening 4-min script, speaking, grammar present simple vs continuous, vocabulary), and inserts sample ai.log + ai.feedback rows. - reset_demo_passwords.py forces every demo login back to canonical passwords (admin123/teacher123/student123/approver123/corporate123/ master123/agent123/dev123). - e2e_full_scenario.py: 46/46 PASS read-only API smoke across all 8 roles, including a live LangGraph round-trip on writing_grader. - e2e_approval_chain.py: 6/6 PASS mutation E2E - approver approves stage 1, admin approves stage 2, linked encoach.exam.custom flips to status=published, verified via psql. Docs - docs/PROJECT_SUMMARY.md updated to 2026-04-25: new Latest events bullets, refreshed credentials table, full sections 22 (LangGraph runtime) and 23 (full demo seed + 8-role E2E). - docs/ENCOACH_FULL_DEMO_QA_REPORT.md added with credentials, per-endpoint PASS/FAIL, mutation chain proof, LangGraph live output. - backend/GE1 Course Outline_ Fall AY25-26.pdf vendored as the reference outline the GE1 plan/materials are aligned to. Dependencies - requirements.txt: langgraph>=0.2.0, langchain-core>=0.3.0. - encoach_ai/__manifest__.py: external_dependencies updated. Made-with: Cursor
This commit is contained in:
@@ -3,3 +3,4 @@ from . import coach_controller
|
||||
from . import media_controller
|
||||
from . import prompt_controller
|
||||
from . import feedback_controller
|
||||
from . import agents_controller
|
||||
|
||||
@@ -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/<id>`` and
|
||||
``POST /api/ai/agents/<id>/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/<id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/ai/agents/<int:agent_id>",
|
||||
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/<id> (admin-only)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/ai/agents/<int:agent_id>",
|
||||
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/<id>/test (admin-only)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/ai/agents/<int:agent_id>/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/<id> (admin-only; currently toggle active)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(
|
||||
"/api/ai/agents/tools/<int:tool_id>",
|
||||
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)
|
||||
Reference in New Issue
Block a user