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
|
||||
|
||||
171
custom_addons/encoach_ai_course/controllers/course_plan.py
Normal file
171
custom_addons/encoach_ai_course/controllers/course_plan.py
Normal 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)
|
||||
Reference in New Issue
Block a user