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
368 lines
14 KiB
Python
368 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
e2e_full_scenario.py — E2E API test driver for every user type.
|
|
|
|
Hits the live Odoo API at http://localhost:8069 with each demo account and
|
|
exercises the key flows for that role:
|
|
|
|
admin → AI agents config, list agents/tools, run grader, branding
|
|
teacher → list courses, course plans, create approval-bound exam
|
|
approver → list pending approval requests, peek into a request
|
|
student → list assigned exams, fetch exam, list course materials
|
|
corporate → corporate dashboard / users in entity
|
|
mastercorporate → master (multi-entity) overview
|
|
agent → agent surface area
|
|
developer → developer / superuser-ish API access
|
|
|
|
Exit code 0 when every targeted endpoint either returns 2xx or returns a
|
|
known graceful 4xx; non-zero if any unexpected failure happens.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
BASE = os.environ.get("BASE", "http://localhost:8069")
|
|
TIMEOUT = 30
|
|
|
|
PASS, FAIL, WARN = 0, 0, 0
|
|
RESULTS = [] # list of (role, label, ok, detail)
|
|
|
|
|
|
def colour(s, code):
|
|
return f"\033[{code}m{s}\033[0m"
|
|
|
|
GREEN = lambda s: colour(s, 32)
|
|
RED = lambda s: colour(s, 31)
|
|
YELL = lambda s: colour(s, 33)
|
|
DIM = lambda s: colour(s, 90)
|
|
BOLD = lambda s: colour(s, "1;36")
|
|
|
|
|
|
def http(method, path, *, token=None, body=None):
|
|
url = f"{BASE}{path}"
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
req = Request(url, data=data, method=method)
|
|
req.add_header("Accept", "application/json")
|
|
if data is not None:
|
|
req.add_header("Content-Type", "application/json")
|
|
if token:
|
|
req.add_header("Authorization", f"Bearer {token}")
|
|
try:
|
|
with urlopen(req, timeout=TIMEOUT) as resp:
|
|
raw = resp.read().decode("utf-8", "replace")
|
|
return resp.status, _try_json(raw), raw
|
|
except HTTPError as e:
|
|
raw = e.read().decode("utf-8", "replace")
|
|
return e.code, _try_json(raw), raw
|
|
except URLError as e:
|
|
return 0, None, str(e)
|
|
|
|
|
|
def _try_json(raw):
|
|
try:
|
|
return json.loads(raw)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def login(email, password):
|
|
code, payload, _ = http("POST", "/api/login", body={"login": email, "password": password})
|
|
if code == 200 and isinstance(payload, dict) and payload.get("access_token"):
|
|
return payload["access_token"], payload.get("user", {})
|
|
return None, None
|
|
|
|
|
|
def record(role, label, ok, detail="", soft=False):
|
|
global PASS, FAIL, WARN
|
|
RESULTS.append((role, label, ok, detail, soft))
|
|
if ok:
|
|
PASS += 1
|
|
print(f" {GREEN('PASS')} {label} {DIM(detail)}")
|
|
else:
|
|
if soft:
|
|
WARN += 1
|
|
print(f" {YELL('SKIP')} {label} {DIM(detail)}")
|
|
else:
|
|
FAIL += 1
|
|
print(f" {RED('FAIL')} {label} {RED(detail)}")
|
|
|
|
|
|
def check(role, label, code, payload, *, ok_codes=(200,), allow_codes=(), key_required=None):
|
|
"""Generic assertion: 2xx is pass, codes in allow_codes are soft-skip."""
|
|
ok = code in ok_codes
|
|
if ok and key_required:
|
|
if isinstance(payload, dict) and key_required in payload:
|
|
ok = True
|
|
else:
|
|
ok = False
|
|
detail = f"HTTP {code}"
|
|
if not ok and isinstance(payload, dict):
|
|
msg = payload.get("error") or payload.get("message")
|
|
if msg:
|
|
detail += f" — {str(msg)[:80]}"
|
|
soft = code in allow_codes
|
|
record(role, label, ok, detail, soft=soft)
|
|
return ok
|
|
|
|
|
|
def banner(text):
|
|
print("\n" + BOLD("━" * 72))
|
|
print(BOLD(f" {text}"))
|
|
print(BOLD("━" * 72))
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
# Test definitions per role
|
|
# ─────────────────────────────────────────────────────────────────────────
|
|
|
|
ACCOUNTS = [
|
|
("admin", "admin@encoach.test", "admin123"),
|
|
("teacher", "khalid@encoach.test", "teacher123"),
|
|
("approver", "approver@encoach.test", "approver123"),
|
|
("student", "sarah@encoach.test", "student123"),
|
|
("corporate", "corporate@encoach.test", "corporate123"),
|
|
("mastercorporate", "master@encoach.test", "master123"),
|
|
("agent", "agent@encoach.test", "agent123"),
|
|
("developer", "dev@encoach.test", "dev123"),
|
|
]
|
|
|
|
|
|
def whoami(role, token):
|
|
code, _, _ = http("GET", "/api/user", token=token)
|
|
check(role, "GET /api/user (current profile)", code, None, ok_codes=(200,))
|
|
|
|
|
|
def test_admin(token):
|
|
role = "admin"
|
|
whoami(role, token)
|
|
|
|
# AI agents config (the new LangGraph surface)
|
|
code, payload, _ = http("GET", "/api/ai/agents", token=token)
|
|
check(role, "GET /api/ai/agents (LangGraph agents list)", code, payload, key_required="items")
|
|
agent_id = None
|
|
if isinstance(payload, dict):
|
|
items = payload.get("items") or []
|
|
agent_id = next((a["id"] for a in items if a.get("key") == "writing_grader"), None)
|
|
|
|
code, payload, _ = http("GET", "/api/ai/agents/tools", token=token)
|
|
check(role, "GET /api/ai/agents/tools (tool registry)", code, payload, key_required="items")
|
|
|
|
if agent_id:
|
|
code, payload, _ = http("GET", f"/api/ai/agents/{agent_id}", token=token)
|
|
check(role, f"GET /api/ai/agents/{agent_id} (writing_grader detail)", code, payload, key_required="key")
|
|
|
|
body = {
|
|
"variables": {"language": "en"},
|
|
"payload": {
|
|
"rubric": "Task achievement (0-9), coherence (0-9), lexical (0-9), grammar (0-9).",
|
|
"task": "Describe a place you visited recently. Min 80 words.",
|
|
"response": "Last weekend I visited Sharjah Aquarium. The building is modern and clean. There were many fish in big tanks. I went there with my family and we taked photos. The price was good.",
|
|
},
|
|
}
|
|
t0 = time.time()
|
|
code, payload, _ = http("POST", f"/api/ai/agents/{agent_id}/test", token=token, body=body)
|
|
dt = int((time.time() - t0) * 1000)
|
|
ok = (code == 200 and isinstance(payload, dict) and not payload.get("error")
|
|
and isinstance(payload.get("output"), dict))
|
|
record(role, "POST /api/ai/agents/{id}/test (writing_grader live LangGraph)",
|
|
ok, detail=f"HTTP {code} in {dt}ms band={payload.get('output',{}).get('overall_band') if isinstance(payload, dict) else '?'}")
|
|
|
|
# Branding admin (per-entity)
|
|
code, _, _ = http("GET", "/api/entity/1/branding", token=token)
|
|
check(role, "GET /api/entity/1/branding", code, None, ok_codes=(200, 404), allow_codes=(404,))
|
|
|
|
# AI prompts library
|
|
code, _, _ = http("GET", "/api/ai/prompts", token=token)
|
|
check(role, "GET /api/ai/prompts", code, None, ok_codes=(200,))
|
|
|
|
# Approval workflows
|
|
code, payload, _ = http("GET", "/api/approval-workflows", token=token)
|
|
check(role, "GET /api/approval-workflows", code, payload, ok_codes=(200,))
|
|
|
|
# Approval users (the picker that lists potential approvers)
|
|
code, _, _ = http("GET", "/api/approval-users", token=token)
|
|
check(role, "GET /api/approval-users", code, None, ok_codes=(200,))
|
|
|
|
# Reports
|
|
code, _, _ = http("GET", "/api/reports/student-performance", token=token)
|
|
check(role, "GET /api/reports/student-performance", code, None, ok_codes=(200,))
|
|
|
|
# Users
|
|
code, _, _ = http("GET", "/api/users/list", token=token)
|
|
check(role, "GET /api/users/list", code, None, ok_codes=(200,))
|
|
|
|
|
|
def test_teacher(token):
|
|
role = "teacher"
|
|
whoami(role, token)
|
|
|
|
# Course plans (correct path: /api/ai/course-plan)
|
|
code, payload, _ = http("GET", "/api/ai/course-plan", token=token)
|
|
plan_id = None
|
|
if check(role, "GET /api/ai/course-plan (list)", code, payload, ok_codes=(200,)):
|
|
items = (isinstance(payload, dict) and (payload.get("items") or payload.get("plans"))) or []
|
|
plan_id = items[0]["id"] if items else None
|
|
|
|
if plan_id:
|
|
code, _, _ = http("GET", f"/api/ai/course-plan/{plan_id}", token=token)
|
|
check(role, f"GET /api/ai/course-plan/{plan_id} (full plan with weeks)",
|
|
code, None, ok_codes=(200,))
|
|
code, _, _ = http("GET",
|
|
f"/api/ai/course-plan/{plan_id}/weeks/1/materials",
|
|
token=token)
|
|
check(role, f"GET /api/ai/course-plan/{plan_id}/weeks/1/materials",
|
|
code, None, ok_codes=(200,))
|
|
|
|
# Courses the teacher can see
|
|
code, _, _ = http("GET", "/api/courses", token=token)
|
|
check(role, "GET /api/courses", code, None, ok_codes=(200,))
|
|
|
|
# Exam structures + schedules
|
|
code, _, _ = http("GET", "/api/exam-structures", token=token)
|
|
check(role, "GET /api/exam-structures", code, None, ok_codes=(200,))
|
|
code, _, _ = http("GET", "/api/exam-schedules", token=token)
|
|
check(role, "GET /api/exam-schedules", code, None, ok_codes=(200,))
|
|
|
|
# Approval requests this teacher raised (requester) and pending for them
|
|
code, _, _ = http("GET", "/api/approval-requests", token=token)
|
|
check(role, "GET /api/approval-requests", code, None, ok_codes=(200,))
|
|
|
|
|
|
def test_approver(token):
|
|
role = "approver"
|
|
whoami(role, token)
|
|
|
|
# Pending approvals (filter handled server-side via current user)
|
|
code, payload, _ = http("GET", "/api/approval-requests", token=token)
|
|
check(role, "GET /api/approval-requests", code, payload, ok_codes=(200,))
|
|
|
|
# Exam-review queue (used by the legacy approver page)
|
|
code, _, _ = http("GET", "/api/exam/review/queue", token=token)
|
|
check(role, "GET /api/exam/review/queue", code, None, ok_codes=(200,))
|
|
|
|
|
|
def test_student(token):
|
|
role = "student"
|
|
whoami(role, token)
|
|
|
|
# Assigned exams (correct path)
|
|
code, _, _ = http("GET", "/api/student/my-exams", token=token)
|
|
check(role, "GET /api/student/my-exams", code, None, ok_codes=(200,))
|
|
|
|
# Enrolled courses
|
|
code, _, _ = http("GET", "/api/student/my-courses", token=token)
|
|
check(role, "GET /api/student/my-courses", code, None, ok_codes=(200,))
|
|
|
|
# LMS tutor (lms_tutor agent under the hood)
|
|
code, _, _ = http("POST", "/api/coach/chat", token=token,
|
|
body={"message": "Give me one B1 example using present continuous."})
|
|
check(role, "POST /api/coach/chat (LMS tutor agent)", code, None, ok_codes=(200,))
|
|
|
|
# Quick tip + writing help calls (also AI-coach surface)
|
|
code, _, _ = http("GET", "/api/coach/tip", token=token)
|
|
check(role, "GET /api/coach/tip", code, None, ok_codes=(200,))
|
|
|
|
|
|
def test_corporate(token):
|
|
role = "corporate"
|
|
whoami(role, token)
|
|
|
|
# Reports — corporate stats
|
|
code, _, _ = http("GET", "/api/reports/stats-corporate", token=token)
|
|
check(role, "GET /api/reports/stats-corporate", code, None, ok_codes=(200,))
|
|
|
|
# User listing (entity-scoped)
|
|
code, _, _ = http("GET", "/api/users/list", token=token)
|
|
check(role, "GET /api/users/list", code, None, ok_codes=(200, 403), allow_codes=(403,))
|
|
|
|
|
|
def test_mastercorporate(token):
|
|
role = "mastercorporate"
|
|
whoami(role, token)
|
|
|
|
code, _, _ = http("GET", "/api/reports/stats-corporate", token=token)
|
|
check(role, "GET /api/reports/stats-corporate (multi-entity)", code, None, ok_codes=(200,))
|
|
|
|
code, _, _ = http("GET", "/api/users/list", token=token)
|
|
check(role, "GET /api/users/list", code, None, ok_codes=(200, 403), allow_codes=(403,))
|
|
|
|
|
|
def test_agent(token):
|
|
role = "agent"
|
|
whoami(role, token)
|
|
code, _, _ = http("GET", "/api/courses", token=token)
|
|
check(role, "GET /api/courses", code, None, ok_codes=(200, 403), allow_codes=(403,))
|
|
|
|
|
|
def test_developer(token):
|
|
role = "developer"
|
|
whoami(role, token)
|
|
code, _, _ = http("GET", "/api/ai/agents", token=token)
|
|
check(role, "GET /api/ai/agents", code, None, ok_codes=(200,))
|
|
code, _, _ = http("GET", "/api/metrics", token=token)
|
|
check(role, "GET /api/metrics", code, None, ok_codes=(200,))
|
|
|
|
|
|
HANDLERS = {
|
|
"admin": test_admin,
|
|
"teacher": test_teacher,
|
|
"approver": test_approver,
|
|
"student": test_student,
|
|
"corporate": test_corporate,
|
|
"mastercorporate": test_mastercorporate,
|
|
"agent": test_agent,
|
|
"developer": test_developer,
|
|
}
|
|
|
|
|
|
def main():
|
|
print(BOLD("\nEnCoach — End-to-end role smoke test"))
|
|
print(DIM(f"Target: {BASE}"))
|
|
|
|
for role, login_email, password in ACCOUNTS:
|
|
banner(f"{role.upper()} ({login_email})")
|
|
token, user = login(login_email, password)
|
|
if not token:
|
|
record(role, "POST /api/login", False, f"login failed for {login_email}")
|
|
continue
|
|
record(role, "POST /api/login", True,
|
|
f"user_id={user.get('id')} type={user.get('user_type')}")
|
|
try:
|
|
HANDLERS[role](token)
|
|
except Exception as e:
|
|
record(role, f"{role}-handler raised", False, str(e)[:100])
|
|
|
|
print("\n" + BOLD("━" * 72))
|
|
print(BOLD(f" Summary: {GREEN(str(PASS) + ' PASS')} "
|
|
f"{RED(str(FAIL) + ' FAIL')} "
|
|
f"{YELL(str(WARN) + ' SKIP (endpoint absent)')}"))
|
|
print(BOLD("━" * 72))
|
|
|
|
# Compact per-role rollup
|
|
by_role = {}
|
|
for role, label, ok, detail, soft in RESULTS:
|
|
agg = by_role.setdefault(role, {"pass": 0, "fail": 0, "skip": 0})
|
|
if ok:
|
|
agg["pass"] += 1
|
|
elif soft:
|
|
agg["skip"] += 1
|
|
else:
|
|
agg["fail"] += 1
|
|
print()
|
|
for role, agg in by_role.items():
|
|
line = (f" {role:<18} "
|
|
f"{GREEN(str(agg['pass']) + ' pass')} "
|
|
f"{RED(str(agg['fail']) + ' fail')} "
|
|
f"{YELL(str(agg['skip']) + ' skip')}")
|
|
print(line)
|
|
print()
|
|
|
|
sys.exit(0 if FAIL == 0 else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|