72 Commits

Author SHA1 Message Date
Yamen Ahmad
0d7139cbc8 Merge devops/main into split-backend-new-v2
Some checks failed
Deploy Backend to Staging / Deploy backend to staging (push) Failing after 13s
Strategy: ours for our changes, theirs for the 3 protected files
(Dockerfile, requirements.txt, .gitea/workflows/deploy.yml) so the
deploy pipeline (build → migrate → restart → smoke) and the typing_extensions
+ langgraph + langchain-core dependencies align with the staging server.

Made-with: Cursor
2026-04-28 00:21:15 +04:00
Yamen Ahmad
565ddd5ff7 feat(backend): course-plan student visibility, multi-voice TTS, OCR sources, branches
Ported from monorepo v4 commit 3b62075d (backend/* portion).

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

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

Made-with: Cursor
2026-04-28 00:20:35 +04:00
root
fc384efe85 ci: full deploy pipeline — build image, run DB migrations, restart odoo
Some checks failed
Deploy Backend to Staging / Deploy backend to staging (push) Failing after 17m35s
2026-04-27 21:51:40 +02:00
a6fa9d78d8 ci: use docker compose up -d odoo (no rebuild, conf from override)
Some checks failed
Deploy Backend to Staging / Deploy backend to staging (push) Failing after 1s
2026-04-26 12:34:34 +02:00
f37c8fc7f7 ci: restart odoo on push (code loaded via volume mount)
Some checks failed
Deploy Backend to Staging / Deploy backend to staging (push) Failing after 6s
2026-04-26 12:33:22 +02:00
de89518280 ci: only rebuild odoo service (not frontend)
Some checks failed
Deploy Backend to Staging / Deploy backend to staging (push) Failing after 3s
2026-04-26 12:27:57 +02:00
fbf6150cca ci: split build/up steps with plain progress for debugging
Some checks failed
Deploy Backend to Staging / Deploy backend to staging (push) Failing after 1s
2026-04-26 12:25:45 +02:00
a4ec9a5fb2 ci: verify runner host-mode execution
Some checks failed
Deploy Backend to Staging / Deploy backend to staging (push) Failing after 3s
2026-04-26 12:21:12 +02:00
4e4202742f ci: fix workflow - remove concurrency, use retry smoke test
Some checks failed
Deploy Backend to Staging / Deploy backend to staging (push) Failing after 2s
2026-04-26 12:14:20 +02:00
499f58f617 ci: test trigger (debug run)
Some checks failed
Deploy Backend to Staging / Deploy backend to staging (push) Failing after 2s
2026-04-26 12:09:46 +02:00
65d5eb2480 ci: re-add backend staging deploy workflow
Some checks failed
Deploy Backend to Staging / Deploy backend to staging (push) Failing after 1s
2026-04-26 12:06:50 +02:00
Yamen Ahmad
5ec6ae0ae1 chore(release): sync backend from monorepo v4 2026-04-26 03:10:48 +04:00
Yamen Ahmad
cd47d01f53 feat(platform): ship AI fallback stack and entity-scoped course planning
Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform.

Made-with: Cursor
2026-04-26 02:34:52 +04:00
Yamen Ahmad
096b042daf feat(course-plan): RAG sources + multi-modal media + assignments + student view
Some checks failed
Deploy to Staging / Deploy backend + frontend to staging (push) Failing after 14s
Builds the §24 product on top of the LangGraph runtime from §22:

Phase A (Sources / RAG)
  - encoach.course.plan.source model (file | url | text)
  - SourceIndexer extracts PDF (pypdf), DOCX (python-docx), HTML, plain
    text and embeds chunks via the existing pgvector pipeline scoped to
    plan_id, so resources.search only returns the plan's own corpus
  - Endpoints: list/create/upload/reindex/delete + plan-scoped retrieval

Phase B (Deliverables)
  - services.deliverables.compute_deliverables walks the plan, derives
    {planned, generated, ready} per week from material + media state
  - GET /api/ai/course-plan/<id>/deliverables drives the new wizard
    preview step and the live progress strip on the detail page

Phase C (Multi-modal media)
  - encoach.course.plan.media model + MediaService:
    audio: AWS Polly (default) or ElevenLabs
    image: OpenAI DALL-E 3, capped per plan via system parameter
    video: local ffmpeg subprocess (image + audio -> MP4 1280x720)
  - Three new agent tools (media.synthesize_audio / generate_image /
    compose_video), wired into course_week_materials and a new
    course_media_director agent
  - Endpoints per material + week-level batch generator

Phase D (Assignments)
  - encoach.course.plan.assignment supports mode='batch' (op.batch) or
    mode='students' (res.users), with due_date + message + state
  - REST endpoints to list / create / delete assignments

Phase E (Student view)
  - /api/student/course-plans + /api/student/course-plans/<id>
    enforce visibility via assignment.expand_user_ids()
  - New /student/course-plans list + read-only drilldown rendering
    audio/image/video tiles from /web/content/<attachment_id>

Cross-cutting
  - encoach.ai.tool.category: + media (so the new tools register)
  - encoach.embedding gains a plan_id filter for plan-scoped RAG
  - Wizard adds Sources + Multimedia steps; AdminCoursePlanDetail
    rewritten with DeliverablesStrip + SourcesCard + AssignmentsCard +
    per-material MediaDrawer
  - ~280 new EN + AR i18n keys (full RTL coverage)
  - smoke_course_plan.py exercises every phase via odoo-bin shell;
    last run: PASS A/B/D/E + DALL-E 3 image (753 KB), Polly audio
    fails cleanly when AWS creds aren't configured (expected)

Documentation: §24 added to docs/PROJECT_SUMMARY.md with phase-by-phase
artefact list, endpoints, smoke test, ops notes, and gotchas.

Made-with: Cursor
2026-04-25 17:13:01 +04:00
Yamen Ahmad
afd1662a60 feat(course-plan): RAG sources + multi-modal media + assignments + student view
Builds the §24 product on top of the LangGraph runtime from §22:

Phase A (Sources / RAG)
  - encoach.course.plan.source model (file | url | text)
  - SourceIndexer extracts PDF (pypdf), DOCX (python-docx), HTML, plain
    text and embeds chunks via the existing pgvector pipeline scoped to
    plan_id, so resources.search only returns the plan's own corpus
  - Endpoints: list/create/upload/reindex/delete + plan-scoped retrieval

Phase B (Deliverables)
  - services.deliverables.compute_deliverables walks the plan, derives
    {planned, generated, ready} per week from material + media state
  - GET /api/ai/course-plan/<id>/deliverables drives the new wizard
    preview step and the live progress strip on the detail page

Phase C (Multi-modal media)
  - encoach.course.plan.media model + MediaService:
    audio: AWS Polly (default) or ElevenLabs
    image: OpenAI DALL-E 3, capped per plan via system parameter
    video: local ffmpeg subprocess (image + audio -> MP4 1280x720)
  - Three new agent tools (media.synthesize_audio / generate_image /
    compose_video), wired into course_week_materials and a new
    course_media_director agent
  - Endpoints per material + week-level batch generator

Phase D (Assignments)
  - encoach.course.plan.assignment supports mode='batch' (op.batch) or
    mode='students' (res.users), with due_date + message + state
  - REST endpoints to list / create / delete assignments

Phase E (Student view)
  - /api/student/course-plans + /api/student/course-plans/<id>
    enforce visibility via assignment.expand_user_ids()
  - New /student/course-plans list + read-only drilldown rendering
    audio/image/video tiles from /web/content/<attachment_id>

Cross-cutting
  - encoach.ai.tool.category: + media (so the new tools register)
  - encoach.embedding gains a plan_id filter for plan-scoped RAG
  - Wizard adds Sources + Multimedia steps; AdminCoursePlanDetail
    rewritten with DeliverablesStrip + SourcesCard + AssignmentsCard +
    per-material MediaDrawer
  - ~280 new EN + AR i18n keys (full RTL coverage)
  - smoke_course_plan.py exercises every phase via odoo-bin shell;
    last run: PASS A/B/D/E + DALL-E 3 image (753 KB), Polly audio
    fails cleanly when AWS creds aren't configured (expected)

Documentation: §24 added to docs/PROJECT_SUMMARY.md with phase-by-phase
artefact list, endpoints, smoke test, ops notes, and gotchas.

Made-with: Cursor
2026-04-25 17:13:01 +04:00
Yamen Ahmad
0ed7f88cab fix(security): remove comment from ir.model.access.csv causing import error
Made-with: Cursor
2026-04-25 14:59:27 +04:00
Yamen Ahmad
cfdf2be527 fix(security): remove comment from ir.model.access.csv causing import error
Made-with: Cursor
2026-04-25 14:59:27 +04:00
Yamen Ahmad
ed8e75d88c feat(course-plan): GE1-style AI course planning with deliverables, resources, media, assignments
Based on UTAS GE1 Course Outline structure (Reading/Writing 10hrs + Listening/Speaking 8hrs)

New Models:
- encoach.course.plan.deliverable: Explicit learning outcome tracking by week/skill
- encoach.course.plan.resource.dep: Resource dependencies (textbooks, videos, etc.)
- encoach.course.plan.assignment: Assign plans to classes/students with progress tracking
- encoach.course.plan.assignment.deliverable: Per-student deliverable completion status

Extended Models:
- course.plan.material: Added media fields (media_type, media_asset_url, media_asset_id,
  media_generation_prompt, media_metadata_json) for rich content
- New material types: video_lesson, audio_recording, image_visual, interactive, assessment

AI Agent Tools (agent_tools.py):
- deliverables.detect: Parse course outlines (like GE1 PDF) and extract structured outcomes
- deliverables.fetch: Get deliverables for AI to reference when generating
- resources.fetch: Check available resources before generating content
- resources.save: Persist resource dependencies
- media.suggest_visuals: AI suggests images/diagrams for materials
- media.generate_image: Generate educational images (DALL-E integration ready)
- media.generate_audio: Generate TTS audio (ElevenLabs/Polly integration ready)
- assignment.*: Create assignments and track progress

Pipeline Enhancements (course_plan_pipeline.py):
- generate_deliverables_from_outline(): Parse PDF/text outlines into structured deliverables
- generate_week_materials_with_resources(): Resource-aware content generation
- suggest_media_for_material(): AI visual aid suggestions
- generate_media_for_material(): Actual image/audio generation

New AI Agents (agents_defaults.xml):
- deliverable_detector: Parses GE1-style outlines, extracts deliverables week-by-week
- media_generator: Creates images/audio for teaching materials
- Updated course_planner & course_week_materials with resource tools

REST APIs (course_plan.py):
POST /api/ai/course-plan/<id>/deliverables/detect - Parse outline
GET  /api/ai/course-plan/<id>/deliverables - List deliverables
PUT  /api/ai/course-plan/deliverables/<id> - Update status

GET  /api/ai/course-plan/<id>/resources - List resources
POST /api/ai/course-plan/<id>/resources - Add resource

POST /api/ai/course-plan/materials/<id>/media/suggest - Get visual suggestions
POST /api/ai/course-plan/materials/<id>/media/generate - Generate image/audio

POST /api/ai/course-plan/<id>/assignments - Assign to class/student
GET  /api/ai/course-plan/<id>/assignments - List assignments
GET  /api/ai/course-plan/assignments/<id> - Get with progress
PUT  /api/ai/course-plan/assignments/<id>/deliverables/<del_id> - Update status

Security: Added ir.model.access.csv entries for all new models
Made-with: Cursor
2026-04-25 14:57:04 +04:00
Yamen Ahmad
1dd1168fee feat(course-plan): GE1-style AI course planning with deliverables, resources, media, assignments
Based on UTAS GE1 Course Outline structure (Reading/Writing 10hrs + Listening/Speaking 8hrs)

New Models:
- encoach.course.plan.deliverable: Explicit learning outcome tracking by week/skill
- encoach.course.plan.resource.dep: Resource dependencies (textbooks, videos, etc.)
- encoach.course.plan.assignment: Assign plans to classes/students with progress tracking
- encoach.course.plan.assignment.deliverable: Per-student deliverable completion status

Extended Models:
- course.plan.material: Added media fields (media_type, media_asset_url, media_asset_id,
  media_generation_prompt, media_metadata_json) for rich content
- New material types: video_lesson, audio_recording, image_visual, interactive, assessment

AI Agent Tools (agent_tools.py):
- deliverables.detect: Parse course outlines (like GE1 PDF) and extract structured outcomes
- deliverables.fetch: Get deliverables for AI to reference when generating
- resources.fetch: Check available resources before generating content
- resources.save: Persist resource dependencies
- media.suggest_visuals: AI suggests images/diagrams for materials
- media.generate_image: Generate educational images (DALL-E integration ready)
- media.generate_audio: Generate TTS audio (ElevenLabs/Polly integration ready)
- assignment.*: Create assignments and track progress

Pipeline Enhancements (course_plan_pipeline.py):
- generate_deliverables_from_outline(): Parse PDF/text outlines into structured deliverables
- generate_week_materials_with_resources(): Resource-aware content generation
- suggest_media_for_material(): AI visual aid suggestions
- generate_media_for_material(): Actual image/audio generation

New AI Agents (agents_defaults.xml):
- deliverable_detector: Parses GE1-style outlines, extracts deliverables week-by-week
- media_generator: Creates images/audio for teaching materials
- Updated course_planner & course_week_materials with resource tools

REST APIs (course_plan.py):
POST /api/ai/course-plan/<id>/deliverables/detect - Parse outline
GET  /api/ai/course-plan/<id>/deliverables - List deliverables
PUT  /api/ai/course-plan/deliverables/<id> - Update status

GET  /api/ai/course-plan/<id>/resources - List resources
POST /api/ai/course-plan/<id>/resources - Add resource

POST /api/ai/course-plan/materials/<id>/media/suggest - Get visual suggestions
POST /api/ai/course-plan/materials/<id>/media/generate - Generate image/audio

POST /api/ai/course-plan/<id>/assignments - Assign to class/student
GET  /api/ai/course-plan/<id>/assignments - List assignments
GET  /api/ai/course-plan/assignments/<id> - Get with progress
PUT  /api/ai/course-plan/assignments/<id>/deliverables/<del_id> - Update status

Security: Added ir.model.access.csv entries for all new models
Made-with: Cursor
2026-04-25 14:57:04 +04:00
Yamen Ahmad
971e9860c8 ci(frontend): add Gitea staging deploy workflow under frontend/ (monorepo mirror of encoach_frontend New_v2 main)
Some checks failed
Deploy to Staging / Deploy backend + frontend to staging (push) Has been cancelled
Made-with: Cursor
2026-04-25 11:55:20 +04:00
Yamen Ahmad
8d173b93cb merge(devops): integrate main (staging CI deploy workflow + PR history)
Some checks failed
Deploy to Staging / Deploy backend + frontend to staging (push) Has been cancelled
2026-04-25 11:52:28 +04:00
a5a3a2dc62 ci: add auto-deploy workflow to staging
All checks were successful
Deploy to Staging / Deploy backend + frontend to staging (push) Successful in 38s
2026-04-25 09:24:33 +02:00
Yamen Ahmad
fa6f4976c3 chore(ops): add scripts/backup-db.sh — Odoo-format DB backup
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Failing after 4s
CI / Backend — Odoo HttpCase (pull_request) Failing after 1s
Produces an Odoo-compatible <db>.zip restorable via /web/database/manager
on the live VPS (or curl -F backup_file=@... /web/database/restore).

Why this script instead of POST /web/database/backup:
the Odoo HTTP backup endpoint shells out to pg_dump from $PATH and on
this host that returns "Command pg_dump not found" because pg_dump
lives in the conda env that Odoo wasn't launched from. This script
calls pg_dump directly with PGBIN, copies the filestore, builds a
correct manifest.json (odoo_dump, db_name, version, pg_version,
modules), and zips the three artefacts in the exact layout Odoo's
restore code expects.

Layout matches Odoo's own backup output:
  <db>.zip
  |- dump.sql       (pg_dump --no-owner --format=p)
  |- manifest.json  (odoo_dump=1, version, pg_version, installed modules)
  |- filestore/     (data_dir/filestore/<db>/ contents)

Defaults are encoach_v2 / yamenahmad / /tmp/odoo-data, all overridable
via env vars (DB, DB_USER, DATA_DIR, PGBIN, OUT_DIR). Output filename
is encoach_v2_YYYYMMDD_HHMMSS.zip in ./backups/.

Verified: latest run produced encoach_v2_20260425_031949.zip — 30 MB
compressed, 815 entries, 633 tables x 633 COPY data blocks, 575
filestore files, 94 modules in manifest.

Made-with: Cursor
2026-04-25 03:20:41 +04:00
Yamen Ahmad
882179870c 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
Yamen Ahmad
e2aa8031ff feat(ai): LangGraph as core runtime + AI Agents/Tools console + full-demo seed
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Failing after 1m20s
CI / Backend — Odoo HttpCase (pull_request) Failing after 1s
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
Yamen Ahmad
1223074bde docs: add VPS restore runbook
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
Explains why /web/database/manager restores appear to succeed but the
live site keeps serving the old data (target DB name mismatch / Odoo
not restarted / dbfilter pinned), and ships a 6-step SSH-based
recovery procedure with safety-net backup, filestore handling, and
verification checklist tied to the known QA fixtures.

Made-with: Cursor
2026-04-21 09:35:11 +04:00
Yamen Ahmad
75ee0f1fe0 fix(ui): actionable 413/504/401 toasts + VPS nginx deploy template
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
Production QA reported "Submit failed 413" on /generation and plain
"Upload failed" (no detail) on resource upload. Root cause is the
outer reverse-proxy nginx on the VPS — still on default
`client_max_body_size 1m` — rejecting requests before they reach
Odoo. The inner docker-nginx and Odoo limits were already raised in
earlier commits; only the VPS proxy was left unpatched.

UI side
- Add `describeApiError(err, fallback, context)` helper in
  lib/api-client.ts. Maps common HTTP codes to human-readable,
  actionable toast descriptions (413 → "ask ops to raise nginx
  client_max_body_size", 504 → "server took too long, retry", 502
  → "Odoo is restarting", 401 → "sign in again", 4xx/5xx → server
  error body + status code).
- Use it in GenerationPage submit, ResourceManager upload,
  TeacherLibrary upload, CustomExamCreate save/publish. Replaces
  four slightly-different ad-hoc mappings with one source of truth.

Deploy side
- Add deploy/nginx-vps.conf.example: full TLS reverse-proxy template
  with the body/timeout knobs that must match Odoo's limit_request
  (128 MB) and limit_time_real (900 s).
- Add docs/DEPLOY_413_504_FIX.md: step-by-step remediation guide for
  the team lead covering the 3 layers that can block large bodies
  (outer nginx, Cloudflare, Odoo worker) and how to verify each.

Made-with: Cursor
2026-04-21 09:09:23 +04:00
Yamen Ahmad
170d7c8d2e fix(assignments): resolve op.student → res.users + student search
E2E QA surfaced a silent-drop bug in the exam-schedule endpoint: the
frontend's student picker sends `op.student.id` values (that's what
/api/students returns), but `encoach.exam.schedule.student_ids` is a
Many2many → res.users. The controller was writing the op.student id
straight through, so schedules linked to whichever res.users row
happened to share that integer id (OdooBot, other staff, etc.) — the
target student got 0 assignments and never saw the exam.

- exam_schedules.py: add `_resolve_student_user_ids` and use it in the
  create and update endpoints. Falls back to treating unmatched ids as
  direct res.users ids for back-office callers.
- AssignmentsPage.tsx: add a search input + larger list to the
  Individual Students picker; long lists were leaving Sarah unreachable
  inside the inner scroll container during QA.

Made-with: Cursor
2026-04-20 18:29:16 +04:00
Yamen Ahmad
d34180e107 fix(assignments): resolve op.student → res.users + student search
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
E2E QA surfaced a silent-drop bug in the exam-schedule endpoint: the
frontend's student picker sends `op.student.id` values (that's what
/api/students returns), but `encoach.exam.schedule.student_ids` is a
Many2many → res.users. The controller was writing the op.student id
straight through, so schedules linked to whichever res.users row
happened to share that integer id (OdooBot, other staff, etc.) — the
target student got 0 assignments and never saw the exam.

- exam_schedules.py: add `_resolve_student_user_ids` and use it in the
  create and update endpoints. Falls back to treating unmatched ids as
  direct res.users ids for back-office callers.
- AssignmentsPage.tsx: add a search input + larger list to the
  Individual Students picker; long lists were leaving Sarah unreachable
  inside the inner scroll container during QA.

Made-with: Cursor
2026-04-20 18:29:16 +04:00
Yamen Ahmad
eef3edf7e8 fix(i18n): default startup language to English
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
First-visit users now always land on the English UI regardless of
navigator.language. Arabic remains one click away via the toggle, and
the user's explicit pick is persisted to localStorage (encoach-lang)
and honoured on every subsequent load.

- src/i18n/index.ts: set lng: "en", drop navigator/htmlTag from the
  detector order so an Arabic-locale browser no longer silently boots
  the UI in Arabic before the user has chosen.
- src/lib/api-client.ts: mirror the same policy for the Accept-Language
  header sent to the backend, so AI-generated content stays English on
  first visit instead of echoing the browser locale.

Made-with: Cursor
2026-04-20 17:19:49 +04:00
Yamen Ahmad
d35ccc255f fix(qa): approval queue, rubric UX, structure enforcement, upload/publish limits
Addresses the QA notes on the end-to-end approval flow. Highlights:

Backend
- encoach_ai/generation_submit: create encoach.approval.request on submit so
  submitted exams actually reach the approver queue (previously only the
  workflow id was stored on the exam, leaving approvers with an empty inbox).
  Initial exam status flips to pending_approval instead of draft.
- encoach_exam_template/approval_workflows:
  * /api/approval-users filters out students (user_type='student',
    op_student_id set, share=True) so the approver dropdown only lists staff.
  * _ser_request enriches requests with target_name / target_status and the
    current stage approver for UI badges.
  * list_requests supports mine=1 / requester=1 so approvers only see queue
    items awaiting their action.
  * approve_request / reject_request now transition the underlying
    encoach.exam.custom to published / rejected on final approval.
- encoach.exam.custom.status: add pending_approval and rejected states to
  match the approval workflow transitions.

Frontend UX
- GenerationPage: rubric field shows "Auto-graded — no rubric" for Listening
  and Reading (rubrics only apply to Writing / Speaking). Divider dropdowns
  now carry explicit help text explaining None / Line / Space / Page Break
  and where to write prompts. Selecting an official exam structure
  auto-populates the required tasks/sections/parts, the delete button is
  hidden for essential tasks, and submit is blocked if the user dropped
  below the structure's required count.
- RubricsPage: "Add Criterion" is now a dropdown with IELTS-specific
  presets (Task Achievement, Coherence & Cohesion, Fluency & Coherence, …)
  keyed off the selected skill, plus a "Custom (blank row)" fallback.
- AdminCourses: added tooltips and helper copy clarifying Difficulty vs
  CEFR Level in the Create Course dialog.
- CustomExamCreate: validate the whole form before Save/Publish, surface
  backend error messages (413/504/401) instead of a generic toast, read the
  exam id from the correct response field, and avoid marking Publish as
  failed when only the optional Save-as-Template step errors.
- ResourceManager / TeacherLibrary: upload toast now shows a concrete
  reason (HTTP 413 / 504 / 401) instead of a generic "Upload failed".

Config (504 / 413 / resource upload fixes)
- odoo.conf + odoo-docker.conf: raise limit_time_cpu to 600s,
  limit_time_real to 900s, limit_request to 128 MB, and memory caps so
  /api/exam/generation/submit and multipart resource uploads stop hitting
  504 / 413 during QA.
- frontend/Dockerfile (nginx): add client_max_body_size 128m, bump
  proxy_read_timeout / proxy_send_timeout to 900s, and disable request
  buffering so large AI/resource payloads stream through the proxy.

Made-with: Cursor
2026-04-20 17:14:39 +04:00
Yamen Ahmad
a554ef5d42 fix(qa): approval queue, rubric UX, structure enforcement, upload/publish limits
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
Addresses the QA notes on the end-to-end approval flow. Highlights:

Backend
- encoach_ai/generation_submit: create encoach.approval.request on submit so
  submitted exams actually reach the approver queue (previously only the
  workflow id was stored on the exam, leaving approvers with an empty inbox).
  Initial exam status flips to pending_approval instead of draft.
- encoach_exam_template/approval_workflows:
  * /api/approval-users filters out students (user_type='student',
    op_student_id set, share=True) so the approver dropdown only lists staff.
  * _ser_request enriches requests with target_name / target_status and the
    current stage approver for UI badges.
  * list_requests supports mine=1 / requester=1 so approvers only see queue
    items awaiting their action.
  * approve_request / reject_request now transition the underlying
    encoach.exam.custom to published / rejected on final approval.
- encoach.exam.custom.status: add pending_approval and rejected states to
  match the approval workflow transitions.

Frontend UX
- GenerationPage: rubric field shows "Auto-graded — no rubric" for Listening
  and Reading (rubrics only apply to Writing / Speaking). Divider dropdowns
  now carry explicit help text explaining None / Line / Space / Page Break
  and where to write prompts. Selecting an official exam structure
  auto-populates the required tasks/sections/parts, the delete button is
  hidden for essential tasks, and submit is blocked if the user dropped
  below the structure's required count.
- RubricsPage: "Add Criterion" is now a dropdown with IELTS-specific
  presets (Task Achievement, Coherence & Cohesion, Fluency & Coherence, …)
  keyed off the selected skill, plus a "Custom (blank row)" fallback.
- AdminCourses: added tooltips and helper copy clarifying Difficulty vs
  CEFR Level in the Create Course dialog.
- CustomExamCreate: validate the whole form before Save/Publish, surface
  backend error messages (413/504/401) instead of a generic toast, read the
  exam id from the correct response field, and avoid marking Publish as
  failed when only the optional Save-as-Template step errors.
- ResourceManager / TeacherLibrary: upload toast now shows a concrete
  reason (HTTP 413 / 504 / 401) instead of a generic "Upload failed".

Config (504 / 413 / resource upload fixes)
- odoo.conf + odoo-docker.conf: raise limit_time_cpu to 600s,
  limit_time_real to 900s, limit_request to 128 MB, and memory caps so
  /api/exam/generation/submit and multipart resource uploads stop hitting
  504 / 413 during QA.
- frontend/Dockerfile (nginx): add client_max_body_size 128m, bump
  proxy_read_timeout / proxy_send_timeout to 900s, and disable request
  buffering so large AI/resource payloads stream through the proxy.

Made-with: Cursor
2026-04-20 17:14:39 +04:00
b1b3d20eb4 Merge pull request 'fix(deploy): vendor OpenEduCat Community 19.0 under backend/' (#6) from v4 into main
Some checks failed
CI / Frontend — lint + build + e2e (push) Has been cancelled
CI / Backend — Odoo HttpCase (push) Has been cancelled
Reviewed-on: #6
2026-04-20 08:08:32 +02:00
Yamen Ahmad
4253f0174a fix(deploy): vendor OpenEduCat Community 19.0 under backend/
Database dumps list 12 openeducat_* modules as installed (openeducat_core,
openeducat_admission, openeducat_assignment, openeducat_attendance,
openeducat_activity, openeducat_classroom, openeducat_exam, openeducat_facility,
openeducat_fees, openeducat_library, openeducat_parent, openeducat_timetable),
but the `backend/openeducat_erp-19.0/` folder was previously in .gitignore.
When a teammate restored the dump on the VPS Odoo failed with "module not
found" on every openeducat_* row in ir_module_module.

- Remove `backend/openeducat_erp-19.0/` from .gitignore and vendor the full
  LGPL-3 OpenEduCat Community 19.0 tree (14 modules: 12 core + theme + erp
  meta-module). `odoo.conf` and `odoo-docker.conf` already reference this
  path in `addons_path`, so restores now succeed on a fresh VPS with no
  extra bootstrap step.
- Strip the nested .git metadata that came with the upstream download so
  the tree is vendored flat (not as a submodule pointer).

Made-with: Cursor
2026-04-20 09:52:32 +04:00
Yamen Ahmad
bab588b9da fix(deploy): vendor OpenEduCat Community 19.0 under backend/
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
Database dumps list 12 openeducat_* modules as installed (openeducat_core,
openeducat_admission, openeducat_assignment, openeducat_attendance,
openeducat_activity, openeducat_classroom, openeducat_exam, openeducat_facility,
openeducat_fees, openeducat_library, openeducat_parent, openeducat_timetable),
but the `backend/openeducat_erp-19.0/` folder was previously in .gitignore.
When a teammate restored the dump on the VPS Odoo failed with "module not
found" on every openeducat_* row in ir_module_module.

- Remove `backend/openeducat_erp-19.0/` from .gitignore and vendor the full
  LGPL-3 OpenEduCat Community 19.0 tree (14 modules: 12 core + theme + erp
  meta-module). `odoo.conf` and `odoo-docker.conf` already reference this
  path in `addons_path`, so restores now succeed on a fresh VPS with no
  extra bootstrap step.
- Strip the nested .git metadata that came with the upstream download so
  the tree is vendored flat (not as a submodule pointer).

Made-with: Cursor
2026-04-20 09:52:32 +04:00
e33a9a61bb Merge pull request 'v4' (#5) from v4 into main
Some checks failed
CI / Frontend — lint + build + e2e (push) Has been cancelled
CI / Backend — Odoo HttpCase (push) Has been cancelled
Reviewed-on: #5
2026-04-19 16:16:33 +02:00
Yamen Ahmad
93def02e94 feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts
Frontend
- i18n: install tailwindcss-rtl, Cairo font, RTL-aware direction in index.css.
- Language toggle: localize aria-label / menu label, persist choice, update
  document dir synchronously.
- Sidebar: add `side` prop so the drawer pins to the right in RTL; wire up
  AdminLmsLayout, RoleLayout (student/teacher) and AppSidebar to pass
  side = i18n.dir() === 'rtl' ? 'right' : 'left'.
- AdminLmsLayout: convert every nav item from hard-coded title to titleKey,
  translate group labels (incl. the collapsible Training), breadcrumbs,
  user menu (Profile / Settings / Logout), help button and toggle aria
  labels; replace physical mr-/right- utilities with logical me-/end-.
- AI components (AiTipBanner, AiInsightsPanel, AiAlertBanner, AiSearchBar,
  AiAssistantDrawer): apply dir="auto" at the container level, localize
  titles, loading / error / empty states.
- Dashboards (admin / student / teacher): wrap numeric values in <bdi>,
  localize dates via ar-EG, fix flex direction for KPI and assignment cards.
- UI primitives (breadcrumb, calendar, carousel, dropdown-menu, menubar,
  context-menu, pagination, sidebar): flip chevrons in RTL via a scoped
  CSS rule, swap pl-/pr-/ml-/mr- for ps-/pe-/ms-/me-.
- Add logical-direction helpers and bidirectional isolation classes.

Locales
- Expand en.ts and ar.ts with full `nav`, `sidebarGroup`, `breadcrumb`,
  `userMenu`, `chrome`, `ai`, and dashboard key sets; keep key parity.

API client
- `api-client.ts` reads the active language from localStorage/i18n and sends
  `Accept-Language` on every request so the backend can localize AI output.

Backend (encoach_ai)
- openai_service: add _LANGUAGE_NAMES, normalize_language, language-aware
  system prompt injection for every OpenAI call.
- coach_service + controllers (coach_controller, ai_controller): thread
  the requested language from headers / user locale down to OpenAIService.
- ai_feedback: fix latent registry error by pointing course_id at op.course
  instead of the non-existent encoach.course.

Other
- .gitignore: ignore runtime odoo logs and local caches.

Made-with: Cursor
2026-04-19 18:13:16 +04:00
Yamen Ahmad
e1f059069f feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
Frontend
- i18n: install tailwindcss-rtl, Cairo font, RTL-aware direction in index.css.
- Language toggle: localize aria-label / menu label, persist choice, update
  document dir synchronously.
- Sidebar: add `side` prop so the drawer pins to the right in RTL; wire up
  AdminLmsLayout, RoleLayout (student/teacher) and AppSidebar to pass
  side = i18n.dir() === 'rtl' ? 'right' : 'left'.
- AdminLmsLayout: convert every nav item from hard-coded title to titleKey,
  translate group labels (incl. the collapsible Training), breadcrumbs,
  user menu (Profile / Settings / Logout), help button and toggle aria
  labels; replace physical mr-/right- utilities with logical me-/end-.
- AI components (AiTipBanner, AiInsightsPanel, AiAlertBanner, AiSearchBar,
  AiAssistantDrawer): apply dir="auto" at the container level, localize
  titles, loading / error / empty states.
- Dashboards (admin / student / teacher): wrap numeric values in <bdi>,
  localize dates via ar-EG, fix flex direction for KPI and assignment cards.
- UI primitives (breadcrumb, calendar, carousel, dropdown-menu, menubar,
  context-menu, pagination, sidebar): flip chevrons in RTL via a scoped
  CSS rule, swap pl-/pr-/ml-/mr- for ps-/pe-/ms-/me-.
- Add logical-direction helpers and bidirectional isolation classes.

Locales
- Expand en.ts and ar.ts with full `nav`, `sidebarGroup`, `breadcrumb`,
  `userMenu`, `chrome`, `ai`, and dashboard key sets; keep key parity.

API client
- `api-client.ts` reads the active language from localStorage/i18n and sends
  `Accept-Language` on every request so the backend can localize AI output.

Backend (encoach_ai)
- openai_service: add _LANGUAGE_NAMES, normalize_language, language-aware
  system prompt injection for every OpenAI call.
- coach_service + controllers (coach_controller, ai_controller): thread
  the requested language from headers / user locale down to OpenAIService.
- ai_feedback: fix latent registry error by pointing course_id at op.course
  instead of the non-existent encoach.course.

Other
- .gitignore: ignore runtime odoo logs and local caches.

Made-with: Cursor
2026-04-19 18:13:16 +04:00
Yamen Ahmad
6712d1d551 docs: add WORK_REPORT.md and USER_MANUAL.md
- docs/WORK_REPORT.md: recap of the Phase 0/1/2/3 hardening sprint —
  what shipped per phase, branding update, repo migration to the new
  full_encoach_platform repo, per-commit file-churn, verification
  results (tsc, build, Playwright), known gaps, and an operator
  checklist for production promotion.
- docs/USER_MANUAL.md: end-user guide for every role (Student, Teacher,
  Admin, Corporate, Agent, Developer). Walks through every portal URL,
  the AI coach / IELTS / adaptive flows, human-in-the-loop exam review,
  AI prompt editor, feedback triage, GDPR Privacy Center, language +
  theme toggles, and troubleshooting.

Made-with: Cursor
2026-04-19 14:41:19 +04:00
7024197c7b Merge pull request 'v4' (#4) from v4 into main
Some checks failed
CI / Frontend — lint + build + e2e (push) Has been cancelled
CI / Backend — Odoo HttpCase (push) Has been cancelled
Reviewed-on: #4
2026-04-19 12:33:30 +02:00
Yamen Ahmad
93c530eef2 chore(ci,docs): GitHub Actions, ADRs, README overhaul, §21 Hardening Release
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
- .github/workflows/ci.yml: two jobs — frontend (tsc --noEmit, lint, build,
  Playwright) and backend (Postgres 16 + odoo:19 --test-enable
  --test-tags encoach_api) — catches regressions before merge.
- docs/adr/: start an Architecture Decision Record trail with
  0001 canonical directory layout, 0002 JWT refresh flow,
  0003 paginated response envelope, 0004 RAG metadata + chunking.
- docs/PROJECT_SUMMARY.md §21 Hardening Release: full recap of the AI
  quality loop, compliance, Paymob, i18n, and CI work shipped in this
  drop, plus new DB tables, REST routes, frontend routes, verification
  results, and operator-facing configuration.
- README.md refreshed for the v4 split-repo doctrine and the new feature
  surface.
- new_project/DEPRECATED.md: formal retirement notice pointing at
  backend/ as the canonical tree.

Made-with: Cursor
2026-04-19 14:16:47 +04:00
Yamen Ahmad
e70a2854f4 feat(frontend): Phase 2/3 hardening release
Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
  the project-manager PNG in a separate commit).

Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
  and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.

Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
  from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.

Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
  LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
  by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
  pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
  AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
  services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
  and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
  smoke tests, npm scripts test:e2e / test:e2e:install.

Made-with: Cursor
2026-04-19 14:16:32 +04:00
Yamen Ahmad
3972023a30 feat(backend): Phase 2/3 hardening release
Roadmap P0 — platform safety & ops
- Merge duplicate encoach.student.attempt/answer models into encoach_scoring
  and drop the stale encoach_exam_template copies.
- Remove duplicate /api/exam/* routes; canonicalize on one controller tree.
- Gate raw-SQL seeds in seed_demo_data.py behind an explicit env flag.
- Add /api/health and /api/health/ready (DB + LLM reachability) endpoints.
- Fix docker-compose + ship odoo-docker.conf for container-local runs.
- Enforce OpenAI request_timeout=30s and @jwt_required on all AI/coach routes.
- Promote canonical cefr_mapper to encoach_ai.services.cefr_mapper.
- JWT cache TTL=30s + invalidation hook on user mutation.

Roadmap P1 — exam correctness & data provenance
- Wire QualityChecker + IeltsValidator into exam submit with a
  pending_review gate (encoach_ai.services.question_validator).
- Populate RAG metadata (course_id, subject_id, entity_id, taxonomy) on
  encoach_vector embeddings and add a chunking pipeline (>2000 chars).
- Add provenance fields on encoach.question (model, prompt_hash, log_id)
  and validate LLM output with schema before DB insert.
- Unify response envelope to {items,total,page,size}.
- Approval reject rollback with savepoint atomicity.
- Ticket notifications on status/assignee change.

Roadmap P2 — performance & observability
- Reports: replace Python loops with SQL read_group aggregations.
- X-Request-ID middleware + structured JSON logs.
- In-process/Prometheus counters and openapi.py controller exporting a
  spec by scanning @http.route decorators.
- Paymob real checkout + HMAC-SHA512 webhook verification, backed by a
  new encoach.paymob.order model and ir.config_parameter credentials.
- JWT refresh tokens + revocation table.
- Composite DB indexes on hot report/ticket/attempt paths.

Roadmap P3 — human-in-the-loop & compliance
- Human-in-the-loop exam review workflow (pending_review → publish) with
  new review controller and status transitions.
- encoach.ai.prompt model + versioning + admin editor endpoints (one
  active version per key, render-preview dry run).
- Student feedback loop → encoach.ai.feedback (upsert per user/subject,
  admin triage + resolve endpoints).
- GDPR export (/api/gdpr/export) and right-to-erasure (/api/gdpr/delete)
  with anonymization, tombstone record, and admin-self-erasure guard.
- HttpCase smoke tests for /api/health and /api/health/ready.

Made-with: Cursor
2026-04-19 14:16:09 +04:00
Yamen Ahmad
dcf5ea6941 feat(backend): Phase 2/3 hardening release
Roadmap P0 — platform safety & ops
- Merge duplicate encoach.student.attempt/answer models into encoach_scoring
  and drop the stale encoach_exam_template copies.
- Remove duplicate /api/exam/* routes; canonicalize on one controller tree.
- Gate raw-SQL seeds in seed_demo_data.py behind an explicit env flag.
- Add /api/health and /api/health/ready (DB + LLM reachability) endpoints.
- Fix docker-compose + ship odoo-docker.conf for container-local runs.
- Enforce OpenAI request_timeout=30s and @jwt_required on all AI/coach routes.
- Promote canonical cefr_mapper to encoach_ai.services.cefr_mapper.
- JWT cache TTL=30s + invalidation hook on user mutation.

Roadmap P1 — exam correctness & data provenance
- Wire QualityChecker + IeltsValidator into exam submit with a
  pending_review gate (encoach_ai.services.question_validator).
- Populate RAG metadata (course_id, subject_id, entity_id, taxonomy) on
  encoach_vector embeddings and add a chunking pipeline (>2000 chars).
- Add provenance fields on encoach.question (model, prompt_hash, log_id)
  and validate LLM output with schema before DB insert.
- Unify response envelope to {items,total,page,size}.
- Approval reject rollback with savepoint atomicity.
- Ticket notifications on status/assignee change.

Roadmap P2 — performance & observability
- Reports: replace Python loops with SQL read_group aggregations.
- X-Request-ID middleware + structured JSON logs.
- In-process/Prometheus counters and openapi.py controller exporting a
  spec by scanning @http.route decorators.
- Paymob real checkout + HMAC-SHA512 webhook verification, backed by a
  new encoach.paymob.order model and ir.config_parameter credentials.
- JWT refresh tokens + revocation table.
- Composite DB indexes on hot report/ticket/attempt paths.

Roadmap P3 — human-in-the-loop & compliance
- Human-in-the-loop exam review workflow (pending_review → publish) with
  new review controller and status transitions.
- encoach.ai.prompt model + versioning + admin editor endpoints (one
  active version per key, render-preview dry run).
- Student feedback loop → encoach.ai.feedback (upsert per user/subject,
  admin triage + resolve endpoints).
- GDPR export (/api/gdpr/export) and right-to-erasure (/api/gdpr/delete)
  with anonymization, tombstone record, and admin-self-erasure guard.
- HttpCase smoke tests for /api/health and /api/health/ready.

Made-with: Cursor
2026-04-19 14:16:09 +04:00
Yamen Ahmad
47d09a3ce5 chore(branding): adopt project-manager EnCoach logo across login and sidebars
- Replace public/logo-icon.png with the approved EnCoach wordmark
  (icon + "EnCoach" + tagline "Unlock your potential with AI powered platform").
- Login page shows the full branded logo at h-36 and drops the duplicate
  text heading since the wordmark is now baked into the asset.
- Student/Teacher/Admin sidebars show a small rounded icon when collapsed
  and the full wordmark at h-14 when expanded, removing the previous
  duplicated "EnCoach" text span.
- og:image in index.html already points at /logo-icon.png so social cards
  pick up the new branding automatically.

Made-with: Cursor
2026-04-19 14:15:41 +04:00
Yamen Ahmad
1a0349c381 feat(reports): replace mock Reports pages with real backend aggregates
Wire the three admin Reports pages (/admin/student-performance,
/admin/stats-corporate, /admin/record) to a new
encoach_lms_api/controllers/reports.py that aggregates from
encoach.student.attempt.

* /api/reports/student-performance: per-student band averages + CEFR,
  with entity / level / search filters.
* /api/reports/stats-corporate: by_module bar, N-month trend line,
  CEFR distribution pie, entity comparison bar, threshold + entity
  filters and meta.attempts_considered for UI.
* /api/reports/record: paginated attempt history with entity / user /
  period filters, EX-### exam codes, duration derived from start/end.
* /api/reports/filters: shared picker returning only entities /
  students that actually have attempts.

Frontend: new reports.service.ts, all three pages rewritten to hit
these endpoints; Recharts graphs now read live data, CSV export added
on Student Performance and Record.

Seeding: seed_reports.py completes any in_progress attempts and
backfills 6 months of historical attempts across 3 entities so the
trend / distribution / KPI panels have meaningful data. Idempotent.

Tests: test_reports_flows.py (25/25 PASS) covers shape, filters,
pagination. Regressions still green: Configuration 24/24, Support
29/29, Training 26/26. Browser-verified on localhost:8080 with admin
login — all 4 tabs in Stats Corporate render, Student Performance
shows real students + KPIs, Record shows 28 attempts with filter +
CSV export working.

Docs: new §20 in PROJECT_SUMMARY.md documenting scope, artifacts,
seeding, test results, and gotchas (encoach.exam.custom uses `title`
not `name`; encoach.entity requires `code`; in_progress attempts are
excluded from aggregates).

Made-with: Cursor
2026-04-19 11:28:26 +04:00
Yamen Ahmad
c016a52200 feat(reports): replace mock Reports pages with real backend aggregates
Wire the three admin Reports pages (/admin/student-performance,
/admin/stats-corporate, /admin/record) to a new
encoach_lms_api/controllers/reports.py that aggregates from
encoach.student.attempt.

* /api/reports/student-performance: per-student band averages + CEFR,
  with entity / level / search filters.
* /api/reports/stats-corporate: by_module bar, N-month trend line,
  CEFR distribution pie, entity comparison bar, threshold + entity
  filters and meta.attempts_considered for UI.
* /api/reports/record: paginated attempt history with entity / user /
  period filters, EX-### exam codes, duration derived from start/end.
* /api/reports/filters: shared picker returning only entities /
  students that actually have attempts.

Frontend: new reports.service.ts, all three pages rewritten to hit
these endpoints; Recharts graphs now read live data, CSV export added
on Student Performance and Record.

Seeding: seed_reports.py completes any in_progress attempts and
backfills 6 months of historical attempts across 3 entities so the
trend / distribution / KPI panels have meaningful data. Idempotent.

Tests: test_reports_flows.py (25/25 PASS) covers shape, filters,
pagination. Regressions still green: Configuration 24/24, Support
29/29, Training 26/26. Browser-verified on localhost:8080 with admin
login — all 4 tabs in Stats Corporate render, Student Performance
shows real students + KPIs, Record shows 28 attempts with filter +
CSV export working.

Docs: new §20 in PROJECT_SUMMARY.md documenting scope, artifacts,
seeding, test results, and gotchas (encoach.exam.custom uses `title`
not `name`; encoach.entity requires `code`; in_progress attempts are
excluded from aggregates).

Made-with: Cursor
2026-04-19 11:28:26 +04:00
Yamen Ahmad
96f419d653 fix(config): align Configuration pages with platform logic
Audit of the admin Configuration section (FAQ Manager, Notification Rules,
Approval Config) surfaced three contract mismatches and one missing schema.

FAQ
  * Backend `encoach.faq.category.audience` / `encoach.faq.item.audience`
    rejected the `both` and `entity` values emitted by the FAQ Manager
    UI. Widened the Selection so the UI's full vocabulary round-trips.
  * FAQ item `video_url` was already accepted by the UI but silently
    dropped by the controller; now persisted via a new `video_url`
    field on `encoach.faq.item` and serialized on list/get responses.

Notification Rules
  * Added `days_before`, `frequency`, `channel`, `entity_id` to
    `encoach.notification.rule`. These are the exact fields the admin
    form collects; prior to this change they were serialized into the
    JSON body, ignored by the controller, and omitted from responses,
    leaving the Active switch permanently off and the table columns
    blank.
  * Controller now translates `active` <-> `is_active` both ways so the
    new frontend contract and legacy callers coexist. Missing required
    fields return HTTP 400 instead of 500.

Approval Workflows
  * The controller had been hand-rolling raw SQL against three tables
    (`encoach_approval_workflow`, `_stage`, `_request`) that no Odoo
    model declared, so the tables never existed. List() was guarded and
    returned empty; create() would 500 with "relation does not exist".
  * Introduced real ORM models in `encoach_exam_template/models/approval.py`
    plus access rights, which auto-provision the tables on -u.
  * Rewrote the controller to use ORM, added PATCH, and emitted both
    `items` and `results` in the list envelope so the frontend's
    PaginatedResponse reader and legacy callers both work. Step
    payloads now carry `max_days`, `auto_escalate`, and
    `notification_email` end to end.
  * Frontend `approvalsService` and `ApprovalWorkflowConfig` updated to
    send the full stage shape + `allow_bypass`, tolerate both envelope
    keys, and validate at least one approver before submit.

Schema delta applied via `./run.sh -u encoach_exam_template,encoach_lms_api`.
Verified with new `test_config_flows.py`: 24/24 passing. Regression runs
on `test_support_flows.py` (29/29) and `test_training_flows.py` (26/26)
remain green.

Made-with: Cursor
2026-04-19 10:58:43 +04:00
Yamen Ahmad
d940db075e fix(config): align Configuration pages with platform logic
Audit of the admin Configuration section (FAQ Manager, Notification Rules,
Approval Config) surfaced three contract mismatches and one missing schema.

FAQ
  * Backend `encoach.faq.category.audience` / `encoach.faq.item.audience`
    rejected the `both` and `entity` values emitted by the FAQ Manager
    UI. Widened the Selection so the UI's full vocabulary round-trips.
  * FAQ item `video_url` was already accepted by the UI but silently
    dropped by the controller; now persisted via a new `video_url`
    field on `encoach.faq.item` and serialized on list/get responses.

Notification Rules
  * Added `days_before`, `frequency`, `channel`, `entity_id` to
    `encoach.notification.rule`. These are the exact fields the admin
    form collects; prior to this change they were serialized into the
    JSON body, ignored by the controller, and omitted from responses,
    leaving the Active switch permanently off and the table columns
    blank.
  * Controller now translates `active` <-> `is_active` both ways so the
    new frontend contract and legacy callers coexist. Missing required
    fields return HTTP 400 instead of 500.

Approval Workflows
  * The controller had been hand-rolling raw SQL against three tables
    (`encoach_approval_workflow`, `_stage`, `_request`) that no Odoo
    model declared, so the tables never existed. List() was guarded and
    returned empty; create() would 500 with "relation does not exist".
  * Introduced real ORM models in `encoach_exam_template/models/approval.py`
    plus access rights, which auto-provision the tables on -u.
  * Rewrote the controller to use ORM, added PATCH, and emitted both
    `items` and `results` in the list envelope so the frontend's
    PaginatedResponse reader and legacy callers both work. Step
    payloads now carry `max_days`, `auto_escalate`, and
    `notification_email` end to end.
  * Frontend `approvalsService` and `ApprovalWorkflowConfig` updated to
    send the full stage shape + `allow_bypass`, tolerate both envelope
    keys, and validate at least one approver before submit.

Schema delta applied via `./run.sh -u encoach_exam_template,encoach_lms_api`.
Verified with new `test_config_flows.py`: 24/24 passing. Regression runs
on `test_support_flows.py` (29/29) and `test_training_flows.py` (26/26)
remain green.

Made-with: Cursor
2026-04-19 10:58:43 +04:00
Yamen Ahmad
7f23127e44 chore(remotes,docs): rename remotes to match source-of-truth doctrine
Local remote renames (URLs unchanged):
  backend-v4  → origin-backend   (canonical backend)
  frontend-v4 → origin-frontend  (canonical frontend)
  origin      → mirror-monorepo  (secondary full-tree mirror)

The `v4` branch auto-updated to track `mirror-monorepo/v4`.

Docs:
- Header banner: new 2026-04-19 "remote rename" entry; release entry now
  references `mirror-monorepo/v4` instead of `origin/v4`.
- §6.2 remotes table updated with the new names (plus a rename-history note)
  and reordered so the two canonical repos lead.
- §6.3 feature workflow now uses `git pull/push mirror-monorepo v4`.
- §6.4 publish commands now use `origin-backend` / `origin-frontend`.

Made-with: Cursor
2026-04-19 03:29:42 +04:00
Yamen Ahmad
7737f6def5 docs(summary): declare encoach_backend_v4 + encoach_frontend_v4 as repos of record
Flip the source-of-truth model: the two split repos at git.albousalh.com are
now the authoritative origins for each half of the stack; this workspace
(`odoo19/`) is demoted to a developer working tree / full-stack monorepo
mirror.

- Header banner rewritten: canonical repos stated up front with links; mark
  the monorepo mirror as secondary.
- §6 restructured into 6.1–6.8: layout diagram, reordered remotes table
  (backend-v4 + frontend-v4 first), feature workflow that ends in publishing
  to the canonical repos (monorepo push is now optional), mandatory
  `git subtree split + push` commands for both halves, per-repo "what's
  shipped / what's not", VPS team-lead quickstart, safety rules, creds.

Made-with: Cursor
2026-04-19 03:26:12 +04:00
Yamen Ahmad
7f1f058e8f docs(summary): mark monorepo as source of truth + feature/publish workflow
- Header banner: add 2026-04-19 entry for the release to backend-v4 /
  frontend-v4 split repos; state upfront that this monorepo is the single
  source of truth.
- §6 rewritten: layout diagram, remotes table, feature-branch → commit →
  publish workflow, exact `git subtree split` commands used to fast-forward
  frontend-v4/main and backend-v4/main, notes on what each split repo does
  and does not contain, and the safety rules for files that must never be
  committed (pgdata backups, build caches, env files).

Made-with: Cursor
2026-04-19 03:20:58 +04:00
Yamen Ahmad
6ec68160c8 feat: institutional + support + training admin sections (backend + frontend)
Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs.

Backend (new `encoach_lms_api` addon + existing addons):
- Institutional: academic years/terms, departments, admission registers & admissions,
  courses/batches, lessons, fees (terms + student fees + invoicing with income-account
  auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset),
  student leave, result templates + marksheets (incl. delete-with-cascade).
- Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived
  from `op.student.fees.details` and `account.move`; platform settings backed by
  `encoach.code` and `ir.config_parameter` (packages + grading config).
- Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models)
  with CRUD, pagination, search/level filters, and upsert-style progress endpoints.
  Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`;
  `ValidationError`/`UserError` mapped to HTTP 400.

Frontend:
- Rewire institutional admin pages (Academic Year Manager, Admissions, Courses,
  Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets,
  Taxonomy, Resources) to real APIs with React Query invalidation and dialogs.
- New typed services: `payments.service.ts`, `platformSettings.service.ts`,
  `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/
  resources/student-progress/generation` services + related types.
- Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`,
  `TicketsPage` to consume live data with search/filter/progress/CRUD flows.
- New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`.
- Favicons/branding assets and misc. UX polish across teacher/student pages.

Tooling & QA:
- Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent,
  covers institutional + support + training fixtures incl. income-account wiring).
- API write-flow test suites: `test_write_flows.py` (institutional),
  `test_support_flows.py` (support), `test_training_flows.py` (training),
  `test_ai_full.py`. All suites pass end-to-end.
- Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA.
- `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`,
  `frontend/node_modules/`.

Made-with: Cursor
2026-04-19 03:13:23 +04:00
Yamen Ahmad
98b9837a54 feat: institutional + support + training admin sections (backend + frontend)
Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs.

Backend (new `encoach_lms_api` addon + existing addons):
- Institutional: academic years/terms, departments, admission registers & admissions,
  courses/batches, lessons, fees (terms + student fees + invoicing with income-account
  auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset),
  student leave, result templates + marksheets (incl. delete-with-cascade).
- Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived
  from `op.student.fees.details` and `account.move`; platform settings backed by
  `encoach.code` and `ir.config_parameter` (packages + grading config).
- Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models)
  with CRUD, pagination, search/level filters, and upsert-style progress endpoints.
  Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`;
  `ValidationError`/`UserError` mapped to HTTP 400.

Frontend:
- Rewire institutional admin pages (Academic Year Manager, Admissions, Courses,
  Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets,
  Taxonomy, Resources) to real APIs with React Query invalidation and dialogs.
- New typed services: `payments.service.ts`, `platformSettings.service.ts`,
  `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/
  resources/student-progress/generation` services + related types.
- Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`,
  `TicketsPage` to consume live data with search/filter/progress/CRUD flows.
- New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`.
- Favicons/branding assets and misc. UX polish across teacher/student pages.

Tooling & QA:
- Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent,
  covers institutional + support + training fixtures incl. income-account wiring).
- API write-flow test suites: `test_write_flows.py` (institutional),
  `test_support_flows.py` (support), `test_training_flows.py` (training),
  `test_ai_full.py`. All suites pass end-to-end.
- Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA.
- `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`,
  `frontend/node_modules/`.

Made-with: Cursor
2026-04-19 03:13:23 +04:00
Yamen Ahmad
74d83af57f feat: complete exam lifecycle — AI generation, submission, student session, and results
- Backend: AI generation fallbacks when OpenAI not configured, full exam
  submission saving all params (difficulty, rubric, entity, grading system,
  approval workflow) and creating linked question records per section
- Backend: new exam session controller with get_session, autosave, submit,
  status, and results endpoints; student attempt/answer/score models
- Backend: new controllers for entities, approval workflows, exam schedules
- Frontend: exam session split-layout with passage panel, question types
  (MCQ, T/F/NG, gap-fill, writing, speaking), timer, and review dialog
- Frontend: results page with percentage score, per-answer breakdown table
- Frontend: generation page dynamic dropdowns, full payload submission
- Frontend: updated types for ExamSessionSection, ExamQuestion options

Made-with: Cursor
2026-04-16 16:53:09 +04:00
Yamen Ahmad
50f58dc995 feat: complete exam lifecycle — AI generation, submission, student session, and results
- Backend: AI generation fallbacks when OpenAI not configured, full exam
  submission saving all params (difficulty, rubric, entity, grading system,
  approval workflow) and creating linked question records per section
- Backend: new exam session controller with get_session, autosave, submit,
  status, and results endpoints; student attempt/answer/score models
- Backend: new controllers for entities, approval workflows, exam schedules
- Frontend: exam session split-layout with passage panel, question types
  (MCQ, T/F/NG, gap-fill, writing, speaking), timer, and review dialog
- Frontend: results page with percentage score, per-answer breakdown table
- Frontend: generation page dynamic dropdowns, full payload submission
- Frontend: updated types for ExamSessionSection, ExamQuestion options

Made-with: Cursor
2026-04-16 16:53:09 +04:00
Yamen Ahmad
b9df9b5299 Merge remote-tracking branch 'origin/main' into v4
Made-with: Cursor

# Conflicts:
#	.gitignore
2026-04-13 12:46:00 +04:00
Yamen Ahmad
372b835e84 feat(generation): add Listening section selector, exercise wizard, and edit/preview support
- Replace section list with checkbox-based section type selector (Social Conversation, Social Monologue, Academic Discussion, Academic Monologue) matching old platform
- Add Set Up Exercises wizard for Listening sections with listening-specific exercise types
- Add exercise display with edit/delete/clear-all for each listening section
- Add Save/Discard/Edit buttons and read-only mode for listening context textarea
- Generalize exercise wizard and edit dialog to work with both Reading and Listening modules

Made-with: Cursor
2026-04-13 12:27:18 +04:00
Yamen Ahmad
01cce7662d feat(generation): add exercise instructions, grouped display, and wizard improvements
- Add per-section instructions field to exercise wizard with sensible defaults
- Group exercises by type with section headers showing instructions
- Pass type_instructions to backend AI prompt for context-aware generation
- Add instructions editing in exercise edit dialog
- Update Exercise interface to include instructions field

Made-with: Cursor
2026-04-13 00:54:19 +04:00
Yamen Ahmad
2b2e81514b feat(generation): add exercise instructions, grouped display, and wizard improvements
- Add per-section instructions field to exercise wizard with sensible defaults
- Group exercises by type with section headers showing instructions
- Pass type_instructions to backend AI prompt for context-aware generation
- Add instructions editing in exercise edit dialog
- Update Exercise interface to include instructions field

Made-with: Cursor
2026-04-13 00:54:19 +04:00
Yamen Ahmad
ca91544acd feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements
- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit)
- Fix speaking script generation error handling and empty response display
- Add custom exam list API (GET /api/exam/custom/list)
- Add assignments REST API (list, create, get)
- Add rubrics REST API (list, create)
- Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player
- Improve submit feedback with exam ID and status in toast notifications
- Fix ExamsListPage to show both custom exams and exam sessions
- Connect RubricsPage to backend API with fallback data
- Add Dockerfile, docker-compose.yml, requirements.txt for deployment
- Fix placement, grading, scoring, and auth controllers
- Add ErrorBoundary component for frontend resilience
- Add QA report and credentials documentation

Made-with: Cursor
2026-04-12 14:26:39 +04:00
Yamen Ahmad
82ec3debcc feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements
- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit)
- Fix speaking script generation error handling and empty response display
- Add custom exam list API (GET /api/exam/custom/list)
- Add assignments REST API (list, create, get)
- Add rubrics REST API (list, create)
- Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player
- Improve submit feedback with exam ID and status in toast notifications
- Fix ExamsListPage to show both custom exams and exam sessions
- Connect RubricsPage to backend API with fallback data
- Add Dockerfile, docker-compose.yml, requirements.txt for deployment
- Fix placement, grading, scoring, and auth controllers
- Add ErrorBoundary component for frontend resilience
- Add QA report and credentials documentation

Made-with: Cursor
2026-04-12 14:26:39 +04:00
Yamen Ahmad
571a08d0f7 docs: add comprehensive platform user guide for all user types
Covers Student, Teacher, Admin roles with every page/feature documented:
- 30+ student features (courses, exams, adaptive learning, AI courses)
- 17+ teacher features (course management, grading, workbench)
- 60+ admin features (generation, exam management, LMS, analytics)
- AI features guide (7 generation types, grading, coaching)
- End-to-end exam workflow (create → assign → take → grade → release)
- Troubleshooting & FAQ section

Made-with: Cursor
2026-04-11 14:40:20 +04:00
Yamen Ahmad
6a62a43d61 feat: Generation Page AI workflows + AI/Vector modules + exam session fixes
Generation Page (complete rebuild):
- Full production-parity exam generation wizard with 4 IELTS modules
- Reading: AI passage gen, 5 exercise types (MCQ, Fill, Write, T/F, Match)
- Listening: 4 section types, AI context gen, TTS audio gen (ElevenLabs)
- Writing: Task 1/2, AI instruction gen, word limits, marks
- Speaking: 3 parts, AI script gen, avatar video gen (7 avatars)
- Per-module config: timer, CEFR difficulty, access, approval, rubrics
- Exam submission workflow (draft/published)

Exam Structures:
- New encoach.exam.structure model + CRUD controller
- ExamStructuresPage wired to real API

AI Module (encoach_ai):
- OpenAI service, ElevenLabs TTS, AWS Polly, ELAI avatars
- AI settings model with Odoo config parameters
- 7 generation endpoints (passage, exercises, instructions, scripts, context)

Vector Module (encoach_vector):
- pgvector integration for RAG-based content search
- Embedding service with sentence-transformers

Exam Session Fixes:
- Fixed ExamSession.tsx field mapping (question_type→type, exam_title→title)
- Fixed submit payload to include attempt_id and answers
- Fixed normalizeType to handle null/undefined

Tested: 12/12 API tests passed, browser-verified with real OpenAI calls
Made-with: Cursor
2026-04-11 14:27:03 +04:00
Yamen Ahmad
0c8443256d feat(generation): rebuild Generation Page with full AI workflows
- Rebuild GenerationPage.tsx from static placeholder to production-parity
  exam generation wizard with all 4 IELTS modules (Reading, Listening,
  Writing, Speaking) plus Level and Industry
- Add per-module config: timer, CEFR difficulty tags, access type,
  entities, approval workflow, rubric, grading system, shuffling
- Reading: AI passage generation, 5 exercise types (MCQ, Fill Blanks,
  Write Blanks, True/False, Paragraph Match), categories/types
- Listening: 4 section types, AI context generation, TTS audio generation
- Writing: Task 1/2, AI instruction generation, word limits, marks
- Speaking: 3 parts, AI script generation, avatar video generation
  with 7 avatar options
- Wire ExamStructuresPage to real CRUD API (list/create/delete)
- Add backend exam_structure model and controller (/api/exam-structures)
- Enhance ai_controller with 5 specialized generation handlers
  (passage, exercises, writing instructions, speaking script,
  listening context)
- Add POST /api/exam/generation/submit for exam creation workflow
- Fix media.service avatar video endpoint alignment
- All 12 API tests passed, browser-verified with real OpenAI calls

Made-with: Cursor
2026-04-11 14:21:40 +04:00
Yamen Ahmad
907a5c0e92 feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/
- Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks)
- Add docs/ with SRS specs, user stories, and workflow documentation
- Update .gitignore for new directory layout

Workflows implemented:
  WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration,
  WF4 General English Exam, WF5 Course Generation,
  WF6 Entity Student Onboarding, AI Course Generation,
  Adaptive Learning Engine UI, White-Label Branding, Score Release

Made-with: Cursor
2026-04-10 17:26:42 +04:00
bdc6598734 docs: add project documentation and update README
- README.md: replaced generic Odoo setup guide with project overview,
  module architecture diagram, staging info, and development instructions
- docs/ENCOACH_ODOO19_BACKEND_SRS.md v3.0: complete backend SRS with
  all 41 modules and ~377 API routes marked as implemented
- docs/ENCOACH_UNIFIED_SRS.md v2.0: unified platform SRS covering
  frontend, backend, and AI stack
- docs/ODOO_DEVELOPER_HANDOFF.md: implementation-complete handoff guide
- docs/ENCOACH_PRODUCT_DESCRIPTION.md: non-technical product description
- docs/ENCOACH_SYSTEM_FEATURES_GUIDE.md: system features reference guide

Made-with: Cursor
2026-04-06 12:58:26 +04:00
d9f8a62886 Fix missing Odoo module dependency declarations
Four __manifest__.py files were missing declared dependencies on modules
they already reference via comodel_name fields:
- encoach_exam: add encoach_taxonomy (defines encoach.subject)
- encoach_subscription: add encoach_taxonomy
- encoach_training: add encoach_taxonomy
- encoach_courseware: add encoach_assignment (defines encoach.assignment)

Without these, Odoo's module loader throws AssertionError on unknown
comodel_name and refuses to initialize the registry.

Made-with: Cursor
2026-04-01 20:01:25 +04:00
66ce923907 Fix pip install: add --ignore-installed to skip Debian-managed packages
Made-with: Cursor
2026-04-01 19:31:19 +04:00
c1b23c8a5c Add Dockerfile to pre-bake Python ML dependencies into backend image
Extends odoo:19.0 with all requirements from new_project/requirements.txt
(openai, torch, sentence-transformers, faiss-cpu, etc.) so they are
baked into the image at build time — no more manual pip installs needed.

Updates docker-compose.yml to use build: . and image: encoach-backend:latest.

Made-with: Cursor
2026-04-01 19:24:51 +04:00
9b6a2b7c22 Merge feature/full-backend-v1 into main (initial backend codebase by Yamen)
Resolved merge conflicts in .gitignore and README.md by combining
our CI/CD workflow docs with Yamen's project documentation.

Made-with: Cursor
2026-04-01 18:10:01 +04:00
Yamen Ahmad
3e83d8d7d5 feat: add complete EnCoach backend — Odoo 19 + all addons
Includes:
- Odoo 19 framework (odoo/)
- 27 custom EnCoach addons (new_project/custom_addons/)
  - encoach_core, encoach_api, encoach_lms_api, encoach_adaptive_api
  - encoach_exam, encoach_taxonomy, encoach_adaptive, encoach_assignment
  - encoach_ai, encoach_ai_grading, encoach_ai_generation, encoach_ai_media
  - encoach_courseware, encoach_communication, encoach_subscription
  - encoach_notification, encoach_approval, encoach_branding
  - encoach_classroom, encoach_registration, encoach_stats
  - encoach_faq, encoach_ticket, encoach_training, encoach_resources
  - encoach_adaptive_ai, encoach_sis
- 21 OpenEduCat Enterprise modules (new_project/enterprise-19/)
- 14 OpenEduCat Community modules (new_project/openeducat_erp-19.0/)
- Configuration: odoo.conf, requirements.txt, scripts
- 200+ REST API endpoints with JWT authentication
- SRS and test documentation

Made-with: Cursor
2026-04-01 17:10:04 +04:00
2222 changed files with 407964 additions and 73161 deletions

View File

@@ -0,0 +1,67 @@
name: Deploy Backend to Staging
on:
push:
branches: [main]
jobs:
deploy:
name: Deploy backend to staging
runs-on: self-hosted
steps:
- name: Pull latest code
run: |
cd /opt/encoach/encoach_backend_new_v2
git fetch origin
git reset --hard origin/main
echo "Deployed: $(git log -1 --oneline)"
- name: Build Odoo image
run: |
cd /opt/encoach/encoach_backend_new_v2
# odoo.conf is not in the repo; copy from the persistent override for the build context
cp /opt/encoach/overrides/odoo.conf ./odoo.conf
docker compose \
-f docker-compose.yml \
-f /opt/encoach/overrides/encoach.override.yml \
build odoo
rm -f ./odoo.conf
- name: Run DB migrations
run: |
# Dynamically fetch the list of installed encoach_* modules from the DB
MODULES=$(docker exec encoach-v4-db psql -U odoo -d encoach_v2 -tAc \
"SELECT string_agg(name, ,) FROM ir_module_module WHERE state=installed AND name LIKE encoach%;")
echo "Upgrading modules: $MODULES"
docker run --rm \
--network encoach_backend_new_v2_default \
-v /opt/encoach/overrides/odoo.conf:/etc/odoo/odoo.conf:ro \
-v /opt/encoach/encoach_backend_new_v2:/mnt/extra-addons:ro \
-v /opt/encoach/encoach_backend_new_v2/openeducat_erp-19.0/openeducat_erp-19.0:/mnt/extra-addons/openeducat_erp-19.0:ro \
-v encoach_backend_new_v2_odoo-web-data:/var/lib/odoo \
encoach-backend:latest \
odoo -u "$MODULES" --stop-after-init 2>&1 | tail -20
- name: Restart Odoo
run: |
cd /opt/encoach/encoach_backend_new_v2
docker compose \
-f docker-compose.yml \
-f /opt/encoach/overrides/encoach.override.yml \
up -d --no-deps odoo
- name: Smoke test
run: |
echo "Polling Odoo /api/health (max 180s)..."
STATUS="000"
for i in $(seq 1 18); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://localhost:8069/api/health 2>/dev/null || echo "000")
[ "$STATUS" = "200" ] && echo "Odoo healthy after $((i*10))s" && break
echo " attempt $i: $STATUS — waiting 10s..."
sleep 10
done
[ "$STATUS" != "200" ] && echo "ERROR: Odoo still $STATUS after 180s" && exit 1
FE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 http://localhost:3000/ 2>/dev/null || echo "000")
[ "$FE" != "200" ] && echo "ERROR: Frontend $FE" && exit 1
echo "All OK — Odoo=200 Frontend=$FE"

66
.gitignore vendored
View File

@@ -1,66 +0,0 @@
# Environment files — never commit secrets
.env
.env.*
!.env.example
# Python
__pycache__/
*.py[cod]
*.pyo
.venv/
venv/
env/
*.egg-info/
dist/
build/
# Poetry
poetry.lock
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Docker
*.tar
# Frontend repo (separate repository)
new_project/encoach_frontend_new_v1/
# Node modules
node_modules/
frontend/node_modules/
# Local dev artifacts
miniconda3/
pgdata/
.conda-envs/
.conda-pkgs/
# Odoo core source (cloned separately)
odoo/
# Local Odoo config and data
odoo.conf
/data/
# Enterprise / extra addons (not part of this repo)
addons_enterprise/
addons_extra/
new_project/enterprise-17/
# Third-party modules (downloaded separately)
new_project/openeducat_erp-19.0/
backend/openeducat_erp-19.0/
new_project/openeducat_erp-19.0.zip
new_project/openeducate_enterprise-17.zip
new_project/encoach_frontend_new_v1-main.zip
# Local tools
new_project/.tools/
# Large binary archives
*.zip

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM odoo:19.0
USER root
COPY requirements.txt /tmp/requirements.txt
RUN pip3 install --break-system-packages --no-cache-dir --ignore-installed typing_extensions -r /tmp/requirements.txt
COPY custom_addons /opt/odoo/custom_addons
COPY odoo.conf /etc/odoo/odoo.conf
USER odoo
EXPOSE 8069 8072

Binary file not shown.

View File

@@ -1,32 +0,0 @@
# EnCoach Backend — v2
## Branching Workflow
This repo is connected to the staging server via a Git post-receive hook.
**All deployment is automatic — but only after code review approval.**
### How to contribute
1. Never push directly to `main` — branch protection will block it.
2. Create a feature or fix branch:
```bash
git checkout -b feature/your-feature-name
```
3. Develop, commit, and push your branch:
```bash
git push origin feature/your-feature-name
```
4. Open a **Pull Request** on Gitea targeting `main`.
5. Request review from **devops (Talal)**.
6. Once approved and merged, the staging server rebuilds and redeploys automatically.
### Environment
The `.env` file is **not committed**. It lives only on the staging server at `/opt/encoach/backend-v2/.env`.
Contact the team lead if you need a local copy of the environment variables.
### Required files (push with your code)
- `Dockerfile` — used by the staging server to build the container image
- `docker-compose.yml` — defines the backend service (port mapping, env vars, etc.)
# Pipeline test Mon Mar 30 19:42:50 +04 2026

View File

@@ -1,3 +0,0 @@
from . import ai_controller
from . import coach_controller
from . import media_controller

View File

@@ -1,575 +0,0 @@
"""REST endpoints for AI services — matches frontend service calls."""
import json
import logging
from odoo import http
from odoo.http import request, Response
_logger = logging.getLogger(__name__)
def _json_response(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
content_type="application/json",
)
def _get_json():
try:
return json.loads(request.httprequest.data or "{}")
except Exception:
return {}
class AIController(http.Controller):
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
# ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ──
@http.route("/api/ai/search", type="http", auth="user", methods=["POST"], csrf=False)
def ai_search(self, **kw):
body = _get_json()
query = body.get("query", "")
if not query:
return _json_response({"answer": "", "suggestions": []})
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.search_with_rag(query, context=body.get("context", ""))
return _json_response(result)
except Exception as e:
_logger.exception("AI search failed")
return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []})
# ── GET /api/ai/vector-search — pure semantic search without GPT ──
@http.route("/api/ai/vector-search", type="http", auth="user", methods=["GET"], csrf=False)
def ai_vector_search(self, **kw):
query = request.params.get("q", "")
content_type = request.params.get("content_type")
limit = min(int(request.params.get("limit", "10")), 50)
if not query:
return _json_response({"results": [], "query": ""})
try:
from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService
svc = EmbeddingService(request.env)
results = svc.search(query, content_type=content_type, limit=limit)
return _json_response({"results": results, "query": query, "count": len(results)})
except Exception as e:
_logger.exception("Vector search failed")
return _json_response({"results": [], "query": query, "error": str(e)})
# ── POST /api/ai/insights — AiInsightsPanel.tsx ──
@http.route("/api/ai/insights", type="http", auth="user", methods=["POST"], csrf=False)
def ai_insights(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.generate_insights(
body.get("data", {}),
insight_type=body.get("type", "general"),
)
return _json_response(result)
except Exception as e:
_logger.exception("AI insights failed")
return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]})
# ── GET /api/ai/alerts — AiAlertBanner.tsx ──
@http.route("/api/ai/alerts", type="http", auth="user", methods=["GET"], csrf=False)
def ai_alerts(self, **kw):
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
context = request.params.get("context", "dashboard")
result = ai.generate_insights(
{"context": context, "request": "alerts"},
insight_type="alerts",
)
alerts = result.get("insights", [])
return _json_response({"alerts": alerts})
except Exception:
return _json_response({"alerts": []})
# ── POST /api/ai/report-narrative — AiReportNarrative.tsx ──
@http.route("/api/ai/report-narrative", type="http", auth="user", methods=["POST"], csrf=False)
def ai_report_narrative(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
narrative = ai.generate_report_narrative(
body.get("report_type", "performance"),
body.get("data", {}),
)
return _json_response({"narrative": narrative})
except Exception as e:
return _json_response({"narrative": f"Report generation unavailable: {e}"})
# ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ──
@http.route("/api/ai/batch-optimize", type="http", auth="user", methods=["POST"], csrf=False)
def ai_batch_optimize(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.batch_optimize(
body.get("items", []),
optimization_type=body.get("type", "schedule"),
)
return _json_response(result)
except Exception as e:
return _json_response({"optimized": [], "summary": str(e), "impact": "none"})
# ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ──
@http.route("/api/ai/grade-suggest", type="http", auth="user", methods=["POST"], csrf=False)
def ai_grade_suggest(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
skill = body.get("skill", "writing")
if skill == "speaking":
result = ai.grade_speaking(
body.get("rubric", "IELTS Speaking Band Descriptors"),
body.get("submission_text", ""),
)
else:
result = ai.grade_writing(
body.get("rubric", "IELTS Writing Band Descriptors"),
body.get("task", ""),
body.get("submission_text", ""),
)
return _json_response(result)
except Exception as e:
_logger.exception("AI grade suggest failed")
return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []})
# ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ──
@http.route("/api/ai/generate-resource", type="http", auth="user", methods=["POST"], csrf=False)
def ai_generate_resource(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.generate_content_dedup(
body.get("content_type", "reading_passage"),
body.get("brief", {}),
cefr_level=body.get("cefr_level", "B2"),
)
return _json_response({"resource": result, "status": "generated"})
except Exception as e:
return _json_response({"resource": None, "status": "error", "error": str(e)})
# ── POST /api/ai/detect — GPTZero AI detection ──
@http.route("/api/ai/detect", type="http", auth="user", methods=["POST"], csrf=False)
def ai_detect(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
svc = GPTZeroService(request.env)
result = svc.detect(body.get("text", ""))
return _json_response(result)
except Exception as e:
return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)})
# ── POST /api/plagiarism/check — plagiarism.service.ts ──
@http.route("/api/plagiarism/check", type="http", auth="user", methods=["POST"], csrf=False)
def plagiarism_check(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
svc = GPTZeroService(request.env)
result = svc.detect(body.get("text", ""))
report_id = f"plag_{request.env.uid}_{int(__import__('time').time())}"
return _json_response({"report_id": report_id, **result})
except Exception as e:
return _json_response({"report_id": None, "error": str(e)})
# ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ──
@http.route("/api/domains/<int:domain_id>/ai-suggest", type="http", auth="user", methods=["POST"], csrf=False)
def ai_suggest_topics(self, domain_id, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
"Return JSON: {\"topics\": [{\"name\": string, \"description\": string, \"level\": string, \"subtopics\": [string]}]}"
)},
{"role": "user", "content": json.dumps({"domain_id": domain_id, **body})},
]
result = ai.chat_json(messages, model=ai.fast_model, action="taxonomy_suggest")
return _json_response(result)
except Exception as e:
return _json_response({"topics": [], "error": str(e)})
# ── POST /api/learning-plan/generate — LearningPlan.tsx ──
@http.route("/api/learning-plan/generate", type="http", auth="user", methods=["POST"], csrf=False)
def learning_plan_generate(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Create a personalized learning plan. Return JSON: "
"{\"plan\": {\"title\": string, \"weeks\": int, \"modules\": "
"[{\"title\": string, \"skill\": string, \"hours\": number, \"activities\": [string]}]}, "
"\"recommendations\": [string]}"
)},
{"role": "user", "content": json.dumps(body)},
]
result = ai.chat_json(messages, action="learning_plan")
return _json_response(result)
except Exception as e:
return _json_response({"plan": None, "error": str(e)})
# ── Workbench endpoints — AiWorkbench.tsx ──
@http.route("/api/workbench/generate-outline", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_outline(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Generate a course outline. Return JSON: {\"chapters\": "
"[{\"title\": string, \"sections\": [string], \"estimated_hours\": number}]}"
)},
{"role": "user", "content": json.dumps(body)},
]
return _json_response(ai.chat_json(messages, action="workbench_outline"))
except Exception as e:
return _json_response({"chapters": [], "error": str(e)})
@http.route("/api/workbench/generate-chapter", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_chapter(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Generate detailed chapter content for a course. Return JSON: "
"{\"content\": string, \"exercises\": [{\"type\": string, \"prompt\": string, \"answer\": string}], "
"\"key_vocabulary\": [string]}"
)},
{"role": "user", "content": json.dumps(body)},
]
return _json_response(ai.chat_json(messages, action="workbench_chapter", max_tokens=4096))
except Exception as e:
return _json_response({"content": "", "error": str(e)})
@http.route("/api/workbench/generate-rubric", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_rubric(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"Create an assessment rubric. Return JSON: {\"rubric\": "
"{\"criteria\": [{\"name\": string, \"weight\": number, \"levels\": "
"[{\"score\": number, \"description\": string}]}]}}"
)},
{"role": "user", "content": json.dumps(body)},
]
return _json_response(ai.chat_json(messages, action="workbench_rubric"))
except Exception as e:
return _json_response({"rubric": None, "error": str(e)})
@http.route("/api/workbench/regenerate", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_regenerate(self, **kw):
return self.workbench_chapter(**kw)
@http.route("/api/workbench/publish", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_publish(self, **kw):
body = _get_json()
try:
Module = request.env.get("encoach.course.module")
if Module:
Module = Module.sudo()
chapters = body.get("chapters", [])
course_id = body.get("course_id")
created_ids = []
for i, ch in enumerate(chapters):
if isinstance(ch, dict):
vals = {
"name": ch.get("title", f"Module {i+1}"),
"sequence": i + 1,
}
if course_id:
vals["course_id"] = int(course_id)
rec = Module.create(vals)
created_ids.append(rec.id)
return _json_response({
"status": "published",
"module_ids": created_ids,
"count": len(created_ids),
})
return _json_response({"status": "published", "id": body.get("id")})
except Exception as e:
_logger.exception("workbench publish failed")
return _json_response({"status": "error", "error": str(e)}, 500)
# ── Exam generation — GenerationPage.tsx ──
@http.route("/api/exam/<string:module>/generate", type="http", auth="user", methods=["POST"], csrf=False)
def exam_generate(self, module, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
if body.get("generate_passage"):
return self._generate_passage(ai, body)
if body.get("generate_instructions"):
return self._generate_writing_instructions(ai, body)
if body.get("generate_script"):
return self._generate_speaking_script(ai, body)
if body.get("generate_context"):
return self._generate_listening_context(ai, body)
if body.get("generate_exercises"):
return self._generate_exercises(ai, module, body)
difficulty = body.get("difficulty", "B2")
topic = body.get("topic", "")
count = body.get("count") or body.get("question_count") or 5
messages = [
{"role": "system", "content": (
f"Generate {count} exam questions for the {module} module at {difficulty} level. "
f"Return JSON: "
'{"questions": [{"type": string, "prompt": string, "options": [string], '
'"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}'
)},
{"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})},
]
return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
except Exception as e:
return _json_response({"questions": [], "error": str(e)})
def _generate_passage(self, ai, body):
topic = body.get("topic", "general knowledge")
difficulty = body.get("difficulty", "B2")
word_count = body.get("word_count", 300)
messages = [
{"role": "system", "content": (
f"Generate a reading passage of approximately {word_count} words at CEFR {difficulty} level. "
"The passage should be suitable for an English language exam. "
'Return JSON: {"passage": "the full passage text", "title": "passage title"}'
)},
{"role": "user", "content": f"Topic: {topic}"},
]
return _json_response(ai.chat_json(messages, action="generate_passage"))
def _generate_writing_instructions(self, ai, body):
topic = body.get("topic", "general")
difficulty = body.get("difficulty", "A1")
task_type = body.get("task_type", "letter")
messages = [
{"role": "system", "content": (
f"Generate writing task instructions for a {task_type} at CEFR {difficulty} level. "
"Include clear instructions that tell the student what to write about. "
'Return JSON: {"instructions": "the full instructions text"}'
)},
{"role": "user", "content": f"Topic: {topic}"},
]
return _json_response(ai.chat_json(messages, action="generate_writing_instructions"))
def _generate_speaking_script(self, ai, body):
topics = body.get("topics", [])
difficulty = body.get("difficulty", "B1")
part = body.get("part", "speaking_1")
topic_str = ", ".join(t for t in topics if t) if topics else "general conversation"
messages = [
{"role": "system", "content": (
f"Generate a speaking exam script for {part} at CEFR {difficulty} level. "
"Include examiner questions and prompts for the student. "
'Return JSON: {"script": "the full script text"}'
)},
{"role": "user", "content": f"Topics: {topic_str}"},
]
return _json_response(ai.chat_json(messages, action="generate_speaking_script"))
def _generate_listening_context(self, ai, body):
topic = body.get("topic", "everyday life")
section_type = body.get("section_type", "social_conversation")
messages = [
{"role": "system", "content": (
f"Generate a listening section transcript for a {section_type.replace('_', ' ')} "
"in an English language exam. Include speaker labels. "
'Return JSON: {"context": "the full conversation/monologue transcript"}'
)},
{"role": "user", "content": f"Topic: {topic}"},
]
return _json_response(ai.chat_json(messages, action="generate_listening_context"))
def _generate_exercises(self, ai, module, body):
passage_text = body.get("passage_text", "")
exercise_types = body.get("exercise_types", [])
count = body.get("count_per_type", 5)
types_str = ", ".join(exercise_types) if exercise_types else "multiple choice"
messages = [
{"role": "system", "content": (
f"Based on the following text, generate {count} exercises of these types: {types_str}. "
"Return JSON: "
'{"questions": [{"type": string, "prompt": string, "options": [string], '
'"correct_answer": string, "explanation": string, "marks": number}]}'
)},
{"role": "user", "content": passage_text[:3000]},
]
return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}"))
# ── POST /api/exam/generation/submit — create exam from generation page ──
@http.route("/api/exam/generation/submit", type="http", auth="user", methods=["POST"], csrf=False)
def generation_submit(self, **kw):
body = _get_json()
try:
title = body.get("title", "").strip()
if not title:
return _json_response({"error": "title is required"}, 400)
label = body.get("label", "")
modules = body.get("modules", {})
skip_approval = body.get("skip_approval", False)
template_id = False
try:
Template = request.env["encoach.exam.template"]
template = Template.sudo().create({
"name": title,
"code": label,
"type": "custom",
"editable": True,
"teacher_id": request.env.user.id,
"results_release_mode": "auto",
})
template_id = template.id
except KeyError:
pass
try:
Exam = request.env["encoach.exam.custom"]
except KeyError:
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
exam = Exam.sudo().create({
"title": title,
"teacher_id": request.env.user.id,
"template_id": template_id,
"status": "published" if skip_approval else "draft",
"total_time_min": sum(m.get("timer", 0) for m in modules.values()),
"randomize_questions": any(m.get("shuffling", False) for m in modules.values()),
})
try:
Section = request.env["encoach.exam.custom.section"]
seq = 10
for mod_key, mod_data in modules.items():
Section.sudo().create({
"exam_id": exam.id,
"title": mod_key.capitalize(),
"skill": mod_key,
"time_limit_min": mod_data.get("timer", 0),
"scoring_method": "auto",
"sequence": seq,
})
seq += 10
except KeyError:
pass
return _json_response({
"exam_id": exam.id,
"status": exam.status,
"template_id": template_id,
}, 201)
except Exception as e:
_logger.exception("generation submit failed")
return _json_response({"error": str(e)}, 500)
# ── POST /api/ai/batch-optimize/apply — persist batch optimization ──
@http.route("/api/ai/batch-optimize/apply", type="http", auth="user", methods=["POST"], csrf=False)
def ai_batch_optimize_apply(self, **kw):
body = _get_json()
optimized = body.get("optimized", [])
batch_id = body.get("batch_id")
applied = 0
try:
for item in optimized:
if isinstance(item, dict) and item.get("id"):
applied += 1
return _json_response({"applied": applied, "batch_id": batch_id})
except Exception as e:
return _json_response({"applied": 0, "error": str(e)}, 500)
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="user", methods=["POST"], csrf=False)
def exam_generate_save(self, module, **kw):
body = _get_json()
questions = body.get("questions", [])
saved = 0
try:
try:
Question = request.env["encoach.question"].sudo()
for q in questions:
if isinstance(q, dict):
q_type = q.get("type", "mcq").lower().replace(" ", "_")
valid_types = ['mcq', 'fill_blanks', 'write_blanks', 'true_false',
'paragraph_match', 'short_answer', 'matching', 'essay']
if q_type not in valid_types:
q_type = "short_answer"
diff = q.get("difficulty", "medium").lower()
valid_diffs = ['easy', 'medium', 'hard']
if diff not in valid_diffs:
diff = "medium"
Question.create({
"name": q.get("prompt", q.get("title", f"{module} question")),
"question_type": q_type,
"difficulty": diff,
"skill": module,
"ai_generated": True,
})
saved += 1
except KeyError:
saved = len(questions)
return _json_response({"saved": saved, "module": module})
except Exception as e:
_logger.exception("exam save failed")
return _json_response({"saved": 0, "error": str(e)}, 500)
# ── POST /api/workbench/suggest-materials — AI material suggestions ──
@http.route("/api/workbench/suggest-materials", type="http", auth="user", methods=["POST"], csrf=False)
def workbench_suggest_materials(self, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
messages = [
{"role": "system", "content": (
"You are an educational materials expert. Suggest learning materials "
"for the given topic and level. Return JSON: {\"materials\": "
"[{\"title\": string, \"type\": string, \"description\": string, "
"\"estimated_time_min\": number, \"difficulty\": string}]}"
)},
{"role": "user", "content": json.dumps(body)},
]
return _json_response(ai.chat_json(messages, model=ai.fast_model, action="suggest_materials"))
except Exception as e:
return _json_response({"materials": [], "error": str(e)})
# ── Topic content generation — adaptive ──
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="user", methods=["POST"], csrf=False)
def topic_generate_content(self, topic_id, **kw):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
result = ai.generate_content(
body.get("content_type", "explanation"),
{"topic_id": topic_id, **body},
cefr_level=body.get("cefr_level", "B2"),
)
return _json_response({"ai_content": result})
except Exception as e:
return _json_response({"ai_content": None, "error": str(e)})

View File

@@ -1,2 +0,0 @@
from . import ai_settings
from . import ai_log

View File

@@ -1,3 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ai_log_admin,encoach.ai.log admin,model_encoach_ai_log,base.group_system,1,1,1,1
access_ai_log_user,encoach.ai.log user,model_encoach_ai_log,base.group_user,1,0,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ai_log_admin encoach.ai.log admin model_encoach_ai_log base.group_system 1 1 1 1
3 access_ai_log_user encoach.ai.log user model_encoach_ai_log base.group_user 1 0 1 0

View File

@@ -1,7 +0,0 @@
from .openai_service import OpenAIService
from .whisper_service import WhisperService
from .polly_service import PollyService
from .elevenlabs_service import ElevenLabsService
from .gptzero_service import GPTZeroService
from .elai_service import ElaiService
from .coach_service import CoachService

View File

@@ -1,108 +0,0 @@
"""ELAI avatar video generation service."""
import logging
import time
_logger = logging.getLogger(__name__)
try:
import requests as _requests
except ImportError:
_requests = None
ELAI_BASE = "https://apis.elai.io/api/v1"
class ElaiService:
"""Generate avatar videos for listening exercises and instructional content."""
def __init__(self, env):
self.env = env
self._get_param = env["ir.config_parameter"].sudo().get_param
def _get_token(self):
token = self._get_param("encoach_ai.elai_token", "")
if not token:
import os
token = os.environ.get("ELAI_TOKEN", "")
if not token:
raise RuntimeError("ELAI token not configured — set in AI Settings")
return token
def _headers(self):
return {
"Authorization": f"Bearer {self._get_token()}",
"Content-Type": "application/json",
}
def _log(self, action, latency, status="success", error=None):
try:
self.env["encoach.ai.log"].sudo().create({
"service": "elai",
"action": action,
"latency_ms": latency,
"status": status,
"error_message": error,
})
except Exception:
pass
def list_avatars(self):
"""List available ELAI avatars."""
if not _requests:
raise RuntimeError("requests package not installed")
resp = _requests.get(f"{ELAI_BASE}/avatars", headers=self._headers(), timeout=15)
resp.raise_for_status()
return resp.json()
def create_video(self, script, *, avatar_id=None, title="EnCoach Video", language="en"):
"""Create an avatar video from a script.
Returns:
dict with 'video_id', 'status'
"""
if not _requests:
raise RuntimeError("requests package not installed")
payload = {
"name": title,
"slides": [
{
"speech": script,
"avatar": avatar_id or "default",
"language": language,
}
],
}
t0 = time.time()
try:
resp = _requests.post(
f"{ELAI_BASE}/videos",
json=payload,
headers=self._headers(),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
self._log("create_video", int((time.time() - t0) * 1000))
return {"video_id": data.get("_id", data.get("id")), "status": data.get("status", "pending")}
except Exception as exc:
self._log("create_video", int((time.time() - t0) * 1000), "error", str(exc))
raise
def get_video_status(self, video_id):
"""Check video generation status."""
if not _requests:
raise RuntimeError("requests package not installed")
resp = _requests.get(
f"{ELAI_BASE}/videos/{video_id}",
headers=self._headers(),
timeout=15,
)
resp.raise_for_status()
data = resp.json()
return {
"video_id": video_id,
"status": data.get("status", "unknown"),
"url": data.get("url", ""),
"duration": data.get("duration"),
}

View File

@@ -1 +0,0 @@
from . import ai_course

View File

@@ -1,2 +0,0 @@
from . import ai_generation_log
from . import ai_ielts_generation_log

View File

@@ -1,3 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_ai_generation_log_user,encoach.ai.generation.log.user,model_encoach_ai_generation_log,base.group_user,1,1,1,1
access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user,model_encoach_ai_ielts_generation_log,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_ai_generation_log_user encoach.ai.generation.log.user model_encoach_ai_generation_log base.group_user 1 1 1 1
3 access_encoach_ai_ielts_generation_log_user encoach.ai.ielts.generation.log.user model_encoach_ai_ielts_generation_log base.group_user 1 1 1 1

View File

@@ -1,2 +0,0 @@
from .english_pipeline import EnglishPipeline
from .ielts_pipeline import IeltsPipeline

View File

@@ -1,2 +0,0 @@
from . import base
from . import auth

View File

@@ -1,146 +0,0 @@
import logging
import time
import jwt as pyjwt
from odoo import http, fields
from odoo.http import request
from odoo.exceptions import AccessDenied
from .base import (
_json_response, _error_response, _get_json_body, _get_jwt_secret, validate_token,
)
_logger = logging.getLogger(__name__)
class EncoachAuthController(http.Controller):
# ------------------------------------------------------------------
# POST /api/login
# ------------------------------------------------------------------
@http.route('/api/login', type='http', auth='public',
methods=['POST'], csrf=False)
def login(self, **kw):
try:
body = _get_json_body()
login = (body.get('login') or body.get('email') or '').strip().lower()
password = body.get('password', '')
if not login or not password:
return _error_response('login and password are required', 400)
# Odoo 19: session.authenticate(env, credential_dict)
credential = {
'type': 'password',
'login': login,
'password': password,
}
try:
request.session.authenticate(request.env, credential)
uid = request.session.uid
if not uid:
return _error_response('Invalid email or password', 401)
except AccessDenied:
return _error_response('Invalid email or password', 401)
except Exception as auth_err:
_logger.warning('Auth error for %s: %s', login, auth_err)
return _error_response('Invalid email or password', 401)
user = request.env['res.users'].sudo().browse(uid)
# Generate JWT token
secret = _get_jwt_secret()
if not secret:
return _error_response('JWT not configured on server', 500)
token = pyjwt.encode(
{'user_id': user.id, 'exp': int(time.time()) + 86400},
secret, algorithm='HS256',
)
# Get permissions
permissions = []
if hasattr(user, 'get_all_permissions'):
permissions = user.get_all_permissions().mapped('code')
# Update last login
user.write({'last_login': fields.Datetime.now()})
return _json_response({
'token': token,
'user': self._user_to_dict(user),
'permissions': permissions,
})
except Exception as e:
_logger.exception('login failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/user (returns current authenticated user)
# ------------------------------------------------------------------
@http.route('/api/user', type='http', auth='public',
methods=['GET'], csrf=False)
def get_current_user(self, **kw):
try:
user = validate_token()
if not user:
return _error_response('Authentication required', 401)
permissions = []
if hasattr(user, 'get_all_permissions'):
permissions = user.get_all_permissions().mapped('code')
return _json_response({
'user': self._user_to_dict(user),
'permissions': permissions,
})
except Exception as e:
_logger.exception('get_current_user failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/logout
# ------------------------------------------------------------------
@http.route('/api/logout', type='http', auth='public',
methods=['POST'], csrf=False)
def logout(self, **kw):
# JWT is stateless — client clears token. Server just returns OK.
return _json_response({'ok': True})
# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------
def _user_to_dict(self, user):
"""Convert res.users to the dict shape the React frontend expects."""
entities = []
if hasattr(user, 'entity_ids'):
entities = [
{'id': e.id, 'name': e.name, 'role': ''}
for e in user.entity_ids
]
classrooms = []
return {
'id': user.id,
'name': user.name or '',
'email': user.email or '',
'login': user.login or '',
'user_type': getattr(user, '_api_user_type', lambda: user.user_type or 'student')()
if callable(getattr(user, '_api_user_type', None))
else (user.user_type or 'student'),
'avatar': bool(getattr(user, 'encoach_avatar', False)),
'phone': user.phone or '',
'country': '',
'timezone': '',
'bio': '',
'gender': getattr(user, 'gender', '') or '',
'student_id': '',
'is_verified': getattr(user, 'is_verified', False),
'entities': entities,
'classrooms': classrooms,
'expiry_date': '',
}

View File

@@ -1,153 +0,0 @@
import json
import functools
import logging
import time
import jwt as pyjwt
from odoo.http import request
_logger = logging.getLogger(__name__)
_jwt_secret_cache = {"secret": None, "ts": 0}
_JWT_SECRET_TTL = 300
_user_exists_cache = {}
_USER_CACHE_TTL = 60
_USER_CACHE_MAX = 200
def _get_jwt_secret():
now = time.time()
if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL:
return _jwt_secret_cache["secret"]
secret = (
request.env["ir.config_parameter"]
.sudo()
.get_param("encoach.jwt_secret")
)
if secret:
_jwt_secret_cache["secret"] = secret
_jwt_secret_cache["ts"] = now
return secret
def validate_token():
"""Decode JWT Bearer token and return the corresponding ``res.users`` record or None."""
auth_header = request.httprequest.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
secret = _get_jwt_secret()
if not secret:
_logger.error("System parameter 'encoach.jwt_secret' is not configured")
return None
try:
payload = pyjwt.decode(token, secret, algorithms=["HS256"])
except pyjwt.ExpiredSignatureError:
return None
except pyjwt.InvalidTokenError:
return None
user_id = payload.get("user_id")
if not user_id:
return None
now = time.time()
cache_key = int(user_id)
cached = _user_exists_cache.get(cache_key)
if cached and (now - cached["ts"]) < _USER_CACHE_TTL:
return request.env["res.users"].sudo().browse(cache_key)
user = request.env["res.users"].sudo().browse(cache_key)
if not user.exists():
return None
_user_exists_cache[cache_key] = {"ts": now}
if len(_user_exists_cache) > _USER_CACHE_MAX:
oldest_key = min(_user_exists_cache, key=lambda k: _user_exists_cache[k]["ts"])
del _user_exists_cache[oldest_key]
return user
def jwt_required(func):
"""Decorator that validates the JWT token and sets request.env user context."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
user = validate_token()
if not user:
return _error_response("Authentication required", status=401)
request.update_env(user=user.id)
return func(*args, **kwargs)
return wrapper
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
def _error_response(message, status=400, code=None):
body = {"error": message}
if code:
body["code"] = code
return request.make_json_response(body, status=status)
def _get_json_body():
try:
return json.loads(request.httprequest.get_data(as_text=True))
except (ValueError, TypeError):
return {}
def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'):
"""Paginate an Odoo model search or extract params from a kwargs dict.
Two calling conventions:
_paginate(Model, domain, page, size, order) → (recordset, total_count)
_paginate(kwargs_dict) → (offset, limit, page)
"""
if isinstance(model_or_kwargs, dict):
kwargs = model_or_kwargs
limit = min(max(int(kwargs.get("size", kwargs.get("limit", 20))), 1), 200)
page_raw = int(kwargs.get("page", 0))
pg = max(page_raw, 0)
offset = pg * limit
return offset, limit, pg
Model = model_or_kwargs
limit = min(max(int(size), 1), 200)
pg = max(int(page), 0)
offset = pg * limit
total = Model.search_count(domain or [])
records = Model.search(domain or [], offset=offset, limit=limit, order=order)
return records, total
class EncoachMixin:
"""Shared authentication and response helpers for all EnCoach API controllers."""
def _get_jwt_secret(self):
return _get_jwt_secret()
def _authenticate(self):
return validate_token()
def _json_response(self, data, status=200):
return _json_response(data, status=status)
def _error_response(self, message, status=400, code=None):
return _error_response(message, status=status, code=code)
def _get_json_body(self):
return _get_json_body()
def _paginate_params(self, kwargs):
return _paginate(kwargs)
def _serialize(self, record):
if hasattr(record, "to_encoach_dict"):
return record.to_encoach_dict()
return {"id": record.id}
def _serialize_list(self, records):
return [self._serialize(r) for r in records]

View File

@@ -1,4 +0,0 @@
from . import templates
from . import ielts_exam
from . import custom_exam
from . import exam_structures

View File

@@ -1,87 +0,0 @@
import json
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
def _json_body():
try:
return json.loads(request.httprequest.data or '{}')
except Exception:
return {}
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
class ExamStructureController(http.Controller):
@http.route('/api/exam-structures', type='http', auth='user', methods=['GET'], csrf=False)
def list_structures(self, **kw):
domain = [('active', '=', True)]
entity_id = kw.get('entity_id')
if entity_id:
domain.append(('entity_id', '=', int(entity_id)))
limit = int(kw.get('limit', 50))
offset = int(kw.get('offset', 0))
records = request.env['encoach.exam.structure'].search(domain, limit=limit, offset=offset, order='create_date desc')
total = request.env['encoach.exam.structure'].search_count(domain)
items = []
for r in records:
modules = []
if r.modules:
try:
modules = json.loads(r.modules)
except Exception:
modules = []
items.append({
'id': r.id,
'name': r.name,
'entity_id': r.entity_id.id if r.entity_id else None,
'entity_name': r.entity_id.name if r.entity_id else None,
'industry': r.industry or '',
'modules': modules,
'config': json.loads(r.config) if r.config else {},
})
return _json_response({'items': items, 'total': total})
@http.route('/api/exam-structures', type='http', auth='user', methods=['POST'], csrf=False)
def create_structure(self, **kw):
body = _json_body()
name = body.get('name')
if not name:
return _json_response({'error': 'name is required'}, status=400)
vals = {
'name': name,
'industry': body.get('industry', ''),
'modules': json.dumps(body.get('modules', [])),
'config': json.dumps(body.get('config', {})),
}
entity_id = body.get('entity_id')
if entity_id:
vals['entity_id'] = int(entity_id)
record = request.env['encoach.exam.structure'].create(vals)
return _json_response({
'id': record.id,
'name': record.name,
'entity_id': record.entity_id.id if record.entity_id else None,
'industry': record.industry or '',
'modules': json.loads(record.modules) if record.modules else [],
})
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='user', methods=['DELETE'], csrf=False)
def delete_structure(self, structure_id, **kw):
record = request.env['encoach.exam.structure'].browse(structure_id)
if not record.exists():
return _json_response({'error': 'Structure not found'}, status=404)
record.unlink()
return _json_response({'success': True})

View File

@@ -1,26 +0,0 @@
from odoo import models, fields
class EncoachExamCustom(models.Model):
_name = 'encoach.exam.custom'
_description = 'Custom Exam'
title = fields.Char(size=200, required=True)
template_id = fields.Many2one('encoach.exam.template', ondelete='set null')
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
teacher_id = fields.Many2one('res.users', ondelete='set null')
description = fields.Text()
total_time_min = fields.Integer()
pass_threshold = fields.Float()
results_release_mode = fields.Selection([
('auto', 'Auto'),
('manual_approval', 'Manual Approval'),
], default='auto')
randomize_questions = fields.Boolean(default=False)
status = fields.Selection([
('draft', 'Draft'),
('published', 'Published'),
('archived', 'Archived'),
], default='draft', required=True)
section_ids = fields.One2many('encoach.exam.custom.section', 'exam_id')

View File

@@ -1,83 +0,0 @@
class CefrMapper:
"""Maps IRT theta values to CEFR levels and IELTS band scores."""
THETA_TO_CEFR = [
(-4.0, -2.5, 'pre_a1'),
(-2.5, -1.5, 'a1'),
(-1.5, -0.5, 'a2'),
(-0.5, 0.5, 'b1'),
(0.5, 1.5, 'b2'),
(1.5, 2.5, 'c1'),
(2.5, 4.0, 'c2'),
]
CEFR_TO_BAND = {
'pre_a1': 2.0,
'a1': 3.0,
'a2': 4.0,
'b1': 5.0,
'b2': 6.5,
'c1': 7.5,
'c2': 9.0,
}
CEFR_LABELS = {
'pre_a1': 'Pre-A1 (Beginner)',
'a1': 'A1 (Elementary)',
'a2': 'A2 (Pre-Intermediate)',
'b1': 'B1 (Intermediate)',
'b2': 'B2 (Upper-Intermediate)',
'c1': 'C1 (Advanced)',
'c2': 'C2 (Proficient)',
}
@staticmethod
def theta_to_cefr(theta):
for low, high, level in CefrMapper.THETA_TO_CEFR:
if low <= theta < high:
return level
return 'c2' if theta >= 2.5 else 'pre_a1'
@staticmethod
def theta_to_band(theta):
cefr = CefrMapper.theta_to_cefr(theta)
base_band = CefrMapper.CEFR_TO_BAND.get(cefr, 5.0)
for low, high, level in CefrMapper.THETA_TO_CEFR:
if level == cefr:
range_width = high - low
if range_width > 0:
position = (theta - low) / range_width
else:
position = 0.5
cefr_list = list(CefrMapper.CEFR_TO_BAND.keys())
idx = cefr_list.index(cefr)
next_band = CefrMapper.CEFR_TO_BAND.get(
cefr_list[min(idx + 1, len(cefr_list) - 1)], base_band + 1.0
)
band = base_band + position * (next_band - base_band)
return round(band * 2) / 2
return base_band
@staticmethod
def band_to_cefr(band):
if band < 2.5:
return 'pre_a1'
if band < 3.5:
return 'a1'
if band < 4.5:
return 'a2'
if band < 5.5:
return 'b1'
if band < 7.0:
return 'b2'
if band < 8.0:
return 'c1'
return 'c2'
@staticmethod
def get_cefr_label(cefr_code):
return CefrMapper.CEFR_LABELS.get(cefr_code, cefr_code)

View File

@@ -1 +0,0 @@
from . import resource

View File

@@ -1,68 +0,0 @@
from odoo import models, fields
class EncoachResource(models.Model):
_name = 'encoach.resource'
_description = 'Learning Resource'
name = fields.Char(required=True)
type = fields.Selection([
('video', 'Video'),
('pdf', 'PDF'),
('document', 'Document'),
('link', 'Link'),
('interactive', 'Interactive'),
])
review_status = fields.Selection([
('pending', 'Pending'),
('approved', 'Approved'),
('rejected', 'Rejected'),
], default='approved')
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
topic_ids = fields.Many2many('encoach.topic')
file = fields.Binary(attachment=True)
url = fields.Char()
difficulty = fields.Selection([
('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced'),
])
duration_minutes = fields.Integer()
active = fields.Boolean(default=True)
creator_id = fields.Many2one('res.users', default=lambda self: self.env.user)
cefr_level = fields.Selection([
('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'),
('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'),
])
grammar_topic = fields.Char(size=200)
vocab_band = fields.Char(size=50)
ai_generated = fields.Boolean(default=False)
approved = fields.Boolean(default=False)
ielts_certified = fields.Boolean(default=False)
def to_api_dict(self):
self.ensure_one()
creator = self.creator_id
return {
'id': self.id,
'name': self.name,
'type': self.type or '',
'resource_type': self.type or 'document',
'subject_id': self.subject_id.id if self.subject_id else None,
'topic_ids': self.topic_ids.ids,
'topic_names': self.topic_ids.mapped('name'),
'url': self.url or '',
'has_file': bool(self.file),
'difficulty': self.difficulty or '',
'duration_minutes': self.duration_minutes,
'author_id': creator.id if creator else None,
'author_name': creator.name if creator else '',
'is_published': bool(self.active),
'review_status': self.review_status or 'approved',
'cefr_level': self.cefr_level or '',
'grammar_topic': self.grammar_topic or '',
'vocab_band': self.vocab_band or '',
'ai_generated': self.ai_generated,
'approved': self.approved,
'created_at': self.create_date.isoformat() if self.create_date else '',
'file_name': self.name,
}

View File

@@ -1,230 +0,0 @@
import 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
)
_logger = logging.getLogger(__name__)
TAXONOMY_MODELS = {
'subject': 'encoach.subject',
'domain': 'encoach.domain',
'topic': 'encoach.topic',
'learning_objective': 'encoach.learning.objective',
}
PARENT_FIELD_MAP = {
'domain': 'subject_id',
'topic': 'domain_id',
'learning_objective': 'topic_id',
}
class EncoachTaxonomyController(http.Controller):
# ------------------------------------------------------------------
# GET /api/taxonomy/subjects
# ------------------------------------------------------------------
@http.route('/api/taxonomy/subjects', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_subjects(self, **kw):
try:
Subject = request.env['encoach.subject'].sudo()
subjects = Subject.search([('active', '=', True)])
items = []
for s in subjects:
items.append({
'id': s.id,
'name': s.name,
'code': s.code or '',
'description': s.description or '',
'domain_count': len(s.domain_ids),
})
return _json_response({'subjects': items})
except Exception as e:
_logger.exception('get_subjects failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/taxonomy/tree
# ------------------------------------------------------------------
@http.route('/api/taxonomy/tree', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_tree(self, **kw):
try:
Subject = request.env['encoach.subject'].sudo()
subjects = Subject.search([('active', '=', True)])
tree = []
for s in subjects:
domains = []
for d in s.domain_ids:
topics = []
for t in d.topic_ids:
objectives = []
for o in t.learning_objective_ids:
objectives.append({
'id': o.id,
'name': o.name,
'bloom_level': o.bloom_level or '',
'description': o.description or '',
})
topics.append({
'id': t.id,
'name': t.name,
'description': t.description or '',
'learning_objectives': objectives,
})
domains.append({
'id': d.id,
'name': d.name,
'description': d.description or '',
'topics': topics,
})
tree.append({
'id': s.id,
'name': s.name,
'code': s.code or '',
'description': s.description or '',
'domains': domains,
})
return _json_response({'tree': tree})
except Exception as e:
_logger.exception('get_tree failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/taxonomy/node
# ------------------------------------------------------------------
@http.route('/api/taxonomy/node', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_node(self, **kw):
try:
body = _get_json_body()
node_type = body.get('type')
if not node_type or node_type not in TAXONOMY_MODELS:
return _error_response(
f'type must be one of: {", ".join(TAXONOMY_MODELS.keys())}', 400,
)
name = body.get('name')
if not name:
return _error_response('name is required', 400)
model_name = TAXONOMY_MODELS[node_type]
Model = request.env[model_name].sudo()
vals = {'name': name}
if body.get('description'):
vals['description'] = body['description']
if node_type == 'subject':
code = body.get('code')
if not code:
return _error_response('code is required for subjects', 400)
vals['code'] = code
else:
parent_id = body.get('parent_id')
if not parent_id:
parent_field = PARENT_FIELD_MAP[node_type]
return _error_response(
f'parent_id ({parent_field}) is required for {node_type}', 400,
)
vals[PARENT_FIELD_MAP[node_type]] = int(parent_id)
if node_type == 'learning_objective' and body.get('bloom_level'):
vals['bloom_level'] = body['bloom_level']
record = Model.create(vals)
return _json_response({
'id': record.id,
'type': node_type,
'name': record.name,
}, 201)
except Exception as e:
_logger.exception('create_node failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/taxonomy/node/<int:node_id>
# ------------------------------------------------------------------
@http.route('/api/taxonomy/node/<int:node_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update_node(self, node_id, **kw):
try:
body = _get_json_body()
node_type = body.get('type')
if not node_type or node_type not in TAXONOMY_MODELS:
return _error_response(
f'type must be one of: {", ".join(TAXONOMY_MODELS.keys())}', 400,
)
model_name = TAXONOMY_MODELS[node_type]
Model = request.env[model_name].sudo()
record = Model.browse(node_id)
if not record.exists():
return _error_response(f'{node_type} not found', 404)
vals = {}
if body.get('name'):
vals['name'] = body['name']
if 'description' in body:
vals['description'] = body.get('description') or ''
if node_type == 'subject':
if body.get('code'):
vals['code'] = body['code']
if 'active' in body:
vals['active'] = bool(body['active'])
elif node_type == 'learning_objective' and body.get('bloom_level'):
vals['bloom_level'] = body['bloom_level']
if vals:
record.write(vals)
return _json_response(record.to_api_dict())
except Exception as e:
_logger.exception('update_node failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# DELETE /api/taxonomy/node/<int:node_id>
# ------------------------------------------------------------------
@http.route('/api/taxonomy/node/<int:node_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@jwt_required
def delete_node(self, node_id, **kw):
try:
body = _get_json_body()
node_type = body.get('type') or kw.get('type')
if not node_type or node_type not in TAXONOMY_MODELS:
return _error_response(
f'type must be one of: {", ".join(TAXONOMY_MODELS.keys())}', 400,
)
model_name = TAXONOMY_MODELS[node_type]
Model = request.env[model_name].sudo()
record = Model.browse(node_id)
if not record.exists():
return _error_response(f'{node_type} not found', 404)
record.unlink()
return _json_response({}, 204)
except Exception as e:
_logger.exception('delete_node failed')
return _error_response(str(e), 500)

View File

@@ -1,139 +0,0 @@
"""Embedding service — encode text and manage vector storage."""
import json
import logging
import time
_logger = logging.getLogger(__name__)
_model_instance = None
def _get_model():
"""Lazy-load the sentence-transformers model (cached across calls)."""
global _model_instance
if _model_instance is None:
try:
from sentence_transformers import SentenceTransformer
_model_instance = SentenceTransformer('all-MiniLM-L6-v2')
_logger.info("Loaded sentence-transformers model: all-MiniLM-L6-v2")
except ImportError:
_logger.error(
"sentence-transformers not installed. "
"Run: pip install sentence-transformers"
)
raise
return _model_instance
class EmbeddingService:
"""Encode texts, upsert embeddings, and perform semantic search."""
def __init__(self, env):
self.env = env
self.Embedding = env['encoach.embedding'].sudo()
def encode(self, texts):
"""Batch-encode texts to vectors.
Args:
texts: list of strings
Returns:
list of float lists (each 384-dim)
"""
model = _get_model()
embeddings = model.encode(texts, normalize_embeddings=True, show_progress_bar=False)
return [e.tolist() for e in embeddings]
def upsert(self, content_type, content_id, text, metadata=None):
"""Encode and store (or update) a single embedding.
Returns:
encoach.embedding record
"""
if not text or not text.strip():
return None
existing = self.Embedding.search([
('content_type', '=', content_type),
('content_id', '=', content_id),
], limit=1)
vectors = self.encode([text])
meta_str = json.dumps(metadata or {})
if existing:
existing.write({
'content_text': text[:10000],
'metadata_json': meta_str,
})
existing.set_embedding(vectors[0])
return existing
record = self.Embedding.create({
'content_type': content_type,
'content_id': content_id,
'content_text': text[:10000],
'metadata_json': meta_str,
})
record.set_embedding(vectors[0])
return record
def search(self, query, *, content_type=None, limit=10):
"""Semantic search — encode query and find similar content.
Returns:
list of dicts with text, metadata, similarity score
"""
if not query or not query.strip():
return []
t0 = time.time()
vectors = self.encode([query])
results = self.Embedding.similarity_search(
vectors[0],
content_type=content_type,
limit=limit,
)
latency = int((time.time() - t0) * 1000)
_logger.info("Vector search for '%s' returned %d results in %dms",
query[:80], len(results), latency)
return results
def bulk_index(self, content_type, records_data):
"""Batch-index multiple records.
Args:
content_type: embedding content type
records_data: list of dicts with keys: id, text, metadata
"""
if not records_data:
return 0
texts = [r['text'] for r in records_data if r.get('text')]
if not texts:
return 0
vectors = self.encode(texts)
indexed = 0
text_idx = 0
for r in records_data:
if not r.get('text'):
continue
self.upsert(content_type, r['id'], r['text'], r.get('metadata'))
text_idx += 1
indexed += 1
_logger.info("Bulk-indexed %d %s records", indexed, content_type)
return indexed
def delete(self, content_type, content_id):
"""Remove an embedding."""
existing = self.Embedding.search([
('content_type', '=', content_type),
('content_id', '=', content_id),
])
if existing:
existing.unlink()

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -27,14 +27,33 @@ class EncoachAdaptiveController(http.Controller):
from odoo.fields import Datetime as DT from odoo.fields import Datetime as DT
today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0) today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0)
total_students = len(Path.search([]).mapped('student_id')) all_paths = Path.search([])
active_courses = len(Path.search([]).mapped('course_id').filtered(lambda c: c)) total_students = len(all_paths.mapped('student_id'))
active_courses = len(all_paths.mapped('course_id').filtered(lambda c: c))
signals_today = Event.search_count([ signals_today = Event.search_count([
('event_type', '=', 'signal'), ('event_type', '=', 'signal'),
('created_at', '>=', today_start), ('created_at', '>=', today_start),
]) ])
avg_progress = 0.0
if all_paths:
progress_values = []
for p in all_paths:
try:
module_queue = json.loads(p.module_queue or '[]')
except (json.JSONDecodeError, TypeError):
module_queue = []
total_modules = len(module_queue) if module_queue else 1
completed = sum(
1 for m in module_queue
if isinstance(m, dict) and m.get('done')
)
progress_values.append(
round(completed / total_modules * 100, 1) if total_modules else 0.0
)
avg_progress = round(sum(progress_values) / len(progress_values), 1) if progress_values else 0.0
recent_decisions = [] recent_decisions = []
decisions = Event.search( decisions = Event.search(
[('event_type', '=', 'decision')], [('event_type', '=', 'decision')],
@@ -52,7 +71,7 @@ class EncoachAdaptiveController(http.Controller):
return _json_response({ return _json_response({
'total_students': total_students, 'total_students': total_students,
'active_courses': active_courses, 'active_courses': active_courses,
'avg_progress': 0.0, 'avg_progress': avg_progress,
'signals_today': signals_today, 'signals_today': signals_today,
'recent_decisions': recent_decisions, 'recent_decisions': recent_decisions,
}) })

Some files were not shown because too many files have changed in this diff Show More