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:
@@ -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
|
||||
|
||||
357
backend/custom_addons/encoach_ai/models/ai_agent.py
Normal file
357
backend/custom_addons/encoach_ai/models/ai_agent.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""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"),
|
||||
("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
|
||||
Reference in New Issue
Block a user