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:
@@ -7,3 +7,5 @@ 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
|
||||
|
||||
500
backend/custom_addons/encoach_ai/services/agent_runtime.py
Normal file
500
backend/custom_addons/encoach_ai/services/agent_runtime.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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):
|
||||
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 every configured quality tool against the LLM's output."""
|
||||
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])
|
||||
return {**state, "quality_issues": issues}
|
||||
|
||||
def _route_after_review(self, state: AgentState) -> str:
|
||||
issues = state.get("quality_issues") or []
|
||||
if not issues:
|
||||
return "done"
|
||||
if (state.get("revisions_used") or 0) >= max(0, self.agent.max_revisions):
|
||||
return "done"
|
||||
# Queue up a revision: add a system message with the critique and
|
||||
# bump the counter. We return via "revise" which loops back to
|
||||
# the LLM node.
|
||||
critique = (
|
||||
"Your previous draft was rejected for the following reasons:\n- "
|
||||
+ "\n- ".join(issues)
|
||||
+ "\n\nProduce an improved version that addresses every issue. "
|
||||
"Keep the same JSON schema if one was requested."
|
||||
)
|
||||
messages = list(state.get("messages") or []) + [
|
||||
{"role": "system", "content": critique}
|
||||
]
|
||||
state["messages"] = messages
|
||||
state["revisions_used"] = (state.get("revisions_used") or 0) + 1
|
||||
return "revise"
|
||||
|
||||
# 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)
|
||||
326
backend/custom_addons/encoach_ai/services/agent_tools.py
Normal file
326
backend/custom_addons/encoach_ai/services/agent_tools.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""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, **_: Any) -> dict:
|
||||
"""Semantic search over the LMS 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, # noqa: F401
|
||||
)
|
||||
try:
|
||||
svc = EmbeddingService(env)
|
||||
# EmbeddingService.search is expected to filter by content_type;
|
||||
# we accept a skill filter from the agent but don't require it.
|
||||
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, "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)}
|
||||
Reference in New Issue
Block a user