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:
@@ -1 +1,2 @@
|
||||
from . import ai_course
|
||||
from . import course_plan
|
||||
|
||||
@@ -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)
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import ai_generation_log
|
||||
from . import ai_ielts_generation_log
|
||||
from . import course_plan
|
||||
|
||||
276
backend/custom_addons/encoach_ai_course/models/course_plan.py
Normal file
276
backend/custom_addons/encoach_ai_course/models/course_plan.py
Normal 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 '',
|
||||
}
|
||||
@@ -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,2 +1,3 @@
|
||||
from .english_pipeline import EnglishPipeline
|
||||
from .ielts_pipeline import IeltsPipeline
|
||||
from .course_plan_pipeline import CoursePlanPipeline
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user