feat(ai): LangGraph as core runtime + AI Agents/Tools console + full-demo seed
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Failing after 1m20s
CI / Backend — Odoo HttpCase (pull_request) Failing after 1s

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 1223074bde
commit e2aa8031ff
56 changed files with 9846 additions and 40 deletions

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)