Files
full_encoach_platform/smoke_course_plan_rag.py
Yamen Ahmad 3b62075d7e feat(platform): course-plan student visibility, multi-voice TTS, OCR sources, branches
Backend (encoach_ai_course):
- workbook_attempt model + scoring + REST endpoints for student attempts
- dialogue_parser splits scripts by speaker, classifies gender, strips labels
- media_service: multi-voice TTS via Polly/ElevenLabs, ffmpeg concatenation,
  manual media upload endpoint (audio/image/video) with size validation
- source_indexer: OCR fallback (pytesseract + pdf2image) for scanned PDFs,
  page-streaming to stay under memory limit
- exercise_extractor + rag_context for RAG-grounded interactive workbooks
- course_plan_pipeline: v2 generator that grounds week material on indexed
  sources and persists grounded_on_json metadata
- security: access rules for new models

Backend (encoach_lms_api):
- branches model + controller (entity-scoped LMS branches)
- classroom_ext + course_ext (assignment + section workflow)
- classrooms controller: students/teachers/assign-course endpoints

Frontend:
- StudentDashboard: surface assigned AI course plans alongside enrollments;
  enrolled-courses stat now counts plans+enrollments
- InteractiveWorkbook + PlanReader components
- AdminCoursePlanDetail: media drawer with upload buttons (audio/image/video),
  hidden file inputs, upload mutation
- AdminBranches page + sidebar entry
- coursePlan/lms/classrooms services + types updated for new endpoints
- i18n: studentDash.myCoursePlans/noCoursePlans (en + ar)

Infra & docs:
- odoo.conf: bump memory limits to 4G/5G for OCR + sentence-transformers
- .gitignore: ignore *.tsbuildinfo
- docs/ASSIGNMENT_WORKFLOW.{md,pdf}
- smoke_*.py end-to-end tests for assignment workflow, entity isolation,
  course-plan RAG pipeline

Made-with: Cursor
2026-04-28 00:17:51 +04:00

546 lines
19 KiB
Python

"""End-to-end smoke test for the **professional interactive course plans** flow.
Run with the live backend running on http://127.0.0.1:8069. We exercise
every layer added by the "Professional Interactive Course Plans" change:
1. Login as the entity-A admin.
2. Generate a fresh course plan (LLM call; we accept the deterministic
skeleton fallback when no key is configured).
3. Upload the Headway elementary workbook PDF as a RAG source and wait
for the SourceIndexer to flip it to ``indexed``.
4. Run ``extract-workbooks`` — the ExerciseExtractor should produce
``interactive_workbook`` materials whose ``extracted_from`` field
points back at the uploaded PDF.
5. Run ``materials/v2`` for week 1 — the v2 generator must inject RAG
passages and persist a non-empty ``grounded_on`` array on every
created material.
6. As an admin (entity-scope fallback in ``_resolve_attempt_target``),
submit a deliberately-correct attempt against the first
interactive workbook material and assert the server-side scoring
persists the answers + percent.
7. Reload the attempt via ``GET .../attempts/me`` and verify it
survived the round-trip.
Expected provider failures:
- If ``OPENAI_API_KEY`` / embedding key isn't configured the indexer
flips to ``failed`` — the script reports SKIP for the RAG paths and
still validates the API contracts (non-RAG generation, attempt
persistence). That keeps the smoke test useful in CI without secrets.
"""
from __future__ import annotations
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
API = os.environ.get("ENCOACH_API", "http://127.0.0.1:8069")
LOGIN = os.environ.get("ENCOACH_LOGIN", "admin@encoach.test")
PASSWORD = os.environ.get("ENCOACH_PASSWORD", "admin123")
PDF_PATH = os.environ.get(
"HEADWAY_PDF",
"docs/toaz.info-headway-elementary-workbook-5th-edition-pr_a611cb12a73c05e5609ef05996a16ea3 (1).pdf",
)
INDEXING_TIMEOUT_S = int(os.environ.get("INDEXING_TIMEOUT_S", "120"))
# ---------------------------------------------------------------------------
# HTTP helpers (stdlib, identical pattern to smoke_assignment_workflow.py)
# ---------------------------------------------------------------------------
def _req(method, path, token=None, body=None, raw=None, content_type=None):
url = f"{API}{path}"
data = None
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
elif raw is not None:
data = raw
if content_type:
headers["Content-Type"] = content_type
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=120) as r:
payload = r.read().decode() or "{}"
return r.status, json.loads(payload) if payload.strip() else {}
except urllib.error.HTTPError as e:
body_raw = e.read().decode() if e.fp else ""
try:
parsed = json.loads(body_raw)
except Exception:
parsed = {"raw": body_raw}
return e.code, parsed
def _multipart(path, token, fields, file_field, file_path, file_name=None,
file_mime="application/pdf"):
"""Tiny stdlib multipart/form-data uploader for the ``/sources`` endpoint.
We avoid the ``requests`` dependency on purpose — the existing smoke
tests stick to ``urllib`` and we want this script to be runnable in a
bare CI container.
"""
boundary = f"----encoachSmoke{uuid.uuid4().hex}"
cr = b"\r\n"
body = []
for name, value in fields.items():
body.append(f"--{boundary}".encode())
body.append(
f'Content-Disposition: form-data; name="{name}"'.encode()
)
body.append(b"")
body.append(str(value).encode())
with open(file_path, "rb") as fh:
file_bytes = fh.read()
body.append(f"--{boundary}".encode())
body.append(
(
f'Content-Disposition: form-data; name="{file_field}"; '
f'filename="{file_name or os.path.basename(file_path)}"'
).encode()
)
body.append(f"Content-Type: {file_mime}".encode())
body.append(b"")
body.append(file_bytes)
body.append(f"--{boundary}--".encode())
body.append(b"")
payload = cr.join(body)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Length": str(len(payload)),
}
req = urllib.request.Request(
f"{API}{path}", data=payload, method="POST", headers=headers,
)
try:
with urllib.request.urlopen(req, timeout=180) as r:
raw = r.read().decode() or "{}"
return r.status, json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as e:
raw = e.read().decode() if e.fp else ""
try:
parsed = json.loads(raw)
except Exception:
parsed = {"raw": raw}
return e.code, parsed
# ---------------------------------------------------------------------------
# Pretty printing
# ---------------------------------------------------------------------------
def hr(title):
print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
def ok(msg):
print(f" PASS {msg}")
def warn(msg):
print(f" WARN {msg}")
def skip(msg):
print(f" SKIP {msg}")
def fail(msg):
print(f" FAIL {msg}")
sys.exit(1)
def must(label, code, data, ok_codes=(200, 201)):
if code not in ok_codes:
fail(f"{label}: expected {ok_codes}, got {code} -> {data}")
print(f" OK {label} ({code})")
# ---------------------------------------------------------------------------
# 1. Login
# ---------------------------------------------------------------------------
def login():
code, data = _req("POST", "/api/login", body={"login": LOGIN, "password": PASSWORD})
if code != 200:
fail(f"login: {code} -> {data}")
return data["access_token"]
# ---------------------------------------------------------------------------
# 2. Generate a fresh plan
# ---------------------------------------------------------------------------
def create_plan(token):
title = f"RAG smoke {uuid.uuid4().hex[:6]}"
body = {
"title": title,
"cefr_level": "A2",
"total_weeks": 4,
"contact_hours_per_week": 4,
"skills_division": "Reading 25% / Writing 25% / Listening 25% / Speaking 25%",
"learner_profile": "Adult learners preparing for everyday English use.",
"notes": "Use the uploaded Headway elementary workbook as the primary book.",
}
code, data = _req("POST", "/api/ai/course-plan", token=token, body=body)
if code not in (200, 201):
fail(f"create plan: {code} -> {data}")
plan = data.get("data") or {}
pid = plan.get("id")
if not pid:
fail(f"create plan: missing id -> {data}")
ok(f"created plan #{pid} ({plan.get('name')})")
return plan
# ---------------------------------------------------------------------------
# 3. Upload + wait for indexing
# ---------------------------------------------------------------------------
def upload_pdf(token, plan_id, path):
if not os.path.exists(path):
skip(f"PDF missing at {path} — skipping upload (RAG paths will be skipped)")
return None
code, data = _multipart(
f"/api/ai/course-plan/{plan_id}/sources",
token,
fields={"kind": "file", "name": os.path.basename(path), "auto_index": "1"},
file_field="file",
file_path=path,
)
if code not in (200, 201):
fail(f"upload PDF: {code} -> {data}")
src = data.get("data") or {}
sid = src.get("id")
if not sid:
fail(f"upload PDF: missing id -> {data}")
ok(f"uploaded source #{sid} (status={src.get('status')})")
return src
def wait_for_index(token, plan_id, source_id, timeout=INDEXING_TIMEOUT_S):
deadline = time.time() + timeout
last_status = None
while time.time() < deadline:
code, data = _req(
"GET", f"/api/ai/course-plan/{plan_id}/sources", token=token,
)
if code != 200:
fail(f"list sources: {code} -> {data}")
items = data.get("items") or []
src = next((s for s in items if s["id"] == source_id), None)
if not src:
fail(f"source {source_id} disappeared")
if src["status"] != last_status:
print(f" source #{source_id} status = {src['status']}"
f" chunks={src.get('chunks_count', 0)}")
last_status = src["status"]
if src["status"] == "indexed" and src.get("chunks_count", 0) > 0:
ok(f"indexed in pgvector ({src['chunks_count']} chunks,"
f" {src.get('extracted_chars', 0)} chars)")
return src
if src["status"] == "failed":
warn(f"indexing failed: {src.get('error', '?')}")
return src
time.sleep(2.0)
warn(f"indexing did not complete in {timeout}s; last status = {last_status}")
return None
# ---------------------------------------------------------------------------
# 4. Extract workbooks
# ---------------------------------------------------------------------------
def extract_workbooks(token, plan_id):
code, data = _req(
"POST",
f"/api/ai/course-plan/{plan_id}/extract-workbooks",
token=token,
body={"max_batches": 4},
)
if code != 200:
fail(f"extract-workbooks: {code} -> {data}")
stats = (data.get("data") or {})
print(
" extractor stats:"
f" materials_created={stats.get('materials_created')}"
f" exercises_total={stats.get('exercises_total')}"
f" batches_run={stats.get('batches_run')}"
f" reason={stats.get('reason')}"
)
return stats
# ---------------------------------------------------------------------------
# 5. Generate week 1 v2 (RAG)
# ---------------------------------------------------------------------------
def generate_week_v2(token, plan_id, week_number=1):
code, data = _req(
"POST",
f"/api/ai/course-plan/{plan_id}/weeks/{week_number}/materials/v2",
token=token,
)
if code == 409:
warn("v2 generator returned 409 (no indexed sources) — RAG path skipped")
return None
if code != 200:
fail(f"generate v2 week {week_number}: {code} -> {data}")
materials = data.get("items") or []
rag = data.get("rag") or {}
print(
f" week {week_number} produced {len(materials)} materials,"
f" RAG sources_used={rag.get('sources_used')}"
)
return materials
def fallback_generate_v1(token, plan_id, week_number=1):
"""Used only when RAG isn't available (no sources / index failed)."""
code, data = _req(
"POST",
f"/api/ai/course-plan/{plan_id}/weeks/{week_number}/materials",
token=token,
)
if code != 200:
fail(f"generate v1 week {week_number}: {code} -> {data}")
return data.get("items") or []
# ---------------------------------------------------------------------------
# 6. Find a workbook material + assign plan to ourselves so we can submit
# ---------------------------------------------------------------------------
def get_full_plan(token, plan_id):
code, data = _req("GET", f"/api/ai/course-plan/{plan_id}", token=token)
if code != 200:
fail(f"get plan: {code} -> {data}")
return data.get("data") or {}
def pick_workbook_material(plan):
"""Return the first material that exposes ``exercises`` we can answer.
Prefers ``interactive_workbook`` types, but the v2 generator can also
embed an ``interactive_workbook`` block inside ``grammar_lesson`` /
``vocabulary_list`` bodies so we look there too.
"""
for m in plan.get("materials") or []:
if m.get("material_type") == "interactive_workbook":
body = m.get("body") or {}
if (body.get("exercises") or []):
return m, body["exercises"]
body = m.get("body") or {}
wb = body.get("interactive_workbook") or {}
ex = wb.get("exercises") or []
if ex:
return m, ex
return None, []
def build_correct_answers(exercises):
"""Construct the answer key the server should grade as 100%."""
out = {}
for ex in exercises:
eid = ex.get("id")
if not eid:
continue
kind = ex.get("type")
if kind == "gap_fill" and ex.get("answer") is not None:
out[eid] = ex["answer"]
elif kind == "multiple_choice" and ex.get("answer") is not None:
out[eid] = ex["answer"]
elif kind == "match_pairs" and ex.get("answer"):
out[eid] = ex["answer"]
elif kind == "reorder_words" and ex.get("answer") is not None:
out[eid] = ex["answer"]
elif kind == "transformation" and ex.get("answer") is not None:
out[eid] = ex["answer"]
elif kind == "short_answer":
ans = ex.get("answer") or (
ex.get("accepted") or [None]
)[0]
if ans:
out[eid] = ans.replace("*", "anything")
return out
# ---------------------------------------------------------------------------
# 7. Submit attempt + reload
# ---------------------------------------------------------------------------
def submit_attempt(token, plan_id, material_id, answers, finalize=False):
code, data = _req(
"POST",
f"/api/student/course-plans/{plan_id}/materials/{material_id}/attempts",
token=token,
body={"answers": answers, "finalize": finalize},
)
if code != 200:
fail(f"save attempt: {code} -> {data}")
return data.get("data") or {}
def get_my_attempt(token, plan_id, material_id):
code, data = _req(
"GET",
f"/api/student/course-plans/{plan_id}/materials/{material_id}/attempts/me",
token=token,
)
if code != 200:
fail(f"get my attempt: {code} -> {data}")
return data.get("data")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
hr("0. Login")
token = login()
ok(f"logged in as {LOGIN}")
hr("1. Create a fresh plan")
plan = create_plan(token)
plan_id = plan["id"]
hr("2. Upload Headway PDF + wait for pgvector indexing")
src = upload_pdf(token, plan_id, PDF_PATH)
rag_ready = False
if src:
indexed = wait_for_index(token, plan_id, src["id"])
rag_ready = bool(
indexed and indexed.get("status") == "indexed"
and indexed.get("chunks_count", 0) > 0
)
hr("3. Extract workbook exercises from indexed sources")
if rag_ready:
stats = extract_workbooks(token, plan_id)
if (stats.get("materials_created") or 0) > 0:
ok(
f"extractor created {stats['materials_created']} workbook(s)"
f" with {stats.get('exercises_total', 0)} exercise(s)"
)
elif stats.get("reason"):
warn(f"extractor returned reason='{stats['reason']}'")
else:
warn("extractor produced 0 workbooks (LLM may be unavailable)")
else:
skip("no indexed source — extract-workbooks skipped")
hr("4. Generate week 1 with the RAG-grounded v2 endpoint")
materials = None
if rag_ready:
materials = generate_week_v2(token, plan_id, 1)
if materials:
grounded_count = sum(
1 for m in materials if m.get("grounded_on")
)
if grounded_count > 0:
ok(
f"{grounded_count}/{len(materials)} materials carry"
f" grounded_on provenance"
)
else:
warn(
"no material has grounded_on — pipeline may have"
" fallen back to deterministic skeleton"
)
if not materials:
skip("falling back to v1 generator so the rest of the smoke can run")
fallback_generate_v1(token, plan_id, 1)
hr("5. Pick an interactive workbook + submit a correct attempt")
plan_full = get_full_plan(token, plan_id)
material, exercises = pick_workbook_material(plan_full)
if not material or not exercises:
skip(
"no interactive workbook material on this plan — attempt"
" persistence path skipped (extractor + v2 likely returned"
" nothing because no LLM/RAG keys were configured)"
)
hr("DONE — partial smoke (no LLM/RAG path exercised)")
return
print(
f" using material #{material['id']} ({material.get('material_type')})"
f" with {len(exercises)} exercise(s)"
)
answers = build_correct_answers(exercises)
if not answers:
skip("could not build any answers from exercise schema")
hr("DONE — partial smoke")
return
attempt = submit_attempt(token, plan_id, material["id"], answers)
print(
f" server scored {attempt.get('correct_count')}/"
f"{attempt.get('total_count')} = {attempt.get('percent')}%"
)
if (attempt.get("total_count") or 0) <= 0:
fail("attempt persisted with total_count == 0 — scoring did not run")
ok(
f"attempt #{attempt.get('id')} persisted"
f" (attempt_number={attempt.get('attempt_number')})"
)
hr("6. Reload the attempt and verify the answers + score round-trip")
reloaded = get_my_attempt(token, plan_id, material["id"])
if not reloaded:
fail("attempt vanished after reload")
if reloaded.get("id") != attempt.get("id"):
fail(
f"reload returned a different attempt"
f" ({reloaded.get('id')} vs {attempt.get('id')})"
)
if reloaded.get("answers") != answers:
fail(
"answers diverged between save and reload"
f"\n saved: {answers}"
f"\n reloaded: {reloaded.get('answers')}"
)
ok("attempt + answers persisted across reload")
hr("7. Finalise the attempt (locks scoring)")
finalised = submit_attempt(
token, plan_id, material["id"], answers, finalize=True,
)
if not finalised.get("is_final"):
fail("finalize=true did not flip is_final")
ok(f"attempt finalised at {finalised.get('submitted_at')}")
hr("DONE — Professional interactive course-plan smoke passed")
print(
"Tip: open the plan in /admin/course-plans/<id>, click 'Student"
" view' to see the same workbook the student sees, then exit"
" to view the teacher dashboard."
)
if __name__ == "__main__":
main()