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

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