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,2 +1,3 @@
|
||||
from . import ai_generation_log
|
||||
from . import ai_ielts_generation_log
|
||||
from . import course_plan
|
||||
|
||||
276
custom_addons/encoach_ai_course/models/course_plan.py
Normal file
276
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 '',
|
||||
}
|
||||
Reference in New Issue
Block a user