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:
Yamen Ahmad
2026-04-25 03:13:55 +04:00
parent 170d7c8d2e
commit 882179870c
19 changed files with 2728 additions and 1 deletions

View File

@@ -17,12 +17,13 @@
"author": "EnCoach",
"depends": ["base", "encoach_core", "encoach_api"],
"external_dependencies": {
"python": ["openai", "boto3"],
"python": ["openai", "boto3", "langgraph", "langchain_core"],
},
"data": [
"security/ir.model.access.csv",
"views/ai_settings_views.xml",
"data/ai_defaults.xml",
"data/agents_defaults.xml",
],
"installable": True,
"application": True,

View File

@@ -3,3 +3,4 @@ from . import coach_controller
from . import media_controller
from . import prompt_controller
from . import feedback_controller
from . import agents_controller

View File

@@ -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)

View File

@@ -0,0 +1,337 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<!--
Default AI agents + tools seeded on first install.
These are the *sensible defaults* the user asked for: every platform
pillar (course planning, weekly materials, exam generation, exercise
generation, LMS tutor, grading) gets a pre-configured LangGraph
agent so the system works out of the box. Admins edit the system
prompts, models, temperatures and tool bindings from
/admin/ai/prompts → Agents tab.
-->
<!-- ============================== TOOLS ============================== -->
<!-- Retrieval -->
<record id="ai_tool_resources_search" model="encoach.ai.tool">
<field name="key">resources.search</field>
<field name="name">Search resources</field>
<field name="category">retrieval</field>
<field name="description">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.</field>
<field name="schema_json">{"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"]}</field>
<field name="sequence">10</field>
</record>
<record id="ai_tool_rubric_fetch" model="encoach.ai.tool">
<field name="key">rubric.fetch</field>
<field name="name">Fetch rubric</field>
<field name="category">reference</field>
<field name="description">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.</field>
<field name="schema_json">{"type":"object","properties":{"rubric_id":{"type":"integer"},"skill":{"type":"string","enum":["reading","writing","listening","speaking"]}}}</field>
<field name="sequence">20</field>
</record>
<record id="ai_tool_outcomes_fetch" model="encoach.ai.tool">
<field name="key">outcomes.fetch</field>
<field name="name">Fetch course outcomes</field>
<field name="category">reference</field>
<field name="description">Return the registered learning outcomes for a course or CEFR level. Use it when generating course plans to stay aligned with the programme specification.</field>
<field name="schema_json">{"type":"object","properties":{"course_id":{"type":"integer"},"cefr_level":{"type":"string","enum":["pre_a1","a1","a2","b1","b2","c1","c2"]}}}</field>
<field name="sequence">30</field>
</record>
<record id="ai_tool_student_profile" model="encoach.ai.tool">
<field name="key">student.profile</field>
<field name="name">Get student gap profile</field>
<field name="category">reference</field>
<field name="description">Return the student's CEFR band, strengths and gaps so content can be personalised. Required input for personalised exercise generation and tutor follow-ups.</field>
<field name="schema_json">{"type":"object","properties":{"student_id":{"type":"integer"}},"required":["student_id"]}</field>
<field name="sequence">40</field>
</record>
<!-- Quality gates -->
<record id="ai_tool_quality_cefr" model="encoach.ai.tool">
<field name="key">quality.cefr_check</field>
<field name="name">CEFR readability check</field>
<field name="category">quality</field>
<field name="description">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.</field>
<field name="schema_json">{"type":"object","properties":{"text":{"type":"string"},"target_cefr":{"type":"string","enum":["a1","a2","b1","b2","c1","c2"]}},"required":["text","target_cefr"]}</field>
<field name="sequence">50</field>
</record>
<record id="ai_tool_quality_ai" model="encoach.ai.tool">
<field name="key">quality.ai_detect</field>
<field name="name">AI-content detection</field>
<field name="category">quality</field>
<field name="description">Probability the text was written by an AI (via GPTZero). Used during submission review — not usually during generation.</field>
<field name="schema_json">{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}</field>
<field name="sequence">60</field>
</record>
<record id="ai_tool_quality_gate" model="encoach.ai.tool">
<field name="key">quality.content_gate</field>
<field name="name">Unified content gate</field>
<field name="category">quality</field>
<field name="description">Run the project's combined content-source gate (CEFR + toxicity + length checks). Returns ok=false with the first failing rule.</field>
<field name="schema_json">{"type":"object","properties":{"text":{"type":"string"},"cefr_level":{"type":"string"}},"required":["text"]}</field>
<field name="sequence">70</field>
</record>
<!-- Persistence -->
<record id="ai_tool_course_plan_save" model="encoach.ai.tool">
<field name="key">course_plan.save</field>
<field name="name">Save course plan</field>
<field name="category">persistence</field>
<field name="mutates" eval="True"/>
<field name="description">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.</field>
<field name="schema_json">{"type":"object","properties":{"plan_vals":{"type":"object"},"weeks":{"type":"array","items":{"type":"object"}}},"required":["plan_vals"]}</field>
<field name="sequence">80</field>
</record>
<record id="ai_tool_course_plan_save_materials" model="encoach.ai.tool">
<field name="key">course_plan.save_materials</field>
<field name="name">Save weekly teaching materials</field>
<field name="category">persistence</field>
<field name="mutates" eval="True"/>
<field name="description">Persist the generated per-week teaching materials against an existing course plan and week.</field>
<field name="schema_json">{"type":"object","properties":{"plan_id":{"type":"integer"},"week_id":{"type":"integer"},"materials":{"type":"array","items":{"type":"object"}}},"required":["plan_id","week_id","materials"]}</field>
<field name="sequence">90</field>
</record>
<!-- Scoring -->
<record id="ai_tool_scoring_writing" model="encoach.ai.tool">
<field name="key">scoring.grade_writing</field>
<field name="name">Grade writing response</field>
<field name="category">scoring</field>
<field name="description">Grade a writing response against a rubric using the platform's standard writing examiner prompt.</field>
<field name="schema_json">{"type":"object","properties":{"rubric":{"type":"string"},"task":{"type":"string"},"response":{"type":"string"}},"required":["rubric","response"]}</field>
<field name="sequence">100</field>
</record>
<record id="ai_tool_scoring_speaking" model="encoach.ai.tool">
<field name="key">scoring.grade_speaking</field>
<field name="name">Grade speaking transcript</field>
<field name="category">scoring</field>
<field name="description">Grade a speaking transcript against a rubric using the platform's standard speaking examiner prompt.</field>
<field name="schema_json">{"type":"object","properties":{"rubric":{"type":"string"},"transcript":{"type":"string"}},"required":["rubric","transcript"]}</field>
<field name="sequence">110</field>
</record>
<!-- ============================== AGENTS ============================== -->
<!-- 1. Course planner -->
<record id="ai_agent_course_planner" model="encoach.ai.agent">
<field name="key">course_planner</field>
<field name="name">Course Planner</field>
<field name="description">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.</field>
<field name="model">gpt-4o</field>
<field name="fallback_model">gpt-4o-mini</field>
<field name="temperature">0.4</field>
<field name="max_tokens">4096</field>
<field name="response_format">json</field>
<field name="graph_type">plan_review_revise</field>
<field name="max_revisions">1</field>
<field name="quality_checks">quality.cefr_check</field>
<field name="sequence">10</field>
<field name="system_prompt">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.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_outcomes_fetch'),
ref('ai_tool_resources_search'),
ref('ai_tool_quality_cefr'),
ref('ai_tool_course_plan_save'),
])]"/>
</record>
<!-- 2. Week materials -->
<record id="ai_agent_course_week_materials" model="encoach.ai.agent">
<field name="key">course_week_materials</field>
<field name="name">Week Teaching Materials</field>
<field name="description">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.</field>
<field name="model">gpt-4o</field>
<field name="fallback_model">gpt-4o-mini</field>
<field name="temperature">0.6</field>
<field name="max_tokens">6000</field>
<field name="response_format">json</field>
<field name="graph_type">plan_review_revise</field>
<field name="max_revisions">1</field>
<field name="quality_checks">quality.cefr_check</field>
<field name="sequence">20</field>
<field name="system_prompt">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.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_outcomes_fetch'),
ref('ai_tool_resources_search'),
ref('ai_tool_quality_cefr'),
ref('ai_tool_course_plan_save_materials'),
])]"/>
</record>
<!-- 3. Exam generator -->
<record id="ai_agent_exam_generator" model="encoach.ai.agent">
<field name="key">exam_generator</field>
<field name="name">Exam Generator</field>
<field name="description">Generates exam questions (MCQ, short answer, cloze, speaking prompts, writing tasks) matching a structure and blueprint.</field>
<field name="model">gpt-4o</field>
<field name="fallback_model">gpt-4o-mini</field>
<field name="temperature">0.5</field>
<field name="max_tokens">6000</field>
<field name="response_format">json</field>
<field name="graph_type">plan_review_revise</field>
<field name="max_revisions">1</field>
<field name="quality_checks">quality.cefr_check</field>
<field name="sequence">30</field>
<field name="system_prompt">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.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_resources_search'),
ref('ai_tool_outcomes_fetch'),
ref('ai_tool_rubric_fetch'),
ref('ai_tool_quality_cefr'),
])]"/>
</record>
<!-- 4. Personalised exercise generator -->
<record id="ai_agent_exercise_generator" model="encoach.ai.agent">
<field name="key">exercise_generator</field>
<field name="name">Personalised Exercise Generator</field>
<field name="description">Produces targeted practice items (gap-fill, reordering, short response) using a learner's gap profile to focus on their weakest outcomes.</field>
<field name="model">gpt-4o-mini</field>
<field name="fallback_model">gpt-4o</field>
<field name="temperature">0.7</field>
<field name="max_tokens">3000</field>
<field name="response_format">json</field>
<field name="graph_type">react</field>
<field name="max_revisions">0</field>
<field name="quality_checks"></field>
<field name="sequence">40</field>
<field name="system_prompt">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.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_student_profile'),
ref('ai_tool_resources_search'),
ref('ai_tool_outcomes_fetch'),
ref('ai_tool_quality_cefr'),
])]"/>
</record>
<!-- 5. LMS tutor / study assistant -->
<record id="ai_agent_lms_tutor" model="encoach.ai.agent">
<field name="key">lms_tutor</field>
<field name="name">LMS Tutor</field>
<field name="description">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.</field>
<field name="model">gpt-4o-mini</field>
<field name="fallback_model">gpt-4o</field>
<field name="temperature">0.6</field>
<field name="max_tokens">1500</field>
<field name="response_format">text</field>
<field name="graph_type">react</field>
<field name="max_revisions">0</field>
<field name="quality_checks"></field>
<field name="sequence">50</field>
<field name="system_prompt">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.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_resources_search'),
ref('ai_tool_student_profile'),
ref('ai_tool_outcomes_fetch'),
])]"/>
</record>
<!-- 6. Writing grader -->
<record id="ai_agent_writing_grader" model="encoach.ai.agent">
<field name="key">writing_grader</field>
<field name="name">Writing Grader</field>
<field name="description">Grades a writing submission against its rubric and produces band scores, feedback, and targeted suggestions.</field>
<field name="model">gpt-4o</field>
<field name="fallback_model">gpt-4o-mini</field>
<field name="temperature">0.2</field>
<field name="max_tokens">1800</field>
<field name="response_format">json</field>
<field name="graph_type">simple</field>
<field name="max_revisions">0</field>
<field name="quality_checks"></field>
<field name="sequence">60</field>
<field name="system_prompt">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]}.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_rubric_fetch'),
ref('ai_tool_scoring_writing'),
])]"/>
</record>
<!-- 7. Speaking evaluator -->
<record id="ai_agent_speaking_grader" model="encoach.ai.agent">
<field name="key">speaking_grader</field>
<field name="name">Speaking Evaluator</field>
<field name="description">Grades a speaking transcript against its rubric and produces band scores, feedback on fluency / pronunciation, and next-step drills.</field>
<field name="model">gpt-4o</field>
<field name="fallback_model">gpt-4o-mini</field>
<field name="temperature">0.2</field>
<field name="max_tokens">1800</field>
<field name="response_format">json</field>
<field name="graph_type">simple</field>
<field name="max_revisions">0</field>
<field name="quality_checks"></field>
<field name="sequence">70</field>
<field name="system_prompt">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]}.</field>
<field name="tool_ids" eval="[(6, 0, [
ref('ai_tool_rubric_fetch'),
ref('ai_tool_scoring_speaking'),
])]"/>
</record>
<!-- Feature flag: pipelines consult this before routing through AgentRuntime.
Default "True" so the defaults-ship-working contract holds. -->
<record id="ai_default_use_langgraph" model="ir.config_parameter">
<field name="key">encoach_ai.use_langgraph_runtime</field>
<field name="value">True</field>
</record>
</odoo>

View File

@@ -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

View 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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_ai_prompt_user encoach.ai.prompt user model_encoach_ai_prompt base.group_user 1 0 0 0
6 access_ai_feedback_admin encoach.ai.feedback admin model_encoach_ai_feedback base.group_system 1 1 1 1
7 access_ai_feedback_user encoach.ai.feedback user model_encoach_ai_feedback base.group_user 1 1 1 0
8 access_ai_agent_admin encoach.ai.agent admin model_encoach_ai_agent base.group_system 1 1 1 1
9 access_ai_agent_user encoach.ai.agent user model_encoach_ai_agent base.group_user 1 0 0 0
10 access_ai_tool_admin encoach.ai.tool admin model_encoach_ai_tool base.group_system 1 1 1 1
11 access_ai_tool_user encoach.ai.tool user model_encoach_ai_tool base.group_user 1 0 0 0

View File

@@ -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

View 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)

View 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)}

View File

@@ -1 +1,2 @@
from . import ai_course
from . import course_plan

View File

@@ -0,0 +1,171 @@
"""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 logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required,
_json_response,
_error_response,
_get_json_body,
_paginate,
)
from odoo.addons.encoach_ai_course.services.course_plan_pipeline import (
CoursePlanPipeline,
)
_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'
class CoursePlanController(http.Controller):
# ------------------------------------------------------------------
# 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)
pipeline = CoursePlanPipeline(
request.env, language=_request_language(),
)
plan = pipeline.generate_plan(body)
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), 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 = []
search = (params.get('search') or '').strip()
if search:
domain.append(('name', 'ilike', search))
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), 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/<id>
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_plan(self, plan_id, **kw):
try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
return _json_response({
'data': plan.to_api_dict(include_weeks=True, include_materials=True),
})
except Exception as exc:
_logger.exception('course-plan.get failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# DELETE /api/ai/course-plan/<id>
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>', type='http',
auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_plan(self, plan_id, **kw):
try:
plan = request.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
return _error_response('Plan not found', 404)
plan.unlink()
return _json_response({'success': True})
except Exception as exc:
_logger.exception('course-plan.delete failed')
return _error_response(str(exc), 500)
# ------------------------------------------------------------------
# POST /api/ai/course-plan/<id>/weeks/<n>/materials
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def generate_week_materials(self, plan_id, week_number, **kw):
try:
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), 500)
# ------------------------------------------------------------------
# GET /api/ai/course-plan/<id>/weeks/<n>/materials
# ------------------------------------------------------------------
@http.route('/api/ai/course-plan/<int:plan_id>/weeks/<int:week_number>/materials',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_week_materials(self, plan_id, week_number, **kw):
try:
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), 500)

View File

@@ -1,2 +1,3 @@
from . import ai_generation_log
from . import ai_ielts_generation_log
from . import course_plan

View File

@@ -0,0 +1,276 @@
"""Course Plan models.
A *course plan* is the AI-generated, structured outline of a full course
(similar to the UTAS GE1 outline: objectives, per-skill learning outcomes,
grammar scope, assessment split, and a week-by-week delivery plan).
Distinct from the existing exam / exercise generation pipeline:
* ``encoach.ai.generation.log`` generates **exam questions**.
* ``encoach.course.plan`` generates **teaching content** — weeks,
reading texts, listening scripts, speaking prompts, grammar lessons, etc.
Large, loosely-structured JSON (objectives, learning outcomes grouped by
skill, grammar topics, assessment breakdown, learning resources) lives on
the header as ``Text`` columns to keep the schema boring. Per-week rows
and per-week materials each get their own table because they are
generated incrementally and users want to drill into them.
"""
import json
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
SKILL_SELECTION = [
('reading', 'Reading'),
('writing', 'Writing'),
('listening', 'Listening'),
('speaking', 'Speaking'),
('grammar', 'Grammar'),
('vocabulary', 'Vocabulary'),
('integrated', 'Integrated'),
]
MATERIAL_TYPE_SELECTION = [
('reading_text', 'Reading Text'),
('listening_script', 'Listening Script'),
('speaking_prompt', 'Speaking Prompt'),
('writing_prompt', 'Writing Prompt'),
('grammar_lesson', 'Grammar Lesson'),
('vocabulary_list', 'Vocabulary List'),
('practice', 'Practice Exercises'),
('other', 'Other'),
]
class CoursePlan(models.Model):
_name = 'encoach.course.plan'
_description = 'AI-generated Course Plan'
_order = 'create_date desc, id desc'
name = fields.Char(required=True)
course_id = fields.Many2one('op.course', ondelete='set null', string='Linked course')
cefr_level = fields.Selection([
('pre_a1', 'Pre-A1'),
('a1', 'A1'),
('a2', 'A2'),
('b1', 'B1'),
('b2', 'B2'),
('c1', 'C1'),
('c2', 'C2'),
], default='a2')
total_weeks = fields.Integer(default=12, string='Total weeks')
contact_hours_per_week = fields.Integer(default=18, string='Contact hours / week')
# The "Reading & Writing = 10 hrs/wk, Listening & Speaking = 8 hrs/wk"
# breakdown is a free-form label so AI can propose any split.
skills_division = fields.Char(
string='Skills division',
help='Free-form label describing how hours are split across skill '
'tracks, e.g. "10 hrs/wk Reading & Writing + 8 hrs/wk '
'Listening & Speaking".',
)
description = fields.Text()
objectives_json = fields.Text(
help='JSON array of high-level course objectives.',
)
outcomes_json = fields.Text(
help='JSON object keyed by skill (reading/writing/listening/speaking/'
'vocabulary/grammar). Each value is an ordered list of '
'{code, description} learning outcome rows — code is e.g. '
'"RLO1", "WLO3", "GLO2a".',
)
grammar_json = fields.Text(
help='JSON array of grammar topics in the order they should be '
'taught. Each item is {code, label, sub_items: []}.',
)
assessment_json = fields.Text(
help='JSON object describing the CA/FE split and component weights.',
)
resources_json = fields.Text(
help='JSON array of textbooks / URLs / materials referenced by the '
'AI when planning content.',
)
status = fields.Selection([
('draft', 'Draft'),
('generated', 'Generated'),
('approved', 'Approved'),
('archived', 'Archived'),
], default='draft')
brief_json = fields.Text(
help='Original brief that was sent to the AI — kept for audit and '
'so the user can re-generate if the first pass disappoints.',
)
week_ids = fields.One2many(
'encoach.course.plan.week', 'plan_id', string='Weeks',
)
material_ids = fields.One2many(
'encoach.course.plan.material', 'plan_id', string='Materials',
)
week_count = fields.Integer(compute='_compute_counts', store=False)
material_count = fields.Integer(compute='_compute_counts', store=False)
@api.depends('week_ids', 'material_ids')
def _compute_counts(self):
for rec in self:
rec.week_count = len(rec.week_ids)
rec.material_count = len(rec.material_ids)
# ------------------------------------------------------------------
# Serialisation helpers — used by the REST controller so payload
# shape stays in a single, obvious place.
# ------------------------------------------------------------------
def _loads(self, raw, default):
if not raw:
return default
try:
return json.loads(raw)
except (TypeError, ValueError):
return default
def to_api_dict(self, *, include_weeks=True, include_materials=False):
self.ensure_one()
data = {
'id': self.id,
'name': self.name,
'course_id': self.course_id.id if self.course_id else None,
'course_name': self.course_id.name if self.course_id else '',
'cefr_level': self.cefr_level or '',
'total_weeks': self.total_weeks or 0,
'contact_hours_per_week': self.contact_hours_per_week or 0,
'skills_division': self.skills_division or '',
'description': self.description or '',
'status': self.status or 'draft',
'objectives': self._loads(self.objectives_json, []),
'outcomes': self._loads(self.outcomes_json, {}),
'grammar': self._loads(self.grammar_json, []),
'assessment': self._loads(self.assessment_json, {}),
'resources': self._loads(self.resources_json, []),
'week_count': len(self.week_ids),
'material_count': len(self.material_ids),
'created_at': self.create_date.isoformat() if self.create_date else None,
}
if include_weeks:
data['weeks'] = [w.to_api_dict() for w in self.week_ids.sorted('week_number')]
if include_materials:
data['materials'] = [m.to_api_dict() for m in self.material_ids]
return data
class CoursePlanWeek(models.Model):
_name = 'encoach.course.plan.week'
_description = 'Course Plan Week'
_order = 'week_number asc, id asc'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
week_number = fields.Integer(required=True)
date_label = fields.Char(
help='Human-readable date range, e.g. "7-11 Sep. 2025".',
)
unit = fields.Char(help='Textbook unit / theme for the week.')
focus = fields.Char(help='Short focus headline for the week.')
items_json = fields.Text(
help='JSON array of per-skill rows for this week: '
'[{skill, outcome_codes: [...], remarks}]. Mirrors the '
'GE1 delivery plan table.',
)
material_ids = fields.One2many(
'encoach.course.plan.material', 'week_id', string='Materials',
)
material_count = fields.Integer(compute='_compute_material_count', store=False)
@api.depends('material_ids')
def _compute_material_count(self):
for rec in self:
rec.material_count = len(rec.material_ids)
def _loads(self, raw, default):
if not raw:
return default
try:
return json.loads(raw)
except (TypeError, ValueError):
return default
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'week_number': self.week_number or 0,
'date_label': self.date_label or '',
'unit': self.unit or '',
'focus': self.focus or '',
'items': self._loads(self.items_json, []),
'material_count': len(self.material_ids),
}
class CoursePlanMaterial(models.Model):
_name = 'encoach.course.plan.material'
_description = 'Course Plan Teaching Material'
_order = 'week_id, skill, id'
plan_id = fields.Many2one(
'encoach.course.plan', required=True, ondelete='cascade', index=True,
)
week_id = fields.Many2one(
'encoach.course.plan.week', ondelete='cascade', index=True,
)
week_number = fields.Integer(
related='week_id.week_number', store=True, string='Week #',
)
skill = fields.Selection(SKILL_SELECTION, required=True)
material_type = fields.Selection(
MATERIAL_TYPE_SELECTION, required=True, default='other',
)
title = fields.Char(required=True)
summary = fields.Text(
help='Short blurb — purpose / learning outcomes targeted / how to use.',
)
body_json = fields.Text(
help='Structured payload. Shape depends on material_type: '
'reading_text → {text, questions[]}, '
'listening_script → {script, comprehension_questions[]}, '
'grammar_lesson → {explanation, examples[], practice[]}, etc.',
)
body_text = fields.Text(
help='Plain-text rendering for easy preview / copy-paste when the '
'structured body is not needed.',
)
def _loads(self, raw, default):
if not raw:
return default
try:
return json.loads(raw)
except (TypeError, ValueError):
return default
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'plan_id': self.plan_id.id,
'week_id': self.week_id.id if self.week_id else None,
'week_number': self.week_number or 0,
'skill': self.skill or '',
'material_type': self.material_type or 'other',
'title': self.title or '',
'summary': self.summary or '',
'body': self._loads(self.body_json, {}),
'body_text': self.body_text or '',
}

View File

@@ -1,3 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_ai_generation_log_user,encoach.ai.generation.log.user,model_encoach_ai_generation_log,base.group_user,1,1,1,1
access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user,model_encoach_ai_ielts_generation_log,base.group_user,1,1,1,1
access_encoach_course_plan_user,encoach.course.plan.user,model_encoach_course_plan,base.group_user,1,1,1,1
access_encoach_course_plan_week_user,encoach.course.plan.week.user,model_encoach_course_plan_week,base.group_user,1,1,1,1
access_encoach_course_plan_material_user,encoach.course.plan.material.user,model_encoach_course_plan_material,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_ai_generation_log_user encoach.ai.generation.log.user model_encoach_ai_generation_log base.group_user 1 1 1 1
3 access_encoach_ai_ielts_generation_log_user encoach.ai.ielts.generation.log.user model_encoach_ai_ielts_generation_log base.group_user 1 1 1 1
4 access_encoach_course_plan_user encoach.course.plan.user model_encoach_course_plan base.group_user 1 1 1 1
5 access_encoach_course_plan_week_user encoach.course.plan.week.user model_encoach_course_plan_week base.group_user 1 1 1 1
6 access_encoach_course_plan_material_user encoach.course.plan.material.user model_encoach_course_plan_material base.group_user 1 1 1 1

View File

@@ -1,2 +1,3 @@
from .english_pipeline import EnglishPipeline
from .ielts_pipeline import IeltsPipeline
from .course_plan_pipeline import CoursePlanPipeline

View File

@@ -0,0 +1,496 @@
"""Course plan generation pipeline.
Two public entry points:
* :py:meth:`generate_plan` — given a short brief (course title, CEFR level,
duration, skill coverage, grammar focus, resources), produce a full
curriculum outline and persist it as an
:py:class:`encoach.course.plan` record, with one
:py:class:`encoach.course.plan.week` row per planned week.
* :py:meth:`generate_week_materials` — given an existing plan and a
week number, produce the actual teaching content for that week
(reading text, listening script, speaking prompts, grammar mini-lesson
+ practice, writing prompt, vocabulary list) and persist each as an
:py:class:`encoach.course.plan.material` row.
We deliberately ask the LLM to return strict JSON and then normalise it
server-side — the frontend gets a stable shape no matter how loose the
model's output is. Any parse failure is swallowed and reported back
through the standard error channel so the caller can retry without the
server crashing.
"""
import json
import logging
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
except ImportError:
OpenAIService = None
# AgentRuntime is the LangGraph-backed engine. When the feature flag
# ``encoach_ai.use_langgraph_runtime`` is true (default) and an agent with
# the matching key is configured, the pipeline routes through the agent
# instead of calling OpenAIService directly. This keeps the existing
# fall-back path so the pipeline still works if the agent layer is broken
# or being upgraded.
try:
from odoo.addons.encoach_ai.services.agent_runtime import AgentRuntime
except ImportError: # pragma: no cover - optional dep
AgentRuntime = None
_logger = logging.getLogger(__name__)
# JSON schema we coax the LLM into following. Keeping this as a prompt
# string (rather than an OpenAI function call) makes it portable if the
# underlying `chat_json` implementation ever changes providers.
_PLAN_JSON_HINT = """
Return JSON with exactly this shape:
{
"description": "<2-4 sentence course description incl. CEFR>",
"objectives": ["<overall course objective>", ...],
"outcomes": {
"reading": [{"code": "RLO1", "description": "..."}, ...],
"writing": [{"code": "WLO1", "description": "..."}, ...],
"listening": [{"code": "LLO1", "description": "..."}, ...],
"speaking": [{"code": "SLO1", "description": "..."}, ...],
"vocabulary": [{"code": "VLO1", "description": "..."}, ...],
"grammar": [{"code": "GLO1", "description": "..."}, ...]
},
"grammar": [
{"code": "GT1", "label": "Present tense",
"sub_items": ["present simple", "present continuous"]},
...
],
"assessment": {
"continuous_assessment": {"total_weight": 50, "components":
[{"name":"MTE","weight":30}, {"name":"Oral Presentation","weight":10}, ...]},
"final_exam": {"total_weight": 50}
},
"resources": [
{"type": "textbook", "citation": "..."},
{"type": "stm", "citation": "..."}
],
"weeks": [
{
"week_number": 1,
"date_label": "7-11 Sep. 2025",
"unit": "One",
"focus": "Personal introductions, simple present",
"items": [
{"skill": "reading", "outcome_codes": ["RLO1","RLO2"], "remarks": "..."},
{"skill": "writing", "outcome_codes": ["WLO1","WLO2"], "remarks": "..."},
{"skill": "listening", "outcome_codes": ["LLO1"], "remarks": ""},
{"skill": "speaking", "outcome_codes": ["SLO1","SLO2"], "remarks": ""},
{"skill": "grammar", "outcome_codes": ["GLO1"], "remarks": ""}
]
},
...
]
}
Use the exact outcome codes across `outcomes` and `weeks[*].items[*].outcome_codes`.
"""
_WEEK_JSON_HINT = """
Return JSON with exactly this shape:
{
"materials": [
{
"skill": "reading",
"material_type": "reading_text",
"title": "...",
"summary": "1-2 sentence teacher note",
"body": {
"text": "<reading passage ~350-450 words>",
"questions": [
{"q": "...", "type": "multiple_choice",
"options": ["A","B","C","D"], "answer": "A"}
]
}
},
{
"skill": "listening",
"material_type": "listening_script",
"title": "...",
"summary": "...",
"body": {
"script": "<3-4 minute dialogue or monologue>",
"comprehension_questions": [
{"q": "...", "answer": "..."}
]
}
},
{
"skill": "speaking",
"material_type": "speaking_prompt",
"title": "...",
"summary": "...",
"body": {
"prompts": ["...", "..."],
"useful_language": ["..."]
}
},
{
"skill": "writing",
"material_type": "writing_prompt",
"title": "...",
"summary": "...",
"body": {
"prompt": "...",
"word_count": 150,
"model_paragraph": "..."
}
},
{
"skill": "grammar",
"material_type": "grammar_lesson",
"title": "...",
"summary": "...",
"body": {
"explanation": "...",
"examples": ["...","..."],
"practice": [
{"q":"...", "answer":"..."}
]
}
},
{
"skill": "vocabulary",
"material_type": "vocabulary_list",
"title": "...",
"summary": "...",
"body": {
"words": [
{"term":"...", "pos":"n.", "definition":"...", "example":"..."}
]
}
}
]
}
Only include skills present in the week's items list.
"""
class CoursePlanPipeline:
"""Wrap the LLM call, normalise the JSON, persist the result."""
def __init__(self, env, *, language="en"):
self.env = env
self.language = language
if OpenAIService is None:
raise RuntimeError(
"OpenAIService is not available — encoach_ai is not installed."
)
self.ai = OpenAIService(env, language=language)
# Decide once per instance whether to route through the LangGraph
# AgentRuntime or fall back to the direct chat_json path.
self._use_agent = self._resolve_agent_flag(env)
@staticmethod
def _resolve_agent_flag(env):
if AgentRuntime is None:
return False
try:
raw = env["ir.config_parameter"].sudo().get_param(
"encoach_ai.use_langgraph_runtime", "True",
)
except Exception:
return False
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def _agent(self, key):
"""Lazily build an AgentRuntime for ``key`` if the flag allows it."""
if not self._use_agent or AgentRuntime is None:
return None
try:
return AgentRuntime.from_key(self.env, key, language=self.language)
except Exception:
_logger.exception("AgentRuntime.from_key(%s) failed", key)
return None
# ------------------------------------------------------------------
# Plan-level generation
# ------------------------------------------------------------------
def generate_plan(self, brief):
"""Generate the full course plan header + week rows from a brief.
:param brief: ``dict`` with optional keys:
title, cefr_level, total_weeks, contact_hours_per_week,
skills_division, grammar_focus (list), resources (list),
learner_profile (string), notes (string), course_id (int),
language (string ISO-639-1).
:returns: ``encoach.course.plan`` record.
"""
title = (brief.get('title') or '').strip() or 'Untitled course'
cefr = (brief.get('cefr_level') or 'a2').lower()
total_weeks = int(brief.get('total_weeks') or 12)
contact_hours = int(brief.get('contact_hours_per_week') or 18)
skills_division = (brief.get('skills_division') or '').strip()
grammar_focus = brief.get('grammar_focus') or []
resources = brief.get('resources') or []
learner_profile = (brief.get('learner_profile') or '').strip()
notes = (brief.get('notes') or '').strip()
system_msg = (
"You are an expert English language curriculum designer. "
"You produce structured course outlines suitable for a "
"general foundation programme. You MUST return valid JSON "
"that matches the schema in the user prompt exactly. Never "
"wrap the JSON in prose."
)
user_msg = (
f"Design a {total_weeks}-week course titled \"{title}\" at "
f"CEFR {cefr.upper()} with approximately {contact_hours} "
f"contact hours per week.\n"
f"Skills division: {skills_division or 'auto'}.\n"
f"Grammar focus: {', '.join(grammar_focus) or 'auto'}.\n"
f"Resources to reference: "
f"{'; '.join(resources) if resources else 'none'}.\n"
f"Learner profile: {learner_profile or 'mixed L1 adult learners'}.\n"
f"Additional notes: {notes or 'none'}.\n\n"
+ _PLAN_JSON_HINT
)
# Prefer the LangGraph agent if one is configured; fall back to the
# direct OpenAI call so the feature still works if the agent table
# is empty or the runtime fails to compile.
content = self._invoke_agent_or_chat(
agent_key="course_planner",
system_msg=system_msg,
user_msg=user_msg,
variables={
"title": title,
"cefr_level": cefr,
"total_weeks": total_weeks,
},
temperature=0.4,
max_tokens=4096,
action="course_plan.generate",
)
if content is None or 'error' in content:
raise RuntimeError(
(content or {}).get('error', 'AI generation failed.')
)
plan_vals = {
'name': title,
'cefr_level': cefr if cefr in {
'pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2'
} else 'a2',
'total_weeks': total_weeks,
'contact_hours_per_week': contact_hours,
'skills_division': skills_division,
'description': (content.get('description') or '').strip(),
'objectives_json': json.dumps(content.get('objectives') or [], ensure_ascii=False),
'outcomes_json': json.dumps(content.get('outcomes') or {}, ensure_ascii=False),
'grammar_json': json.dumps(content.get('grammar') or [], ensure_ascii=False),
'assessment_json': json.dumps(content.get('assessment') or {}, ensure_ascii=False),
'resources_json': json.dumps(content.get('resources') or [], ensure_ascii=False),
'brief_json': json.dumps(brief, ensure_ascii=False),
'status': 'generated',
}
if brief.get('course_id'):
try:
plan_vals['course_id'] = int(brief['course_id'])
except (TypeError, ValueError):
pass
plan = self.env['encoach.course.plan'].sudo().create(plan_vals)
# Create week rows.
Week = self.env['encoach.course.plan.week'].sudo()
for w in content.get('weeks') or []:
try:
Week.create({
'plan_id': plan.id,
'week_number': int(w.get('week_number') or 0),
'date_label': (w.get('date_label') or '').strip(),
'unit': (w.get('unit') or '').strip(),
'focus': (w.get('focus') or '').strip(),
'items_json': json.dumps(w.get('items') or [], ensure_ascii=False),
})
except Exception as exc: # pragma: no cover - defensive
_logger.warning("Skipping bad week row: %s", exc)
return plan
# ------------------------------------------------------------------
# Week-level material generation
# ------------------------------------------------------------------
def generate_week_materials(self, plan_id, week_number):
"""Generate teaching materials for one week and persist them.
Any existing materials for the same plan_id + week_number are
replaced — callers that want to keep old versions should copy
them before re-running.
"""
plan = self.env['encoach.course.plan'].sudo().browse(int(plan_id))
if not plan.exists():
raise ValueError('Plan not found')
week = plan.week_ids.filtered(lambda w: w.week_number == int(week_number))
if not week:
raise ValueError(f'Week {week_number} not found on plan {plan_id}')
week = week[0]
outcomes = plan._loads(plan.outcomes_json, {})
items = week._loads(week.items_json, [])
system_msg = (
"You are an expert English language teacher creating ready-"
"to-use classroom materials. Your output MUST be valid JSON "
"matching the schema in the user prompt. Keep reading texts "
"close to the target word count for the CEFR level. Keep "
"listening scripts natural and conversational. All tasks "
"must target the outcome codes supplied."
)
user_msg = (
f"Course: {plan.name}\n"
f"CEFR: {(plan.cefr_level or '').upper()}\n"
f"Week {week.week_number}{week.date_label or ''}\n"
f"Unit: {week.unit or ''}\n"
f"Focus: {week.focus or ''}\n\n"
f"Week items:\n{json.dumps(items, indent=2, ensure_ascii=False)}\n\n"
f"Full outcome catalogue (for looking up codes):\n"
f"{json.dumps(outcomes, indent=2, ensure_ascii=False)}\n\n"
+ _WEEK_JSON_HINT
)
content = self._invoke_agent_or_chat(
agent_key="course_week_materials",
system_msg=system_msg,
user_msg=user_msg,
variables={
"course": plan.name,
"cefr_level": (plan.cefr_level or "").lower(),
"week_number": week.week_number,
},
temperature=0.6,
max_tokens=6000,
action="course_plan.generate_week",
)
if content is None or 'error' in content:
raise RuntimeError(
(content or {}).get('error', 'AI generation failed.')
)
# Wipe any previous materials for this week so re-generating is
# idempotent and we never accumulate duplicates.
existing = self.env['encoach.course.plan.material'].sudo().search([
('plan_id', '=', plan.id), ('week_id', '=', week.id),
])
if existing:
existing.unlink()
Material = self.env['encoach.course.plan.material'].sudo()
created = []
for m in content.get('materials') or []:
try:
rec = Material.create({
'plan_id': plan.id,
'week_id': week.id,
'skill': (m.get('skill') or 'integrated').strip().lower(),
'material_type': (m.get('material_type') or 'other').strip(),
'title': (m.get('title') or '').strip() or 'Untitled',
'summary': (m.get('summary') or '').strip(),
'body_json': json.dumps(m.get('body') or {}, ensure_ascii=False),
'body_text': self._flatten_body(m.get('body') or {}),
})
created.append(rec)
except Exception as exc: # pragma: no cover - defensive
_logger.warning("Skipping bad material row: %s", exc)
return created
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _chat_json(self, messages, **kwargs):
"""Best-effort wrapper around ``ai.chat_json``.
The underlying service may raise (network, invalid key, etc.),
or return a dict with an ``error`` field when content moderation
rejects the request. We normalise both to a dict so callers can
just check ``'error' in result``.
"""
try:
return self.ai.chat_json(messages, **kwargs)
except Exception as exc:
_logger.exception("Course plan AI call failed")
return {'error': str(exc)}
def _invoke_agent_or_chat(self, *, agent_key, system_msg, user_msg,
variables, temperature, max_tokens, action):
"""Route through AgentRuntime when available; fall back to chat_json.
Both branches return the same shape — a dict the caller can
``json.loads``-style consume — so the rest of the pipeline doesn't
change. We pass ``user_msg`` as the payload because the agent's own
system prompt is normally the one used; only when the agent is
missing do we pass the inline ``system_msg``.
"""
runtime = self._agent(agent_key)
if runtime is not None:
# The pipeline owns the JSON schema for backward-compat, so we
# forward the schema-bearing user message into the agent. The
# agent's stored system prompt covers the role/rules; we add
# the schema as ``extra_system`` so it's heeded but auditable.
final = runtime.invoke(
variables=variables,
payload=user_msg,
extra_system=system_msg,
)
if final.get("error"):
_logger.warning(
"agent %s failed (%s); falling back to direct chat_json",
agent_key, final.get("error"),
)
else:
output = final.get("output")
if isinstance(output, dict):
return output
# Text output — try parsing once, otherwise fall back.
try:
return json.loads(final.get("output_raw") or "{}")
except Exception:
pass
# Fallback path: plain OpenAI call (legacy).
return self._chat_json(
[
{"role": "system", "content": system_msg},
{"role": "user", "content": user_msg},
],
temperature=temperature,
max_tokens=max_tokens,
action=action,
)
@staticmethod
def _flatten_body(body):
"""Produce a plain-text dump of a material body for quick preview.
Not every shape is predictable (the model sometimes inserts
unusual keys), so we do a shallow walk and join string values
with newlines.
"""
if not isinstance(body, dict):
return ''
lines = []
for key, value in body.items():
if isinstance(value, str):
lines.append(f"{key}: {value}")
elif isinstance(value, list):
lines.append(f"{key}:")
for item in value:
if isinstance(item, str):
lines.append(f" - {item}")
elif isinstance(item, dict):
parts = []
for k, v in item.items():
if isinstance(v, (str, int, float)):
parts.append(f"{k}={v}")
if parts:
lines.append(" - " + ", ".join(parts))
elif isinstance(value, dict):
lines.append(f"{key}: " + json.dumps(value, ensure_ascii=False))
return "\n".join(lines)