Files
encoach_backend_new_v2/e2e_full_scenario.py
Yamen Ahmad e2aa8031ff
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Failing after 1m20s
CI / Backend — Odoo HttpCase (pull_request) Failing after 1s
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
2026-04-25 03:14:22 +04:00

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()