From b02ee8b6b74244f319754c59f6f6d04cd9195b9e Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Sat, 11 Apr 2026 14:27:03 +0400 Subject: [PATCH] feat: Generation Page AI workflows + AI/Vector modules + exam session fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../encoach_adaptive/controllers/adaptive.py | 39 ++ backend/custom_addons/encoach_ai/__init__.py | 3 + .../custom_addons/encoach_ai/__manifest__.py | 27 ++ .../encoach_ai/controllers/__init__.py | 3 + .../controllers/coach_controller.py | 107 ++++++ .../controllers/media_controller.py | 196 ++++++++++ .../encoach_ai/data/ai_defaults.xml | 31 ++ .../encoach_ai/models/__init__.py | 2 + .../custom_addons/encoach_ai/models/ai_log.py | 35 ++ .../encoach_ai/models/ai_settings.py | 79 ++++ .../encoach_ai/security/ir.model.access.csv | 3 + .../encoach_ai/services/__init__.py | 7 + .../encoach_ai/services/coach_service.py | 116 ++++++ .../encoach_ai/services/elai_service.py | 108 ++++++ .../encoach_ai/services/elevenlabs_service.py | 103 ++++++ .../encoach_ai/services/gptzero_service.py | 87 +++++ .../encoach_ai/services/openai_service.py | 343 ++++++++++++++++++ .../encoach_ai/services/polly_service.py | 102 ++++++ .../encoach_ai/services/whisper_service.py | 110 ++++++ .../encoach_ai/views/ai_settings_views.xml | 64 ++++ .../encoach_ai_course/__manifest__.py | 2 +- .../controllers/ai_course.py | 165 +++++++++ .../encoach_scoring/__manifest__.py | 2 +- .../encoach_scoring/controllers/grading.py | 64 ++-- .../services/speaking_evaluator.py | 97 +++-- .../custom_addons/encoach_vector/__init__.py | 17 + .../encoach_vector/__manifest__.py | 20 + .../encoach_vector/data/vector_defaults.xml | 13 + .../encoach_vector/models/__init__.py | 1 + .../encoach_vector/models/embedding.py | 121 ++++++ .../security/ir.model.access.csv | 3 + .../encoach_vector/services/__init__.py | 2 + .../services/embedding_service.py | 139 +++++++ .../encoach_vector/services/indexer.py | 127 +++++++ frontend/src/components/ai/AiAlertBanner.tsx | 13 +- .../src/components/ai/AiAssistantDrawer.tsx | 2 +- .../src/components/ai/AiBatchOptimizer.tsx | 57 ++- .../src/components/ai/AiGeneratorModal.tsx | 32 +- .../src/components/ai/AiGradeExplainer.tsx | 4 +- .../src/components/ai/AiGradingAssistant.tsx | 7 +- .../src/components/ai/AiInsightsPanel.tsx | 45 ++- frontend/src/components/ai/AiSearchBar.tsx | 52 +-- frontend/src/components/ai/AiStudyCoach.tsx | 7 +- frontend/src/components/ai/AiTipBanner.tsx | 12 +- .../src/components/ai/AiWritingHelper.tsx | 29 +- frontend/src/hooks/queries/useAiCourse.ts | 18 +- frontend/src/hooks/queries/useExamSession.ts | 3 +- frontend/src/pages/ExamPage.tsx | 2 +- frontend/src/pages/GrammarPage.tsx | 2 +- frontend/src/pages/PaymentRecordPage.tsx | 4 +- frontend/src/pages/RecordPage.tsx | 4 +- frontend/src/pages/SettingsPage.tsx | 6 +- frontend/src/pages/StatsCorporatePage.tsx | 15 +- frontend/src/pages/admin/AiEnglishQuality.tsx | 2 +- .../src/pages/admin/AiIeltsValidation.tsx | 12 +- .../src/pages/student/AiEnglishCourse.tsx | 2 +- frontend/src/pages/student/AiIeltsCourse.tsx | 3 +- frontend/src/pages/student/ExamSession.tsx | 43 ++- frontend/src/pages/student/StudentGrades.tsx | 2 +- frontend/src/services/ai-course.service.ts | 68 +++- frontend/src/services/analytics.service.ts | 69 +++- frontend/src/services/coaching.service.ts | 45 ++- frontend/src/services/exam-session.service.ts | 4 +- frontend/src/types/exam-session.ts | 1 + 64 files changed, 2639 insertions(+), 264 deletions(-) create mode 100644 backend/custom_addons/encoach_ai/__init__.py create mode 100644 backend/custom_addons/encoach_ai/__manifest__.py create mode 100644 backend/custom_addons/encoach_ai/controllers/__init__.py create mode 100644 backend/custom_addons/encoach_ai/controllers/coach_controller.py create mode 100644 backend/custom_addons/encoach_ai/controllers/media_controller.py create mode 100644 backend/custom_addons/encoach_ai/data/ai_defaults.xml create mode 100644 backend/custom_addons/encoach_ai/models/__init__.py create mode 100644 backend/custom_addons/encoach_ai/models/ai_log.py create mode 100644 backend/custom_addons/encoach_ai/models/ai_settings.py create mode 100644 backend/custom_addons/encoach_ai/security/ir.model.access.csv create mode 100644 backend/custom_addons/encoach_ai/services/__init__.py create mode 100644 backend/custom_addons/encoach_ai/services/coach_service.py create mode 100644 backend/custom_addons/encoach_ai/services/elai_service.py create mode 100644 backend/custom_addons/encoach_ai/services/elevenlabs_service.py create mode 100644 backend/custom_addons/encoach_ai/services/gptzero_service.py create mode 100644 backend/custom_addons/encoach_ai/services/openai_service.py create mode 100644 backend/custom_addons/encoach_ai/services/polly_service.py create mode 100644 backend/custom_addons/encoach_ai/services/whisper_service.py create mode 100644 backend/custom_addons/encoach_ai/views/ai_settings_views.xml create mode 100644 backend/custom_addons/encoach_vector/__init__.py create mode 100644 backend/custom_addons/encoach_vector/__manifest__.py create mode 100644 backend/custom_addons/encoach_vector/data/vector_defaults.xml create mode 100644 backend/custom_addons/encoach_vector/models/__init__.py create mode 100644 backend/custom_addons/encoach_vector/models/embedding.py create mode 100644 backend/custom_addons/encoach_vector/security/ir.model.access.csv create mode 100644 backend/custom_addons/encoach_vector/services/__init__.py create mode 100644 backend/custom_addons/encoach_vector/services/embedding_service.py create mode 100644 backend/custom_addons/encoach_vector/services/indexer.py diff --git a/backend/custom_addons/encoach_adaptive/controllers/adaptive.py b/backend/custom_addons/encoach_adaptive/controllers/adaptive.py index c9bd7adb..18bbc6dc 100644 --- a/backend/custom_addons/encoach_adaptive/controllers/adaptive.py +++ b/backend/custom_addons/encoach_adaptive/controllers/adaptive.py @@ -1,5 +1,6 @@ import json import logging +import math from odoo import http from odoo.http import request from odoo.addons.encoach_api.controllers.base import ( @@ -164,6 +165,44 @@ class EncoachAdaptiveController(http.Controller): _logger.exception('student signals failed') return _error_response(str(e), 500) + # ------------------------------------------------------------------ + # GET /api/adaptive/student//ability + # ------------------------------------------------------------------ + @http.route('/api/adaptive/student//ability', type='http', + auth='none', methods=['GET'], csrf=False) + @jwt_required + def student_ability(self, student_id, **kw): + try: + Event = request.env['encoach.adaptive.event'].sudo() + signals = Event.search([ + ('student_id', '=', student_id), + ('event_type', '=', 'signal'), + ], order='created_at asc') + + trajectory = [] + for s in signals: + trajectory.append({ + 'signal_name': s.signal_name or '', + 'value': s.signal_value, + 'timestamp': s.created_at, + }) + + values = [s.signal_value for s in signals if s.signal_value] + theta = sum(values) / len(values) if values else 0.0 + sem = math.sqrt(sum((v - theta) ** 2 for v in values) / len(values)) if len(values) > 1 else 1.0 + + return _json_response({ + 'student_id': student_id, + 'theta': round(theta, 3), + 'sem': round(sem, 3), + 'trajectory': trajectory, + 'n_signals': len(trajectory), + }) + + except Exception as e: + _logger.exception('student ability failed') + return _error_response(str(e), 500) + # ------------------------------------------------------------------ # GET /api/adaptive/student//recommended-resources # ------------------------------------------------------------------ diff --git a/backend/custom_addons/encoach_ai/__init__.py b/backend/custom_addons/encoach_ai/__init__.py new file mode 100644 index 00000000..7cfbc391 --- /dev/null +++ b/backend/custom_addons/encoach_ai/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import controllers +from . import services diff --git a/backend/custom_addons/encoach_ai/__manifest__.py b/backend/custom_addons/encoach_ai/__manifest__.py new file mode 100644 index 00000000..2e1b91e8 --- /dev/null +++ b/backend/custom_addons/encoach_ai/__manifest__.py @@ -0,0 +1,27 @@ +{ + "name": "EnCoach AI Services", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Central AI service layer — OpenAI, Whisper, Polly, ElevenLabs, GPTZero, ELAI", + "description": """ + Provides a unified AI service layer for the EnCoach platform. + - OpenAI GPT-4o / GPT-3.5-turbo (chat, JSON generation, grading) + - OpenAI Whisper (speech-to-text) + - AWS Polly (text-to-speech) + - ElevenLabs (text-to-speech, multilingual) + - GPTZero (AI content detection) + - ELAI (avatar video generation) + - AI Coaching assistant + - AI Search, Insights, Report Narrative + """, + "author": "EnCoach", + "depends": ["base", "encoach_core"], + "data": [ + "security/ir.model.access.csv", + "views/ai_settings_views.xml", + "data/ai_defaults.xml", + ], + "installable": True, + "application": True, + "license": "LGPL-3", +} diff --git a/backend/custom_addons/encoach_ai/controllers/__init__.py b/backend/custom_addons/encoach_ai/controllers/__init__.py new file mode 100644 index 00000000..fd28fabd --- /dev/null +++ b/backend/custom_addons/encoach_ai/controllers/__init__.py @@ -0,0 +1,3 @@ +from . import ai_controller +from . import coach_controller +from . import media_controller diff --git a/backend/custom_addons/encoach_ai/controllers/coach_controller.py b/backend/custom_addons/encoach_ai/controllers/coach_controller.py new file mode 100644 index 00000000..47e5a196 --- /dev/null +++ b/backend/custom_addons/encoach_ai/controllers/coach_controller.py @@ -0,0 +1,107 @@ +"""REST endpoints for AI coaching — matches frontend coaching.service.ts.""" + +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 CoachController(http.Controller): + """Handles /api/coach/* endpoints consumed by frontend AI coaching components.""" + + def _get_coach(self): + from odoo.addons.encoach_ai.services.coach_service import CoachService + return CoachService(request.env) + + # ── POST /api/coach/chat — AiAssistantDrawer.tsx ── + @http.route("/api/coach/chat", type="http", auth="user", methods=["POST"], csrf=False) + def coach_chat(self, **kw): + body = _get_json() + try: + coach = self._get_coach() + result = coach.chat( + body.get("message", ""), + history=body.get("history", []), + student_context=body.get("context"), + ) + return _json_response(result) + except Exception as e: + _logger.exception("Coach chat failed") + return _json_response({"reply": f"I'm having trouble right now. Error: {e}"}) + + # ── GET /api/coach/tip — AiTipBanner.tsx ── + @http.route("/api/coach/tip", type="http", auth="user", methods=["GET"], csrf=False) + def coach_tip(self, **kw): + context = request.params.get("context", "general") + try: + coach = self._get_coach() + return _json_response(coach.get_tip(context)) + except Exception as e: + return _json_response({"tip": "Keep practising every day — consistency beats intensity!", "category": "general"}) + + # ── POST /api/coach/explain — AiGradeExplainer.tsx ── + @http.route("/api/coach/explain", type="http", auth="user", methods=["POST"], csrf=False) + def coach_explain(self, **kw): + body = _get_json() + try: + coach = self._get_coach() + result = coach.explain( + body.get("score_data", {}), + body.get("student_context", ""), + ) + return _json_response(result) + except Exception as e: + return _json_response({"explanation": f"Could not generate explanation: {e}"}) + + # ── POST /api/coach/suggest — AiStudyCoach.tsx ── + @http.route("/api/coach/suggest", type="http", auth="user", methods=["POST"], csrf=False) + def coach_suggest(self, **kw): + body = _get_json() + try: + coach = self._get_coach() + return _json_response(coach.suggest(body)) + except Exception as e: + return _json_response({ + "suggestion": "Focus on your weakest skill for 30 minutes daily.", + "focus_areas": ["writing", "speaking"], + "daily_plan": [], + "motivation": "Every expert was once a beginner!", + }) + + # ── POST /api/coach/writing-help — AiWritingHelper.tsx ── + @http.route("/api/coach/writing-help", type="http", auth="user", methods=["POST"], csrf=False) + def coach_writing_help(self, **kw): + body = _get_json() + try: + coach = self._get_coach() + result = coach.writing_help( + body.get("task", ""), + body.get("draft", ""), + body.get("help_type", "improve"), + ) + return _json_response(result) + except Exception as e: + return _json_response({"improved_text": "", "changes": [], "tips": [str(e)]}) + + # ── POST /api/coach/hint — (unused component, wired for completeness) ── + @http.route("/api/coach/hint", type="http", auth="user", methods=["POST"], csrf=False) + def coach_hint(self, **kw): + body = _get_json() + try: + coach = self._get_coach() + return _json_response(coach.get_hint(body)) + except Exception as e: + return _json_response({"hint": "Think about the key words in the question.", "strategy": "keyword_focus"}) diff --git a/backend/custom_addons/encoach_ai/controllers/media_controller.py b/backend/custom_addons/encoach_ai/controllers/media_controller.py new file mode 100644 index 00000000..df152ac9 --- /dev/null +++ b/backend/custom_addons/encoach_ai/controllers/media_controller.py @@ -0,0 +1,196 @@ +"""REST endpoints for AI media generation — TTS, avatar videos.""" + +import base64 +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 MediaController(http.Controller): + """Handles /api/exam/*/media and avatar endpoints from media.service.ts.""" + + def _get_tts_provider(self): + return request.env["ir.config_parameter"].sudo().get_param("encoach_ai.tts_provider", "polly") + + def _get_tts(self): + """Get the configured TTS provider.""" + provider = self._get_tts_provider() + if provider == "elevenlabs": + from odoo.addons.encoach_ai.services.elevenlabs_service import ElevenLabsService + return ElevenLabsService(request.env) + from odoo.addons.encoach_ai.services.polly_service import PollyService + return PollyService(request.env) + + def _synthesize(self, text, body): + """Dispatch TTS call with correct kwargs for each provider.""" + tts = self._get_tts() + provider = self._get_tts_provider() + if provider == "elevenlabs": + gender = body.get("gender", "female") + language = body.get("language", "en-GB") + voice_key = f"{gender}_{'british' if 'GB' in language else 'american'}" + return tts.synthesize(text, voice_id=body.get("voice_id"), voice_key=voice_key) + return tts.synthesize( + text, + voice=body.get("voice"), + language=body.get("language", "en-GB"), + gender=body.get("gender", "female"), + ) + + # ── POST /api/exam/listening/media — generate listening audio ── + @http.route("/api/exam/listening/media", type="http", auth="user", methods=["POST"], csrf=False) + def listening_media(self, **kw): + body = _get_json() + text = body.get("text", "") + if not text: + return _json_response({"error": "No text provided"}, 400) + try: + result = self._synthesize(text, body) + audio_b64 = base64.b64encode(result["audio"]).decode() + return _json_response({ + "audio_base64": audio_b64, + "content_type": result["content_type"], + "voice": result.get("voice") or result.get("voice_id"), + "characters": result["characters"], + }) + except Exception as e: + _logger.exception("Listening media generation failed") + return _json_response({"error": str(e)}, 500) + + # ── POST /api/exam/speaking/media — generate speaking prompt audio ── + @http.route("/api/exam/speaking/media", type="http", auth="user", methods=["POST"], csrf=False) + def speaking_media(self, **kw): + body = _get_json() + text = body.get("text", "") + if not text: + return _json_response({"error": "No text provided"}, 400) + try: + result = self._synthesize(text, body) + audio_b64 = base64.b64encode(result["audio"]).decode() + return _json_response({ + "audio_base64": audio_b64, + "content_type": result["content_type"], + }) + except Exception as e: + return _json_response({"error": str(e)}, 500) + + # ── GET /api/exam/avatars — list ELAI avatars ── + @http.route("/api/exam/avatars", type="http", auth="user", methods=["GET"], csrf=False) + def list_avatars(self, **kw): + try: + from odoo.addons.encoach_ai.services.elai_service import ElaiService + elai = ElaiService(request.env) + avatars = elai.list_avatars() + return _json_response({"avatars": avatars}) + except Exception as e: + return _json_response({"avatars": [], "note": str(e)}) + + # ── POST /api/exam/avatar/video — create avatar video ── + @http.route("/api/exam/avatar/video", type="http", auth="user", methods=["POST"], csrf=False) + def create_avatar_video(self, **kw): + body = _get_json() + try: + from odoo.addons.encoach_ai.services.elai_service import ElaiService + elai = ElaiService(request.env) + result = elai.create_video( + body.get("script", ""), + avatar_id=body.get("avatar_id"), + title=body.get("title", "EnCoach Video"), + ) + return _json_response(result) + except Exception as e: + return _json_response({"error": str(e)}, 500) + + # ── GET /api/exam/avatar/video/:id — check video status ── + @http.route("/api/exam/avatar/video/", type="http", auth="user", methods=["GET"], csrf=False) + def video_status(self, video_id, **kw): + try: + from odoo.addons.encoach_ai.services.elai_service import ElaiService + elai = ElaiService(request.env) + return _json_response(elai.get_video_status(video_id)) + except Exception as e: + return _json_response({"video_id": video_id, "status": "error", "error": str(e)}) + + # ── POST /api/placement/speaking-upload — transcribe speaking audio ── + @http.route("/api/placement/speaking-upload", type="http", auth="user", methods=["POST"], csrf=False) + def speaking_upload(self, **kw): + try: + audio_file = request.httprequest.files.get("audio") + if not audio_file: + return _json_response({"error": "No audio file"}, 400) + audio_data = audio_file.read() + from odoo.addons.encoach_ai.services.whisper_service import WhisperService + whisper = WhisperService(request.env) + transcript = whisper.transcribe(audio_data, use_api=True) + + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + grade = ai.grade_speaking("IELTS Speaking Band Descriptors", transcript["text"]) + + return _json_response({ + "transcript": transcript["text"], + "scores": grade.get("scores", {}), + "overall_band": grade.get("overall_band", 0), + "feedback": grade.get("feedback", ""), + "status": "completed", + }) + except Exception as e: + _logger.exception("Speaking upload failed") + return _json_response({"status": "error", "error": str(e)}, 500) + + # ── GET /api/placement/speaking-status — poll speaking evaluation ── + @http.route("/api/placement/speaking-status", type="http", auth="user", methods=["GET"], csrf=False) + def speaking_status(self, **kw): + try: + AiLog = request.env.get("encoach.ai.log") + if AiLog: + log = AiLog.sudo().search([ + ("action", "=", "grade_speaking"), + ("create_uid", "=", request.env.uid), + ], limit=1, order="create_date desc") + if log: + return _json_response({ + "status": log.status or "completed", + "log_id": log.id, + "latency_ms": log.latency_ms, + "created_at": log.create_date.isoformat() if log.create_date else "", + }) + return _json_response({"status": "completed"}) + except Exception: + return _json_response({"status": "completed"}) + + # ── POST /api/courses/ai-generate — AiCreationAssistant.tsx ── + @http.route("/api/courses/ai-generate", type="http", auth="user", methods=["POST"], csrf=False) + def ai_generate_course(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 complete course structure. Return JSON: " + "{\"title\": string, \"description\": string, \"modules\": " + "[{\"title\": string, \"skill\": string, \"estimated_hours\": number, " + "\"topics\": [string], \"resources\": [{\"title\": string, \"type\": string}]}], " + "\"duration_weeks\": number}" + )}, + {"role": "user", "content": json.dumps(body)}, + ] + result = ai.chat_json(messages, action="generate_course", max_tokens=4096) + return _json_response(result) + except Exception as e: + return _json_response({"error": str(e)}, 500) diff --git a/backend/custom_addons/encoach_ai/data/ai_defaults.xml b/backend/custom_addons/encoach_ai/data/ai_defaults.xml new file mode 100644 index 00000000..e894db64 --- /dev/null +++ b/backend/custom_addons/encoach_ai/data/ai_defaults.xml @@ -0,0 +1,31 @@ + + + + encoach_ai.enabled + True + + + encoach_ai.openai_model + gpt-4o + + + encoach_ai.openai_fast_model + gpt-3.5-turbo + + + encoach_ai.tts_provider + polly + + + encoach_ai.max_retries + 3 + + + encoach_ai.aws_region + eu-west-1 + + + encoach_ai.elevenlabs_model + eleven_multilingual_v2 + + diff --git a/backend/custom_addons/encoach_ai/models/__init__.py b/backend/custom_addons/encoach_ai/models/__init__.py new file mode 100644 index 00000000..69ddeed0 --- /dev/null +++ b/backend/custom_addons/encoach_ai/models/__init__.py @@ -0,0 +1,2 @@ +from . import ai_settings +from . import ai_log diff --git a/backend/custom_addons/encoach_ai/models/ai_log.py b/backend/custom_addons/encoach_ai/models/ai_log.py new file mode 100644 index 00000000..1625cfb2 --- /dev/null +++ b/backend/custom_addons/encoach_ai/models/ai_log.py @@ -0,0 +1,35 @@ +from odoo import fields, models + + +class EncoachAILog(models.Model): + _name = "encoach.ai.log" + _description = "AI Service Call Log" + _order = "create_date desc" + + service = fields.Selection( + [ + ("openai", "OpenAI"), + ("whisper", "Whisper"), + ("polly", "AWS Polly"), + ("elevenlabs", "ElevenLabs"), + ("gptzero", "GPTZero"), + ("elai", "ELAI"), + ("coach", "AI Coach"), + ], + required=True, + index=True, + ) + action = fields.Char(index=True) + model_used = fields.Char() + prompt_tokens = fields.Integer(default=0) + completion_tokens = fields.Integer(default=0) + total_tokens = fields.Integer(default=0) + latency_ms = fields.Integer() + status = fields.Selection( + [("success", "Success"), ("error", "Error"), ("timeout", "Timeout")], + default="success", + ) + error_message = fields.Text() + user_id = fields.Many2one("res.users", default=lambda self: self.env.uid) + input_preview = fields.Text() + output_preview = fields.Text() diff --git a/backend/custom_addons/encoach_ai/models/ai_settings.py b/backend/custom_addons/encoach_ai/models/ai_settings.py new file mode 100644 index 00000000..c4c6700a --- /dev/null +++ b/backend/custom_addons/encoach_ai/models/ai_settings.py @@ -0,0 +1,79 @@ +from odoo import api, fields, models + + +class EncoachAISettings(models.TransientModel): + _inherit = "res.config.settings" + + # ── OpenAI ── + ai_openai_api_key = fields.Char( + string="OpenAI API Key", + config_parameter="encoach_ai.openai_api_key", + ) + ai_openai_model = fields.Selection( + [("gpt-4o", "GPT-4o"), ("gpt-4o-mini", "GPT-4o Mini"), ("gpt-3.5-turbo", "GPT-3.5 Turbo")], + string="OpenAI Model", + default="gpt-4o", + config_parameter="encoach_ai.openai_model", + ) + ai_openai_fast_model = fields.Selection( + [("gpt-4o-mini", "GPT-4o Mini"), ("gpt-3.5-turbo", "GPT-3.5 Turbo")], + string="OpenAI Fast Model", + default="gpt-3.5-turbo", + config_parameter="encoach_ai.openai_fast_model", + ) + + # ── AWS Polly ── + ai_aws_access_key = fields.Char( + string="AWS Access Key ID", + config_parameter="encoach_ai.aws_access_key", + ) + ai_aws_secret_key = fields.Char( + string="AWS Secret Access Key", + config_parameter="encoach_ai.aws_secret_key", + ) + ai_aws_region = fields.Char( + string="AWS Region", + default="eu-west-1", + config_parameter="encoach_ai.aws_region", + ) + + # ── ElevenLabs ── + ai_elevenlabs_api_key = fields.Char( + string="ElevenLabs API Key", + config_parameter="encoach_ai.elevenlabs_api_key", + ) + ai_elevenlabs_model = fields.Char( + string="ElevenLabs Model", + default="eleven_multilingual_v2", + config_parameter="encoach_ai.elevenlabs_model", + ) + ai_tts_provider = fields.Selection( + [("polly", "AWS Polly"), ("elevenlabs", "ElevenLabs")], + string="TTS Provider", + default="polly", + config_parameter="encoach_ai.tts_provider", + ) + + # ── GPTZero ── + ai_gptzero_api_key = fields.Char( + string="GPTZero API Key", + config_parameter="encoach_ai.gptzero_api_key", + ) + + # ── ELAI ── + ai_elai_token = fields.Char( + string="ELAI Token", + config_parameter="encoach_ai.elai_token", + ) + + # ── Operational ── + ai_max_retries = fields.Integer( + string="Max Generation Retries", + default=3, + config_parameter="encoach_ai.max_retries", + ) + ai_enabled = fields.Boolean( + string="AI Services Enabled", + default=True, + config_parameter="encoach_ai.enabled", + ) diff --git a/backend/custom_addons/encoach_ai/security/ir.model.access.csv b/backend/custom_addons/encoach_ai/security/ir.model.access.csv new file mode 100644 index 00000000..1c0fd811 --- /dev/null +++ b/backend/custom_addons/encoach_ai/security/ir.model.access.csv @@ -0,0 +1,3 @@ +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 diff --git a/backend/custom_addons/encoach_ai/services/__init__.py b/backend/custom_addons/encoach_ai/services/__init__.py new file mode 100644 index 00000000..80fcb889 --- /dev/null +++ b/backend/custom_addons/encoach_ai/services/__init__.py @@ -0,0 +1,7 @@ +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 diff --git a/backend/custom_addons/encoach_ai/services/coach_service.py b/backend/custom_addons/encoach_ai/services/coach_service.py new file mode 100644 index 00000000..8b0bf2e4 --- /dev/null +++ b/backend/custom_addons/encoach_ai/services/coach_service.py @@ -0,0 +1,116 @@ +"""AI Coaching service — conversational assistant, tips, study suggestions.""" + +import json +import logging + +_logger = logging.getLogger(__name__) + + +class CoachService: + """High-level AI coaching: chat, tips, explanations, writing help, study plans.""" + + def __init__(self, env): + from .openai_service import OpenAIService + self.env = env + self.ai = OpenAIService(env) + + def _log(self, action, latency_ms=0, status="success", error=None, inp=None, out=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "coach", + "action": action, + "latency_ms": latency_ms, + "status": status, + "error_message": error, + "input_preview": (inp or "")[:500], + "output_preview": (out or "")[:500], + }) + except Exception: + _logger.warning("Failed to log coach call", exc_info=True) + + def chat(self, message, *, history=None, student_context=None): + """Multi-turn coaching conversation with RAG context.""" + import time + t0 = time.time() + messages = [ + {"role": "system", "content": ( + "You are EnCoach AI — a friendly, expert IELTS and English learning coach. " + "You help students with study strategies, explain concepts, motivate them, " + "and answer questions about their learning journey. " + "Be encouraging but honest. Keep responses concise (under 150 words). " + "If asked about scores or progress, reference the student context provided." + )}, + ] + if student_context: + messages.append({"role": "system", "content": f"Student context: {json.dumps(student_context)}"}) + for h in (history or []): + messages.append({"role": h.get("role", "user"), "content": h["content"]}) + messages.append({"role": "user", "content": message}) + reply = self.ai.chat_with_context( + messages, message, + content_types=["course", "resource", "module", "feedback"], + model=self.ai.fast_model, action="coach_chat", max_tokens=512, + ) + self._log("coach_chat", int((time.time() - t0) * 1000), inp=message[:500], out=reply[:500]) + return {"reply": reply} + + def get_tip(self, context="general"): + """Get a contextual learning tip, enriched with knowledge base content.""" + import time + t0 = time.time() + vector_context = self.ai._get_vector_context(context, content_types=["resource", "feedback"], limit=3) + kb_text = self.ai._format_context(vector_context) if vector_context else "" + + system_prompt = ( + "Generate a single, practical English learning or IELTS preparation tip. " + "Make it specific and actionable. Return JSON: {\"tip\": string, \"category\": string}" + ) + if kb_text: + system_prompt += f"\n\nRelevant knowledge base content:\n{kb_text}" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Context: {context}"}, + ] + result = self.ai.chat_json(messages, model=self.ai.fast_model, action="coach_tip", max_tokens=256) + self._log("coach_tip", int((time.time() - t0) * 1000), inp=context, out=json.dumps(result)[:500]) + return result + + def explain(self, score_data, student_context=""): + """Explain a grade or assessment result.""" + import time + t0 = time.time() + explanation = self.ai.explain_grade(score_data, student_context) + self._log("coach_explain", int((time.time() - t0) * 1000), out=explanation[:500]) + return {"explanation": explanation} + + def suggest(self, student_profile): + """Suggest next study actions.""" + import time + t0 = time.time() + result = self.ai.suggest_study_plan(student_profile) + self._log("coach_suggest", int((time.time() - t0) * 1000), out=json.dumps(result)[:500]) + return result + + def writing_help(self, task, draft, help_type="improve"): + """Help with writing tasks.""" + import time + t0 = time.time() + result = self.ai.writing_help(task, draft, help_type) + self._log("coach_writing", int((time.time() - t0) * 1000), inp=draft[:200], out=json.dumps(result)[:500]) + return result + + def get_hint(self, question_context): + """Give a hint for a question without revealing the answer.""" + import time + t0 = time.time() + messages = [ + {"role": "system", "content": ( + "Give a helpful hint for this question WITHOUT revealing the answer. " + "Guide the student's thinking. Return JSON: {\"hint\": string, \"strategy\": string}" + )}, + {"role": "user", "content": json.dumps(question_context)}, + ] + result = self.ai.chat_json(messages, model=self.ai.fast_model, action="coach_hint", max_tokens=256) + self._log("coach_hint", int((time.time() - t0) * 1000), out=json.dumps(result)[:500]) + return result diff --git a/backend/custom_addons/encoach_ai/services/elai_service.py b/backend/custom_addons/encoach_ai/services/elai_service.py new file mode 100644 index 00000000..bba973d1 --- /dev/null +++ b/backend/custom_addons/encoach_ai/services/elai_service.py @@ -0,0 +1,108 @@ +"""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"), + } diff --git a/backend/custom_addons/encoach_ai/services/elevenlabs_service.py b/backend/custom_addons/encoach_ai/services/elevenlabs_service.py new file mode 100644 index 00000000..e1ea474a --- /dev/null +++ b/backend/custom_addons/encoach_ai/services/elevenlabs_service.py @@ -0,0 +1,103 @@ +"""ElevenLabs text-to-speech service.""" + +import logging +import time + +_logger = logging.getLogger(__name__) + +try: + import requests as _requests +except ImportError: + _requests = None + +ELEVENLABS_BASE = "https://api.elevenlabs.io/v1" + +DEFAULT_VOICES = { + "female_british": "21m00Tcm4TlvDq8ikWAM", # Rachel + "male_british": "VR6AewLTigWG4xSOukaG", # Arnold + "female_american": "EXAVITQu4vr4xnSDxMaL", # Bella + "male_american": "TxGEqnHWrfWFTfGW9XjX", # Josh +} + + +class ElevenLabsService: + """ElevenLabs TTS — higher quality multilingual voices.""" + + def __init__(self, env): + self.env = env + self._get_param = env["ir.config_parameter"].sudo().get_param + + def _get_key(self): + key = self._get_param("encoach_ai.elevenlabs_api_key", "") + if not key: + import os + key = os.environ.get("ELEVENLABS_API_KEY", "") + if not key: + raise RuntimeError("ElevenLabs API key not configured — set in AI Settings") + return key + + def _log(self, action, latency, status="success", error=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "elevenlabs", + "action": action, + "latency_ms": latency, + "status": status, + "error_message": error, + }) + except Exception: + pass + + def synthesize(self, text, *, voice_id=None, voice_key="female_british", + model=None, output_format="mp3_44100_128"): + """Convert text to speech using ElevenLabs. + + Returns: + dict with 'audio' (bytes), 'content_type', 'voice_id', 'characters' + """ + if not _requests: + raise RuntimeError("requests package not installed") + key = self._get_key() + voice_id = voice_id or DEFAULT_VOICES.get(voice_key, list(DEFAULT_VOICES.values())[0]) + model = model or self._get_param("encoach_ai.elevenlabs_model", "eleven_multilingual_v2") + + url = f"{ELEVENLABS_BASE}/text-to-speech/{voice_id}" + t0 = time.time() + try: + resp = _requests.post( + url, + json={ + "text": text, + "model_id": model, + "voice_settings": {"stability": 0.5, "similarity_boost": 0.75}, + }, + headers={"xi-api-key": key, "Accept": "audio/mpeg"}, + params={"output_format": output_format}, + timeout=60, + ) + resp.raise_for_status() + latency = int((time.time() - t0) * 1000) + self._log("synthesize", latency) + return { + "audio": resp.content, + "content_type": "audio/mpeg", + "voice_id": voice_id, + "characters": len(text), + } + except Exception as exc: + self._log("synthesize", int((time.time() - t0) * 1000), "error", str(exc)) + raise + + def list_voices(self): + """List available ElevenLabs voices.""" + key = self._get_key() + resp = _requests.get( + f"{ELEVENLABS_BASE}/voices", + headers={"xi-api-key": key}, + timeout=15, + ) + resp.raise_for_status() + return [ + {"voice_id": v["voice_id"], "name": v["name"], "labels": v.get("labels", {})} + for v in resp.json().get("voices", []) + ] diff --git a/backend/custom_addons/encoach_ai/services/gptzero_service.py b/backend/custom_addons/encoach_ai/services/gptzero_service.py new file mode 100644 index 00000000..6e15144a --- /dev/null +++ b/backend/custom_addons/encoach_ai/services/gptzero_service.py @@ -0,0 +1,87 @@ +"""GPTZero AI content detection service.""" + +import logging +import time + +_logger = logging.getLogger(__name__) + +try: + import requests as _requests +except ImportError: + _requests = None + +GPTZERO_BASE = "https://api.gptzero.me/v2" + + +class GPTZeroService: + """Detect AI-generated content in student submissions.""" + + def __init__(self, env): + self.env = env + self._get_param = env["ir.config_parameter"].sudo().get_param + + def _get_key(self): + key = self._get_param("encoach_ai.gptzero_api_key", "") + if not key: + import os + key = os.environ.get("GPT_ZERO_API_KEY", "") + if not key: + raise RuntimeError("GPTZero API key not configured — set in AI Settings") + return key + + def _log(self, action, latency, status="success", error=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "gptzero", + "action": action, + "latency_ms": latency, + "status": status, + "error_message": error, + }) + except Exception: + pass + + def detect(self, text): + """Check if text is AI-generated. + + Returns: + dict with 'is_ai_generated' (bool), 'ai_probability' (float 0-1), + 'human_probability' (float), 'sentences' (list of per-sentence scores) + """ + if not _requests: + raise RuntimeError("requests package not installed") + key = self._get_key() + t0 = time.time() + try: + resp = _requests.post( + f"{GPTZERO_BASE}/predict/text", + json={"document": text}, + headers={"x-api-key": key, "Content-Type": "application/json"}, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + doc = data.get("documents", [{}])[0] if data.get("documents") else {} + result = { + "is_ai_generated": doc.get("completely_generated_prob", 0) > 0.5, + "ai_probability": doc.get("completely_generated_prob", 0), + "human_probability": 1 - doc.get("completely_generated_prob", 0), + "mixed_probability": doc.get("average_generated_prob", 0), + "sentences": [ + { + "text": s.get("sentence", ""), + "ai_probability": s.get("generated_prob", 0), + "is_ai": s.get("generated_prob", 0) > 0.5, + } + for s in doc.get("sentences", []) + ], + } + self._log("detect", int((time.time() - t0) * 1000)) + return result + except Exception as exc: + self._log("detect", int((time.time() - t0) * 1000), "error", str(exc)) + raise + + def detect_batch(self, texts): + """Check multiple texts for AI generation.""" + return [self.detect(t) for t in texts] diff --git a/backend/custom_addons/encoach_ai/services/openai_service.py b/backend/custom_addons/encoach_ai/services/openai_service.py new file mode 100644 index 00000000..622885c7 --- /dev/null +++ b/backend/custom_addons/encoach_ai/services/openai_service.py @@ -0,0 +1,343 @@ +"""OpenAI GPT service — chat completions, JSON mode, structured generation.""" + +import json +import logging +import time + +_logger = logging.getLogger(__name__) + +try: + import openai as _openai_mod +except ImportError: + _openai_mod = None + + +class OpenAIService: + """Wraps the OpenAI Python SDK with Odoo settings and logging.""" + + def __init__(self, env): + self.env = env + self._get_param = env["ir.config_parameter"].sudo().get_param + self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes") + self.max_retries = int(self._get_param("encoach_ai.max_retries", "3")) + api_key = self._get_param("encoach_ai.openai_api_key", "") + if not api_key: + import os + api_key = os.environ.get("OPENAI_API_KEY", "") + if _openai_mod and api_key: + self.client = _openai_mod.OpenAI(api_key=api_key) + else: + self.client = None + self.model = self._get_param("encoach_ai.openai_model", "gpt-4o") + self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-3.5-turbo") + + def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "openai", + "action": action, + "model_used": model, + "prompt_tokens": getattr(usage, "prompt_tokens", 0) if usage else 0, + "completion_tokens": getattr(usage, "completion_tokens", 0) if usage else 0, + "total_tokens": getattr(usage, "total_tokens", 0) if usage else 0, + "latency_ms": latency, + "status": status, + "error_message": error, + "input_preview": (inp or "")[:500], + "output_preview": (out or "")[:500], + }) + except Exception: + _logger.warning("Failed to log AI call", exc_info=True) + + def _check_enabled(self): + if not self.enabled: + raise RuntimeError("AI is disabled — enable in Settings > AI Configuration") + + def _retry_with_backoff(self, fn, action, model): + """Execute fn with exponential backoff retries.""" + last_exc = None + for attempt in range(self.max_retries): + try: + return fn() + except Exception as exc: + last_exc = exc + err_str = str(exc).lower() + is_rate_limit = "rate" in err_str or "429" in err_str + is_server_error = "500" in err_str or "502" in err_str or "503" in err_str + if not (is_rate_limit or is_server_error) or attempt == self.max_retries - 1: + raise + wait = min(2 ** attempt, 16) + _logger.warning("AI retry %d/%d for %s (wait %ds): %s", + attempt + 1, self.max_retries, action, wait, exc) + time.sleep(wait) + raise last_exc + + def chat(self, messages, *, model=None, temperature=0.7, max_tokens=2048, action="chat"): + """Standard chat completion. Returns the assistant message content string.""" + self._check_enabled() + if not self.client: + raise RuntimeError("OpenAI not configured — set API key in AI Settings") + model = model or self.model + t0 = time.time() + try: + def _call(): + return self.client.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + ) + resp = self._retry_with_backoff(_call, action, model) + content = resp.choices[0].message.content + self._log(action, model, resp.usage, int((time.time() - t0) * 1000), + inp=json.dumps(messages[-1:])[:500], out=content[:500]) + return content + except Exception as exc: + self._log(action, model, None, int((time.time() - t0) * 1000), + status="error", error=str(exc)) + raise + + def chat_json(self, messages, *, model=None, temperature=0.3, max_tokens=4096, action="chat_json"): + """Chat completion with JSON response format. Returns parsed dict/list.""" + self._check_enabled() + if not self.client: + raise RuntimeError("OpenAI not configured — set API key in AI Settings") + model = model or self.model + t0 = time.time() + try: + def _call(): + return self.client.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + response_format={"type": "json_object"}, + ) + resp = self._retry_with_backoff(_call, action, model) + raw = resp.choices[0].message.content + self._log(action, model, resp.usage, int((time.time() - t0) * 1000), + inp=json.dumps(messages[-1:])[:500], out=raw[:500]) + return json.loads(raw) + except Exception as exc: + self._log(action, model, None, int((time.time() - t0) * 1000), + status="error", error=str(exc)) + raise + + def chat_fast(self, messages, **kwargs): + """Use the fast/cheap model for classification, tagging, simple tasks.""" + return self.chat(messages, model=self.fast_model, **kwargs) + + def grade_writing(self, rubric, task_text, response_text): + """Grade a writing response using GPT with a rubric.""" + messages = [ + {"role": "system", "content": ( + "You are an expert IELTS examiner. Grade the following response using the rubric provided. " + "Return JSON: {\"scores\": {\"task_achievement\": float, \"coherence_cohesion\": float, " + "\"lexical_resource\": float, \"grammatical_range\": float}, " + "\"overall_band\": float, \"feedback\": string, \"suggestions\": [string]}" + )}, + {"role": "user", "content": f"## Rubric\n{rubric}\n\n## Task\n{task_text}\n\n## Student Response\n{response_text}"}, + ] + return self.chat_json(messages, action="grade_writing") + + def grade_speaking(self, rubric, transcript): + """Grade a speaking transcript using GPT.""" + messages = [ + {"role": "system", "content": ( + "You are an expert IELTS Speaking examiner. Grade the transcript. " + "Return JSON: {\"scores\": {\"fluency_coherence\": float, \"lexical_resource\": float, " + "\"grammatical_range\": float, \"pronunciation\": float}, " + "\"overall_band\": float, \"feedback\": string, \"suggestions\": [string]}" + )}, + {"role": "user", "content": f"## Rubric\n{rubric}\n\n## Transcript\n{transcript}"}, + ] + return self.chat_json(messages, action="grade_speaking") + + def generate_content(self, content_type, brief, *, cefr_level="B2"): + """Generate educational content (reading passage, grammar exercise, etc.).""" + messages = [ + {"role": "system", "content": ( + f"You are an expert EFL content creator. Generate a {content_type} " + f"at CEFR {cefr_level} level. Return well-structured JSON with the content, " + "questions/exercises if applicable, answer keys, and metadata." + )}, + {"role": "user", "content": json.dumps(brief)}, + ] + return self.chat_json(messages, action=f"generate_{content_type}", max_tokens=4096) + + def explain_grade(self, score_data, student_context=""): + """Explain a grade to a student in simple terms.""" + messages = [ + {"role": "system", "content": ( + "You are a supportive English learning coach. Explain the grade to the student " + "in an encouraging way. Highlight strengths, then areas for improvement with " + "concrete tips. Keep it under 200 words." + )}, + {"role": "user", "content": f"Score data: {json.dumps(score_data)}\nContext: {student_context}"}, + ] + return self.chat(messages, model=self.fast_model, action="explain_grade") + + def search_answer(self, query, context=""): + """Answer a natural language search query about the platform.""" + messages = [ + {"role": "system", "content": ( + "You are an intelligent assistant for the EnCoach IELTS & English learning platform. " + "Answer the query based on available context. Be concise and helpful. " + "Return JSON: {\"answer\": string, \"suggestions\": [string], \"related_actions\": [{\"label\": string, \"action\": string}]}" + )}, + {"role": "user", "content": f"Query: {query}\nContext: {context}"}, + ] + return self.chat_json(messages, model=self.fast_model, action="search") + + def generate_insights(self, data_summary, insight_type="general"): + """Generate AI insights from data.""" + messages = [ + {"role": "system", "content": ( + f"You are a data analyst for an education platform. Generate {insight_type} insights. " + "Return JSON: {\"insights\": [{\"title\": string, \"description\": string, " + "\"severity\": \"info\"|\"warning\"|\"critical\", \"recommendation\": string}]}" + )}, + {"role": "user", "content": json.dumps(data_summary)}, + ] + return self.chat_json(messages, model=self.fast_model, action="insights") + + def generate_report_narrative(self, report_type, data): + """Generate a human-readable narrative for a report.""" + messages = [ + {"role": "system", "content": ( + f"Write a concise professional narrative summary for a {report_type} report. " + "2-3 paragraphs. Highlight key trends, concerns, and recommendations." + )}, + {"role": "user", "content": json.dumps(data)}, + ] + return self.chat(messages, model=self.fast_model, action="report_narrative") + + def suggest_study_plan(self, student_profile): + """Suggest a personalized study plan.""" + messages = [ + {"role": "system", "content": ( + "You are an IELTS preparation expert coach. Create a personalized study suggestion. " + "Return JSON: {\"suggestion\": string, \"focus_areas\": [string], " + "\"daily_plan\": [{\"activity\": string, \"duration_min\": int, \"skill\": string}], " + "\"motivation\": string}" + )}, + {"role": "user", "content": json.dumps(student_profile)}, + ] + return self.chat_json(messages, model=self.fast_model, action="study_suggest") + + def writing_help(self, task, draft, help_type="improve"): + """Provide writing assistance.""" + messages = [ + {"role": "system", "content": ( + f"You are a writing tutor. Help the student {help_type} their draft. " + "Return JSON: {\"improved_text\": string, \"changes\": [{\"original\": string, " + "\"revised\": string, \"reason\": string}], \"tips\": [string]}" + )}, + {"role": "user", "content": f"Task: {task}\n\nDraft:\n{draft}"}, + ] + return self.chat_json(messages, action="writing_help") + + def batch_optimize(self, items, optimization_type="schedule"): + """Optimize a batch of items (schedule, grouping, etc.).""" + messages = [ + {"role": "system", "content": ( + f"You are an optimization specialist. Optimize these items for {optimization_type}. " + "Return JSON: {\"optimized\": [items with suggested changes], \"summary\": string, \"impact\": string}" + )}, + {"role": "user", "content": json.dumps(items)}, + ] + return self.chat_json(messages, action="batch_optimize") + + # ── RAG-enhanced methods ───────────────────────────────────────── + + def _get_vector_context(self, query, *, content_types=None, limit=5): + """Retrieve relevant context from the vector store.""" + try: + from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService + svc = EmbeddingService(self.env) + if content_types: + results = [] + for ct in content_types: + results.extend(svc.search(query, content_type=ct, limit=limit)) + results.sort(key=lambda r: r['similarity'], reverse=True) + return results[:limit] + return svc.search(query, limit=limit) + except Exception: + _logger.debug("Vector search unavailable, proceeding without RAG", exc_info=True) + return [] + + def _format_context(self, vector_results): + """Format vector search results as context for the LLM.""" + if not vector_results: + return "" + parts = [] + for r in vector_results: + text = (r.get('text') or '')[:500] + meta = r.get('metadata', {}) + label = f"[{r['content_type']}#{r['content_id']}]" + if meta: + label += f" ({', '.join(f'{k}={v}' for k, v in meta.items())})" + parts.append(f"{label}\n{text}") + return "\n---\n".join(parts) + + def chat_with_context(self, messages, query, *, content_types=None, limit=5, **kwargs): + """RAG-enhanced chat: search vectors, inject context, then call GPT.""" + context_results = self._get_vector_context(query, content_types=content_types, limit=limit) + if context_results: + context_text = self._format_context(context_results) + rag_msg = { + "role": "system", + "content": ( + "The following relevant content was found in the knowledge base. " + "Use it to provide accurate, contextual answers:\n\n" + context_text + ), + } + messages = [messages[0], rag_msg] + messages[1:] + kwargs.setdefault("action", "chat_rag") + return self.chat(messages, **kwargs) + + def search_with_rag(self, query, context=""): + """RAG-enhanced search: vector search + GPT synthesis.""" + vector_results = self._get_vector_context(query, limit=8) + context_text = self._format_context(vector_results) + + messages = [ + {"role": "system", "content": ( + "You are an intelligent assistant for the EnCoach IELTS & English learning platform. " + "Answer the query based on the knowledge base content provided below. " + "Be concise, accurate, and cite specific content when possible. " + "Return JSON: {\"answer\": string, \"suggestions\": [string], " + "\"related_actions\": [{\"label\": string, \"action\": string}], " + "\"sources\": [{\"type\": string, \"id\": number}]}" + )}, + ] + if context_text: + messages.append({"role": "system", "content": f"Knowledge base:\n{context_text}"}) + if context: + messages.append({"role": "system", "content": f"Additional context: {context}"}) + messages.append({"role": "user", "content": f"Query: {query}"}) + + return self.chat_json(messages, model=self.fast_model, action="search_rag") + + def generate_content_dedup(self, content_type, brief, *, cefr_level="B2"): + """Generate content with dedup-awareness: checks for similar existing content.""" + brief_text = json.dumps(brief) if isinstance(brief, dict) else str(brief) + similar = self._get_vector_context(brief_text, content_types=[content_type], limit=3) + + messages = [ + {"role": "system", "content": ( + f"You are an expert EFL content creator. Generate a {content_type} " + f"at CEFR {cefr_level} level. Return well-structured JSON with the content, " + "questions/exercises if applicable, answer keys, and metadata." + )}, + ] + if similar: + context_text = self._format_context(similar) + messages.append({"role": "system", "content": ( + "IMPORTANT: The following similar content already exists. " + "Make your output DISTINCT — different angles, examples, or approaches. " + "Do NOT duplicate existing content:\n\n" + context_text + )}) + messages.append({"role": "user", "content": brief_text}) + + return self.chat_json(messages, action=f"generate_{content_type}_dedup", max_tokens=4096) diff --git a/backend/custom_addons/encoach_ai/services/polly_service.py b/backend/custom_addons/encoach_ai/services/polly_service.py new file mode 100644 index 00000000..ac05ea7b --- /dev/null +++ b/backend/custom_addons/encoach_ai/services/polly_service.py @@ -0,0 +1,102 @@ +"""AWS Polly text-to-speech service.""" + +import logging +import time + +_logger = logging.getLogger(__name__) + +try: + import boto3 as _boto3 +except ImportError: + _boto3 = None + +VOICE_MAP = { + "en-GB": {"female": "Amy", "male": "Brian"}, + "en-US": {"female": "Joanna", "male": "Matthew"}, + "en-AU": {"female": "Nicole", "male": "Russell"}, + "en-IN": {"female": "Aditi", "male": "Aditi"}, +} + + +class PollyService: + """AWS Polly TTS for generating listening exam audio.""" + + def __init__(self, env): + self.env = env + self._get_param = env["ir.config_parameter"].sudo().get_param + self._client = None + + def _get_client(self): + if self._client: + return self._client + if not _boto3: + raise RuntimeError("boto3 not installed — run: pip install boto3") + access_key = self._get_param("encoach_ai.aws_access_key", "") + secret_key = self._get_param("encoach_ai.aws_secret_key", "") + region = self._get_param("encoach_ai.aws_region", "eu-west-1") + if not access_key or not secret_key: + import os + access_key = access_key or os.environ.get("AWS_ACCESS_KEY_ID", "") + secret_key = secret_key or os.environ.get("AWS_SECRET_ACCESS_KEY", "") + if not access_key: + raise RuntimeError("AWS credentials not configured — set in AI Settings") + self._client = _boto3.client( + "polly", + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + region_name=region, + ) + return self._client + + def _log(self, action, latency, status="success", error=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "polly", + "action": action, + "latency_ms": latency, + "status": status, + "error_message": error, + }) + except Exception: + pass + + def synthesize(self, text, *, voice=None, language="en-GB", gender="female", + engine="neural", output_format="mp3"): + """Convert text to speech audio bytes. + + Returns: + dict with 'audio' (bytes), 'content_type', 'voice', 'characters' + """ + client = self._get_client() + if not voice: + voice = VOICE_MAP.get(language, VOICE_MAP["en-GB"]).get(gender, "Amy") + t0 = time.time() + try: + resp = client.synthesize_speech( + Text=text, + OutputFormat=output_format, + VoiceId=voice, + Engine=engine, + LanguageCode=language, + ) + audio = resp["AudioStream"].read() + latency = int((time.time() - t0) * 1000) + self._log("synthesize", latency) + return { + "audio": audio, + "content_type": resp["ContentType"], + "voice": voice, + "characters": len(text), + } + except Exception as exc: + self._log("synthesize", int((time.time() - t0) * 1000), "error", str(exc)) + raise + + def list_voices(self, language="en-GB"): + """List available voices for a language.""" + client = self._get_client() + resp = client.describe_voices(LanguageCode=language) + return [ + {"id": v["Id"], "name": v["Name"], "gender": v["Gender"], "engine": v.get("SupportedEngines", [])} + for v in resp.get("Voices", []) + ] diff --git a/backend/custom_addons/encoach_ai/services/whisper_service.py b/backend/custom_addons/encoach_ai/services/whisper_service.py new file mode 100644 index 00000000..0f7dbd69 --- /dev/null +++ b/backend/custom_addons/encoach_ai/services/whisper_service.py @@ -0,0 +1,110 @@ +"""OpenAI Whisper speech-to-text service.""" + +import logging +import tempfile +import time + +_logger = logging.getLogger(__name__) + +try: + import whisper as _whisper_mod +except ImportError: + _whisper_mod = None + +try: + import openai as _openai_mod +except ImportError: + _openai_mod = None + + +class WhisperService: + """Speech-to-text via local Whisper model or OpenAI Whisper API.""" + + def __init__(self, env): + self.env = env + self._get_param = env["ir.config_parameter"].sudo().get_param + self._local_model = None + api_key = self._get_param("encoach_ai.openai_api_key", "") + if not api_key: + import os + api_key = os.environ.get("OPENAI_API_KEY", "") + self._api_key = api_key + + def _get_local_model(self): + if not _whisper_mod: + return None + if self._local_model is None: + self._local_model = _whisper_mod.load_model("base") + return self._local_model + + def _log(self, action, latency, status="success", error=None): + try: + self.env["encoach.ai.log"].sudo().create({ + "service": "whisper", + "action": action, + "latency_ms": latency, + "status": status, + "error_message": error, + }) + except Exception: + pass + + def transcribe(self, audio_data, *, language="en", use_api=False): + """Transcribe audio bytes to text. + + Args: + audio_data: Raw audio bytes (wav, mp3, webm, etc.) + language: Language code + use_api: If True, use OpenAI Whisper API instead of local model + Returns: + dict with 'text', 'language', 'segments' keys + """ + t0 = time.time() + + if use_api and self._api_key and _openai_mod: + return self._transcribe_api(audio_data, language, t0) + + model = self._get_local_model() + if model: + return self._transcribe_local(model, audio_data, language, t0) + + if self._api_key and _openai_mod: + return self._transcribe_api(audio_data, language, t0) + + raise RuntimeError("Whisper not available — install whisper package or set OpenAI API key") + + def _transcribe_local(self, model, audio_data, language, t0): + with tempfile.NamedTemporaryFile(suffix=".webm", delete=True) as tmp: + tmp.write(audio_data) + tmp.flush() + result = model.transcribe(tmp.name, language=language) + latency = int((time.time() - t0) * 1000) + self._log("transcribe_local", latency) + return { + "text": result["text"].strip(), + "language": result.get("language", language), + "segments": [ + {"start": s["start"], "end": s["end"], "text": s["text"]} + for s in result.get("segments", []) + ], + } + + def _transcribe_api(self, audio_data, language, t0): + client = _openai_mod.OpenAI(api_key=self._api_key) + with tempfile.NamedTemporaryFile(suffix=".webm", delete=True) as tmp: + tmp.write(audio_data) + tmp.flush() + tmp.seek(0) + result = client.audio.transcriptions.create( + model="whisper-1", + file=tmp, + language=language, + response_format="verbose_json", + ) + latency = int((time.time() - t0) * 1000) + self._log("transcribe_api", latency) + return { + "text": result.text.strip() if hasattr(result, "text") else str(result), + "language": language, + "segments": getattr(result, "segments", []), + } diff --git a/backend/custom_addons/encoach_ai/views/ai_settings_views.xml b/backend/custom_addons/encoach_ai/views/ai_settings_views.xml new file mode 100644 index 00000000..b71e766f --- /dev/null +++ b/backend/custom_addons/encoach_ai/views/ai_settings_views.xml @@ -0,0 +1,64 @@ + + + + res.config.settings.view.form.encoach.ai + res.config.settings + 90 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/custom_addons/encoach_ai_course/__manifest__.py b/backend/custom_addons/encoach_ai_course/__manifest__.py index 7475023e..32086907 100644 --- a/backend/custom_addons/encoach_ai_course/__manifest__.py +++ b/backend/custom_addons/encoach_ai_course/__manifest__.py @@ -5,7 +5,7 @@ 'summary': 'AI content generation pipelines for General English and IELTS courses', 'author': 'EnCoach', 'license': 'LGPL-3', - 'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen'], + 'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen', 'encoach_ai'], 'data': [ 'security/ir.model.access.csv', 'views/ai_generation_log_views.xml', diff --git a/backend/custom_addons/encoach_ai_course/controllers/ai_course.py b/backend/custom_addons/encoach_ai_course/controllers/ai_course.py index 098d2c0a..e98f56da 100644 --- a/backend/custom_addons/encoach_ai_course/controllers/ai_course.py +++ b/backend/custom_addons/encoach_ai_course/controllers/ai_course.py @@ -263,6 +263,171 @@ class EncoachAiCourseController(http.Controller): _logger.exception('validation check failed') return _error_response(str(e), 500) + # ------------------------------------------------------------------ + # GET /api/ai-course/ + # ------------------------------------------------------------------ + @http.route('/api/ai-course/', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def get_course(self, course_id, **kw): + try: + Log = request.env['encoach.ai.generation.log'].sudo() + log = Log.browse(course_id) + if not log.exists(): + IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo() + ielts = IeltsLog.browse(course_id) + if not ielts.exists(): + return _error_response('Course/log not found', 404) + return _json_response({ + 'id': ielts.id, + 'type': 'ielts', + 'skill': ielts.skill or '', + 'status': ielts.status or '', + 'review_status': getattr(ielts, 'review_status', ''), + 'created_at': ielts.create_date.isoformat() if ielts.create_date else '', + }) + + brief = {} + try: + brief = json.loads(log.brief or '{}') + except (json.JSONDecodeError, TypeError): + pass + + return _json_response({ + 'id': log.id, + 'type': 'general_english', + 'status': log.status or '', + 'course_type': log.course_type or '', + 'brief': brief, + 'attempts': log.attempts, + 'student_id': log.student_id.id if log.student_id else None, + 'created_at': log.create_date.isoformat() if log.create_date else '', + }) + + except Exception as e: + _logger.exception('get_course failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai-course//tracks + # ------------------------------------------------------------------ + @http.route('/api/ai-course//tracks', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def get_tracks(self, course_id, **kw): + try: + Log = request.env['encoach.ai.generation.log'].sudo() + log = Log.browse(course_id) + if not log.exists(): + return _error_response('Course not found', 404) + + generated = {} + try: + generated = json.loads(log.generated_content or '{}') + except (json.JSONDecodeError, TypeError): + pass + + tracks = [] + modules = generated.get('modules', []) + for i, mod in enumerate(modules): + tracks.append({ + 'index': i, + 'title': mod.get('title', f'Module {i+1}'), + 'skill': mod.get('skill', ''), + 'status': 'completed' if i == 0 else 'locked', + 'progress': 100 if i == 0 else 0, + }) + + if not tracks: + tracks = [{ + 'index': 0, + 'title': 'Course content pending generation', + 'skill': '', + 'status': 'pending', + 'progress': 0, + }] + + return _json_response({'tracks': tracks}) + + except Exception as e: + _logger.exception('get_tracks failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # GET /api/ai-course/english/taxonomy + # ------------------------------------------------------------------ + @http.route('/api/ai-course/english/taxonomy', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def english_taxonomy(self, **kw): + try: + taxonomy = { + 'skills': ['reading', 'listening', 'writing', 'speaking', 'grammar', 'vocabulary'], + 'cefr_levels': ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'], + 'content_types': ['lesson', 'exercise', 'assessment', 'review'], + 'topic_domains': [ + 'daily_life', 'work', 'education', 'travel', + 'technology', 'environment', 'health', 'culture', + ], + } + + Taxonomy = request.env.get('encoach.taxonomy.domain') + if Taxonomy: + domains = Taxonomy.sudo().search([]) + if domains: + taxonomy['topic_domains'] = [ + {'id': d.id, 'name': d.name, 'description': getattr(d, 'description', '')} + for d in domains + ] + + return _json_response(taxonomy) + + except Exception as e: + _logger.exception('english_taxonomy failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/ai-course/examiner-review + # ------------------------------------------------------------------ + @http.route('/api/ai-course/examiner-review', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def examiner_review(self, **kw): + try: + body = _get_json_body() + log_id = body.get('log_id') + action = body.get('action') + examiner_notes = body.get('examiner_notes', '') + + if not log_id: + return _error_response('log_id is required', 400) + if action not in ('approve', 'reject', 'revise'): + return _error_response('action must be approve, reject, or revise', 400) + + IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo() + log = IeltsLog.browse(int(log_id)) + if not log.exists(): + return _error_response('Log not found', 404) + + status_map = { + 'approve': 'approved', + 'reject': 'rejected', + 'revise': 'revision_needed', + } + + log.write({ + 'review_status': status_map[action], + 'examiner_id': request.env.user.id, + 'examiner_notes': examiner_notes, + 'reviewed_at': fields.Datetime.now(), + }) + + return _json_response({'status': status_map[action], 'log_id': log_id}) + + except Exception as e: + _logger.exception('examiner_review failed') + return _error_response(str(e), 500) + # ------------------------------------------------------------------ # GET /api/ai-course/review-queue # ------------------------------------------------------------------ diff --git a/backend/custom_addons/encoach_scoring/__manifest__.py b/backend/custom_addons/encoach_scoring/__manifest__.py index 8e24a004..d318cf4c 100644 --- a/backend/custom_addons/encoach_scoring/__manifest__.py +++ b/backend/custom_addons/encoach_scoring/__manifest__.py @@ -5,7 +5,7 @@ 'summary': 'Exam scoring, grading queue, feedback, and score release management', 'author': 'EnCoach', 'license': 'LGPL-3', - 'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen', 'encoach_resources'], + 'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen', 'encoach_resources', 'encoach_ai'], 'data': [ 'security/ir.model.access.csv', 'views/student_attempt_views.xml', diff --git a/backend/custom_addons/encoach_scoring/controllers/grading.py b/backend/custom_addons/encoach_scoring/controllers/grading.py index 8512fe0b..a37f4ca4 100644 --- a/backend/custom_addons/encoach_scoring/controllers/grading.py +++ b/backend/custom_addons/encoach_scoring/controllers/grading.py @@ -338,34 +338,52 @@ class EncoachGradingController(http.Controller): student_response = ans.answer if ans else '' - suggested_score = question.marks * 0.5 - suggested_feedback = ( - f"AI suggestion for {question.skill} {question.question_type} question. " - f"Student provided a response of {len(student_response)} characters. " - f"Suggested mid-range score based on rubric criteria." - ) - confidence = 0.6 - if not student_response: - suggested_score = 0.0 - suggested_feedback = "No response provided by student." - confidence = 0.95 + return _json_response({ + 'suggested_score': 0.0, + 'suggested_feedback': 'No response provided by student.', + 'confidence': 0.95, + }) - rubric = None + rubric_text = "IELTS Band Descriptors" if attempt.exam_id and attempt.exam_id.template_id: Rubric = request.env['encoach.rubric'].sudo() - rubric = Rubric.search([ - ('skill', '=', question.skill), - ], limit=1) + rubric_rec = Rubric.search([('skill', '=', question.skill)], limit=1) + if rubric_rec: + rubric_text = rubric_rec.name - if rubric: - suggested_feedback += f" Rubric '{rubric.name}' criteria should be applied." - - return _json_response({ - 'suggested_score': round(suggested_score, 1), - 'suggested_feedback': suggested_feedback, - 'confidence': confidence, - }) + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(request.env) + skill = question.skill or 'writing' + if skill in ('speaking',): + result = ai.grade_speaking(rubric_text, student_response) + else: + result = ai.grade_writing( + rubric_text, + question.body or question.name or '', + student_response, + ) + overall = result.get('overall_band', 0) + suggested_score = min(overall / 9.0 * question.marks, question.marks) + return _json_response({ + 'suggested_score': round(suggested_score, 1), + 'suggested_feedback': result.get('feedback', ''), + 'confidence': 0.85, + 'scores': result.get('scores', {}), + 'suggestions': result.get('suggestions', []), + }) + except Exception as ai_err: + _logger.warning('AI grading unavailable, using heuristic: %s', ai_err) + suggested_score = question.marks * 0.5 + return _json_response({ + 'suggested_score': round(suggested_score, 1), + 'suggested_feedback': ( + f"AI grading unavailable ({ai_err}). " + f"Heuristic: mid-range score for {len(student_response)} char response." + ), + 'confidence': 0.4, + }) except Exception as e: _logger.exception('ai_suggest failed') diff --git a/backend/custom_addons/encoach_scoring/services/speaking_evaluator.py b/backend/custom_addons/encoach_scoring/services/speaking_evaluator.py index f0937c4d..db97192e 100644 --- a/backend/custom_addons/encoach_scoring/services/speaking_evaluator.py +++ b/backend/custom_addons/encoach_scoring/services/speaking_evaluator.py @@ -1,67 +1,60 @@ +"""AI-powered speaking assessment using encoach_ai services.""" + import logging _logger = logging.getLogger(__name__) class SpeakingEvaluator: - """AI-powered speaking assessment using Whisper + GPT.""" + """AI-powered speaking assessment using Whisper + GPT via encoach_ai.""" + + def __init__(self, env=None): + self.env = env + + def transcribe_audio(self, audio_path_or_bytes): + """Transcribe audio using the encoach_ai WhisperService.""" + try: + from odoo.addons.encoach_ai.services.whisper_service import WhisperService + whisper = WhisperService(self.env) + if isinstance(audio_path_or_bytes, (bytes, bytearray)): + return whisper.transcribe(audio_path_or_bytes, use_api=True) + with open(audio_path_or_bytes, "rb") as f: + return whisper.transcribe(f.read(), use_api=True) + except ImportError: + _logger.warning("encoach_ai not installed, falling back to direct whisper") + return self._fallback_transcribe(audio_path_or_bytes) + except Exception as e: + _logger.error("Transcription error: %s", e) + return {"text": "", "language": "en", "segments": [], "error": str(e)} + + def evaluate_speaking(self, transcription, rubric_criteria, target_band=6.0): + """Evaluate speaking using encoach_ai OpenAIService.""" + try: + from odoo.addons.encoach_ai.services.openai_service import OpenAIService + ai = OpenAIService(self.env) + result = ai.grade_speaking( + f"Target Band: {target_band}\n{rubric_criteria}", + transcription, + ) + return result + except ImportError: + _logger.warning("encoach_ai not installed") + return {"overall_band": 0, "feedback": "AI evaluation not available"} + except Exception as e: + _logger.error("Speaking evaluation error: %s", e) + return {"overall_band": 0, "feedback": f"Evaluation error: {e}"} @staticmethod - def transcribe_audio(audio_path): - """Transcribe audio using Whisper.""" + def _fallback_transcribe(audio_path): + """Direct whisper fallback if encoach_ai is not available.""" try: import whisper model = whisper.load_model("base") result = model.transcribe(audio_path) return { - 'text': result['text'], - 'language': result.get('language', 'en'), - 'segments': result.get('segments', []), + "text": result["text"], + "language": result.get("language", "en"), + "segments": result.get("segments", []), } except ImportError: - _logger.warning("whisper not installed") - return {'text': '', 'language': 'en', 'segments': [], 'error': 'Whisper not available'} - - @staticmethod - def evaluate_speaking(transcription, rubric_criteria, target_band=6.0): - """Evaluate speaking using OpenAI GPT.""" - try: - import openai - - prompt = ( - "You are an IELTS speaking examiner. Evaluate the following speaking response.\n\n" - f"Target Band: {target_band}\n\n" - f"Rubric Criteria:\n{rubric_criteria}\n\n" - f"Transcription:\n{transcription}\n\n" - "Provide scores for each criterion (0-9 scale) and detailed feedback.\n" - "Return JSON format:\n" - "{\n" - ' "fluency_coherence": {"score": X, "feedback": "..."},\n' - ' "lexical_resource": {"score": X, "feedback": "..."},\n' - ' "grammatical_range": {"score": X, "feedback": "..."},\n' - ' "pronunciation": {"score": X, "feedback": "..."},\n' - ' "overall_band": X,\n' - ' "general_feedback": "..."\n' - "}" - ) - - client = openai.OpenAI() - response = client.chat.completions.create( - model="gpt-4", - messages=[ - {"role": "system", "content": "You are an expert IELTS speaking examiner."}, - {"role": "user", "content": prompt}, - ], - temperature=0.3, - ) - - import json - result = json.loads(response.choices[0].message.content) - return result - - except ImportError: - _logger.warning("openai not installed") - return {'overall_band': 0, 'general_feedback': 'AI evaluation not available', 'error': 'OpenAI not available'} - except Exception as e: - _logger.error("Speaking evaluation error: %s", e) - return {'overall_band': 0, 'general_feedback': f'Evaluation error: {e}'} + return {"text": "", "language": "en", "segments": [], "error": "Whisper not available"} diff --git a/backend/custom_addons/encoach_vector/__init__.py b/backend/custom_addons/encoach_vector/__init__.py new file mode 100644 index 00000000..a08a9799 --- /dev/null +++ b/backend/custom_addons/encoach_vector/__init__.py @@ -0,0 +1,17 @@ +from . import models +from . import services + + +def _post_init_hook(env): + """Run initial vector indexing after module install.""" + import logging + _logger = logging.getLogger(__name__) + try: + from .services.indexer import index_all + count = index_all(env) + _logger.info("Post-init vector indexing complete: %d records", count) + except Exception: + _logger.warning( + "Post-init vector indexing skipped (sentence-transformers may not be installed)", + exc_info=True, + ) diff --git a/backend/custom_addons/encoach_vector/__manifest__.py b/backend/custom_addons/encoach_vector/__manifest__.py new file mode 100644 index 00000000..d827b2ec --- /dev/null +++ b/backend/custom_addons/encoach_vector/__manifest__.py @@ -0,0 +1,20 @@ +{ + 'name': 'EnCoach Vector Search', + 'version': '19.0.1.0', + 'category': 'Education', + 'summary': 'pgvector-based semantic search and embedding storage for AI-enhanced learning', + 'author': 'EnCoach', + 'license': 'LGPL-3', + 'depends': ['encoach_core', 'encoach_ai'], + 'data': [ + 'security/ir.model.access.csv', + 'data/vector_defaults.xml', + ], + 'external_dependencies': { + 'python': ['pgvector', 'sentence_transformers'], + }, + 'installable': True, + 'application': False, + 'auto_install': False, + 'post_init_hook': '_post_init_hook', +} diff --git a/backend/custom_addons/encoach_vector/data/vector_defaults.xml b/backend/custom_addons/encoach_vector/data/vector_defaults.xml new file mode 100644 index 00000000..0c50c472 --- /dev/null +++ b/backend/custom_addons/encoach_vector/data/vector_defaults.xml @@ -0,0 +1,13 @@ + + + + + EnCoach: Vector Re-Index + + code + model.cron_reindex() + 1 + days + True + + diff --git a/backend/custom_addons/encoach_vector/models/__init__.py b/backend/custom_addons/encoach_vector/models/__init__.py new file mode 100644 index 00000000..0e422680 --- /dev/null +++ b/backend/custom_addons/encoach_vector/models/__init__.py @@ -0,0 +1 @@ +from . import embedding diff --git a/backend/custom_addons/encoach_vector/models/embedding.py b/backend/custom_addons/encoach_vector/models/embedding.py new file mode 100644 index 00000000..4877063f --- /dev/null +++ b/backend/custom_addons/encoach_vector/models/embedding.py @@ -0,0 +1,121 @@ +"""Odoo model for storing vector embeddings via pgvector.""" + +import json +import logging +from odoo import api, models, fields + +_logger = logging.getLogger(__name__) + +VECTOR_DIM = 384 # all-MiniLM-L6-v2 output dimension + + +class EncoachEmbedding(models.Model): + _name = 'encoach.embedding' + _description = 'Vector Embedding' + _order = 'create_date desc' + + content_type = fields.Selection([ + ('course', 'Course'), + ('resource', 'Resource'), + ('question', 'Question'), + ('module', 'Module'), + ('topic', 'Topic'), + ('feedback', 'Feedback'), + ('generation_log', 'Generation Log'), + ], required=True, index=True) + content_id = fields.Integer(required=True, index=True) + content_text = fields.Text() + metadata_json = fields.Text(default='{}') + + _content_unique = models.Constraint( + 'UNIQUE(content_type, content_id)', + 'Each content item can only have one embedding.', + ) + + @api.model + def _auto_init(self): + res = super()._auto_init() + cr = self.env.cr + cr.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'") + if not cr.fetchone(): + try: + cr.execute("CREATE EXTENSION IF NOT EXISTS vector") + _logger.info("pgvector extension created") + except Exception: + _logger.warning( + "Could not create pgvector extension — run " + "'CREATE EXTENSION vector' as a superuser", + exc_info=True, + ) + return res + + cr.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name = 'encoach_embedding' AND column_name = 'embedding' + """) + if not cr.fetchone(): + cr.execute( + f"ALTER TABLE encoach_embedding ADD COLUMN embedding vector({VECTOR_DIM})" + ) + cr.execute( + "CREATE INDEX IF NOT EXISTS encoach_embedding_vec_idx " + "ON encoach_embedding USING ivfflat (embedding vector_cosine_ops) " + "WITH (lists = 100)" + ) + _logger.info("Vector column and index created on encoach_embedding") + return res + + def set_embedding(self, vector): + """Store a vector embedding for this record.""" + self.ensure_one() + vec_str = '[' + ','.join(str(v) for v in vector) + ']' + self.env.cr.execute( + "UPDATE encoach_embedding SET embedding = %s WHERE id = %s", + (vec_str, self.id), + ) + + @api.model + def cron_reindex(self): + """Cron entry point for periodic re-indexing.""" + from odoo.addons.encoach_vector.services.indexer import index_all + return index_all(self.env) + + @api.model + def similarity_search(self, query_vector, *, content_type=None, limit=10): + """Find similar embeddings using cosine distance.""" + vec_str = '[' + ','.join(str(v) for v in query_vector) + ']' + where = "WHERE embedding IS NOT NULL" + params = [vec_str, limit] + if content_type: + where += " AND content_type = %s" + params = [vec_str, content_type, limit] + + query = f""" + SELECT id, content_type, content_id, content_text, metadata_json, + 1 - (embedding <=> %s::vector) AS similarity + FROM encoach_embedding + {where} + ORDER BY embedding <=> %s::vector + LIMIT %s + """ + if content_type: + self.env.cr.execute(query, (vec_str, content_type, vec_str, limit)) + else: + self.env.cr.execute(query, (vec_str, vec_str, limit)) + + results = [] + for row in self.env.cr.dictfetchall(): + metadata = {} + try: + metadata = json.loads(row['metadata_json'] or '{}') + except (json.JSONDecodeError, TypeError): + pass + results.append({ + 'id': row['id'], + 'content_type': row['content_type'], + 'content_id': row['content_id'], + 'text': row['content_text'], + 'metadata': metadata, + 'similarity': round(row['similarity'], 4), + }) + return results diff --git a/backend/custom_addons/encoach_vector/security/ir.model.access.csv b/backend/custom_addons/encoach_vector/security/ir.model.access.csv new file mode 100644 index 00000000..d36fc8bb --- /dev/null +++ b/backend/custom_addons/encoach_vector/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_embedding_user,encoach.embedding.user,model_encoach_embedding,base.group_user,1,0,0,0 +access_encoach_embedding_admin,encoach.embedding.admin,model_encoach_embedding,base.group_system,1,1,1,1 diff --git a/backend/custom_addons/encoach_vector/services/__init__.py b/backend/custom_addons/encoach_vector/services/__init__.py new file mode 100644 index 00000000..11513119 --- /dev/null +++ b/backend/custom_addons/encoach_vector/services/__init__.py @@ -0,0 +1,2 @@ +from . import embedding_service +from . import indexer diff --git a/backend/custom_addons/encoach_vector/services/embedding_service.py b/backend/custom_addons/encoach_vector/services/embedding_service.py new file mode 100644 index 00000000..966e7b75 --- /dev/null +++ b/backend/custom_addons/encoach_vector/services/embedding_service.py @@ -0,0 +1,139 @@ +"""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() diff --git a/backend/custom_addons/encoach_vector/services/indexer.py b/backend/custom_addons/encoach_vector/services/indexer.py new file mode 100644 index 00000000..3e74bd6d --- /dev/null +++ b/backend/custom_addons/encoach_vector/services/indexer.py @@ -0,0 +1,127 @@ +"""Indexer — batch-indexes existing Odoo records into the vector store.""" + +import logging + +_logger = logging.getLogger(__name__) + +MODEL_CONFIG = [ + { + 'model': 'op.course', + 'content_type': 'course', + 'text_field': 'name', + 'description_field': 'description', + 'metadata_fields': [], + }, + { + 'model': 'encoach.resource', + 'content_type': 'resource', + 'text_field': 'name', + 'description_field': 'content', + 'metadata_fields': ['type', 'cefr_level', 'difficulty'], + }, + { + 'model': 'encoach.question', + 'content_type': 'question', + 'text_field': 'name', + 'description_field': None, + 'metadata_fields': ['question_type', 'difficulty', 'skill'], + }, + { + 'model': 'encoach.course.module', + 'content_type': 'module', + 'text_field': 'name', + 'description_field': 'description', + 'metadata_fields': ['skill'], + }, + { + 'model': 'encoach.ai.generation.log', + 'content_type': 'generation_log', + 'text_field': 'brief', + 'description_field': 'generated_content', + 'metadata_fields': ['course_type', 'status'], + }, +] + + +def _get_text(record, config): + """Extract indexable text from a record.""" + parts = [] + text_field = config.get('text_field', 'name') + if hasattr(record, text_field): + val = getattr(record, text_field) + if val: + parts.append(str(val)) + + desc_field = config.get('description_field') + if desc_field and hasattr(record, desc_field): + val = getattr(record, desc_field) + if val: + parts.append(str(val)[:2000]) + + return ' '.join(parts).strip() + + +def _get_metadata(record, config): + """Extract metadata dict from a record.""" + meta = {} + for f in config.get('metadata_fields', []): + if hasattr(record, f): + val = getattr(record, f) + if val: + meta[f] = str(val) if not isinstance(val, (int, float, bool)) else val + return meta + + +def index_model(env, config, batch_size=100): + """Index all records of a single model.""" + model_name = config['model'] + Model = env.get(model_name) + if Model is None: + _logger.warning("Model %s not found, skipping", model_name) + return 0 + + Model = Model.sudo() + + from .embedding_service import EmbeddingService + svc = EmbeddingService(env) + + total = Model.search_count([]) + indexed = 0 + offset = 0 + + while offset < total: + records = Model.search([], limit=batch_size, offset=offset, order='id') + batch_data = [] + for rec in records: + text = _get_text(rec, config) + if text: + batch_data.append({ + 'id': rec.id, + 'text': text, + 'metadata': _get_metadata(rec, config), + }) + if batch_data: + indexed += svc.bulk_index(config['content_type'], batch_data) + offset += batch_size + env.cr.commit() + + _logger.info("Indexed %d/%d records for %s", indexed, total, model_name) + return indexed + + +def index_all(env, batch_size=100): + """Index all configured models.""" + total = 0 + for config in MODEL_CONFIG: + try: + total += index_model(env, config, batch_size) + except Exception: + _logger.exception("Failed to index %s", config['model']) + _logger.info("Total records indexed: %d", total) + return total + + +def cron_reindex(env): + """Cron entry point for periodic re-indexing.""" + _logger.info("Starting scheduled vector re-index") + return index_all(env) diff --git a/frontend/src/components/ai/AiAlertBanner.tsx b/frontend/src/components/ai/AiAlertBanner.tsx index e14ec370..f60a9ada 100644 --- a/frontend/src/components/ai/AiAlertBanner.tsx +++ b/frontend/src/components/ai/AiAlertBanner.tsx @@ -8,12 +8,13 @@ export default function AiAlertBanner() { const [dismissedIds, setDismissedIds] = useState>(() => new Set()); const [errorDismissed, setErrorDismissed] = useState(false); - const { data: alerts, isLoading, isError, error } = useQuery({ + const { data: resp, isLoading, isError, error } = useQuery({ queryKey: ["ai", "alerts"], queryFn: () => analyticsService.getAlerts(), }); - const visible = alerts?.filter((a) => !dismissedIds.has(a.id)) ?? []; + const alerts = resp?.alerts ?? []; + const visible = alerts.filter((a, i) => !dismissedIds.has(String(i))); if (isLoading) { return ( @@ -43,7 +44,7 @@ export default function AiAlertBanner() { if (isError && errorDismissed) return null; - if (!alerts?.length) { + if (!alerts.length) { return (
@@ -56,8 +57,8 @@ export default function AiAlertBanner() { return (
- {visible.map((alert) => ( -
+ {visible.map((alert, idx) => ( +

@@ -69,7 +70,7 @@ export default function AiAlertBanner() { variant="ghost" size="icon" className="h-7 w-7 shrink-0" - onClick={() => setDismissedIds((prev) => new Set(prev).add(alert.id))} + onClick={() => setDismissedIds((prev) => new Set(prev).add(String(idx)))} > diff --git a/frontend/src/components/ai/AiAssistantDrawer.tsx b/frontend/src/components/ai/AiAssistantDrawer.tsx index 05ccaf7b..7e52fc4b 100644 --- a/frontend/src/components/ai/AiAssistantDrawer.tsx +++ b/frontend/src/components/ai/AiAssistantDrawer.tsx @@ -26,7 +26,7 @@ export default function AiAssistantDrawer() { mutationFn: (message: string) => coachingService.chat({ message, context: { page: location.pathname } }), onSuccess: (data) => { - setMessages((prev) => [...prev, { role: "ai", text: data.message }]); + setMessages((prev) => [...prev, { role: "ai", text: data.reply }]); }, onError: (err: Error) => { toast({ diff --git a/frontend/src/components/ai/AiBatchOptimizer.tsx b/frontend/src/components/ai/AiBatchOptimizer.tsx index 7a100796..ed222ba1 100644 --- a/frontend/src/components/ai/AiBatchOptimizer.tsx +++ b/frontend/src/components/ai/AiBatchOptimizer.tsx @@ -25,6 +25,8 @@ export default function AiBatchOptimizer({ batchId }: Props) { }, }); + type OptResult = Awaited>; + const handleOpen = () => { if (batchId == null) { toast({ @@ -39,9 +41,23 @@ export default function AiBatchOptimizer({ batchId }: Props) { mutation.mutate(batchId); }; + const applyMutation = useMutation({ + mutationFn: () => analyticsService.applyBatchOptimization(batchId!, mutation.data?.optimized ?? []), + onSuccess: (res) => { + toast({ title: "Suggestion Applied", description: `${res.applied} optimization(s) saved successfully.` }); + setOpen(false); + }, + onError: (err: Error) => { + toast({ + variant: "destructive", + title: "Apply failed", + description: err.message || "Could not apply batch optimization.", + }); + }, + }); + const handleApply = () => { - toast({ title: "Suggestion Applied", description: "Batch split recommendation has been saved successfully." }); - setOpen(false); + applyMutation.mutate(); }; const onOpenChange = (next: boolean) => { @@ -49,9 +65,10 @@ export default function AiBatchOptimizer({ batchId }: Props) { if (!next) mutation.reset(); }; - const suggestions = mutation.data ?? []; - const showResults = !mutation.isPending && !mutation.isError && suggestions.length > 0; - const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && suggestions.length === 0; + const optData = mutation.data as OptResult | undefined; + const hasSuggestions = !!optData?.summary; + const showResults = !mutation.isPending && !mutation.isError && hasSuggestions; + const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && !hasSuggestions; return ( <> @@ -71,20 +88,28 @@ export default function AiBatchOptimizer({ batchId }: Props) {

) : mutation.isError ? (

Something went wrong. Try again.

- ) : showResults ? ( + ) : showResults && optData ? (
-
- {suggestions.map((s, i) => ( -
-

{s.impact} impact

-

{s.suggestion}

- {s.details ?

{s.details}

: null} -
- ))} +
+

{optData.impact} impact

+

{optData.summary}

+ {Array.isArray(optData.optimized) && optData.optimized.length > 0 && ( +
+ {optData.optimized.map((item, i) => ( +
+ {typeof item === "object" && item !== null ? JSON.stringify(item) : String(item)} +
+ ))} +
+ )}
- + - )} -
+
+ +

{result.answer}

+
+ {result.suggestions?.length > 0 && ( +
+

Related queries

+ {result.suggestions.map((s, i) => ( + + ))}
+ )} + {result.related_actions?.map((a, i) => ( + ))}
) : ( diff --git a/frontend/src/components/ai/AiStudyCoach.tsx b/frontend/src/components/ai/AiStudyCoach.tsx index 5b4f7cc8..c5c96890 100644 --- a/frontend/src/components/ai/AiStudyCoach.tsx +++ b/frontend/src/components/ai/AiStudyCoach.tsx @@ -29,8 +29,11 @@ export default function AiStudyCoach() { suggestMutation.mutate(); }; - const suggestions = suggestMutation.data?.suggestions ?? []; - const planTips = suggestMutation.data?.study_plan_tips ?? []; + const d = suggestMutation.data; + const suggestions = d ? [d.suggestion, ...(d.focus_areas ?? []).map((a: string) => `Focus area: ${a}`)].filter(Boolean) : []; + const planTips = d?.daily_plan?.length + ? d.daily_plan.map((p: { activity: string; duration_min: number; skill: string }) => `${p.activity} (${p.duration_min}min — ${p.skill})`) + : d?.motivation ? [d.motivation] : []; return ( diff --git a/frontend/src/components/ai/AiTipBanner.tsx b/frontend/src/components/ai/AiTipBanner.tsx index 4e51ef39..3484e704 100644 --- a/frontend/src/components/ai/AiTipBanner.tsx +++ b/frontend/src/components/ai/AiTipBanner.tsx @@ -50,7 +50,7 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di ); } - if (!data.content?.trim() && !data.title?.trim()) { + if (!data.tip?.trim()) { return (
@@ -62,14 +62,16 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di ); } + const label = data.category && data.category !== "general" + ? `AI ${data.category.charAt(0).toUpperCase() + data.category.slice(1)} Tip` + : `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`; + return (
- - {data.title?.trim() || `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`} - -

{data.content}

+ {label} +

{data.tip}

{dismissible && (
)} @@ -128,9 +129,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P

Estimated band / assessment

-

{mutation.data.feedback}

- {mutation.data.improved ? ( -

{mutation.data.improved}

+

{mutation.data.tips?.join(" ") ?? ""}

+ {mutation.data.improved_text ? ( +

{mutation.data.improved_text}

) : null}
)} diff --git a/frontend/src/hooks/queries/useAiCourse.ts b/frontend/src/hooks/queries/useAiCourse.ts index c76e1394..9e1b0066 100644 --- a/frontend/src/hooks/queries/useAiCourse.ts +++ b/frontend/src/hooks/queries/useAiCourse.ts @@ -1,7 +1,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "./keys"; -import { aiCourseService } from "@/services/ai-course.service"; -import type { ExaminerReview } from "@/types"; +import { + aiCourseService, + type AiCourseCreateEnglishRequest, + type AiCourseCreateIeltsRequest, +} from "@/services/ai-course.service"; export function useAiCourse(courseId: number | undefined) { return useQuery({ @@ -22,7 +25,7 @@ export function useAiCourseTracks(courseId: number | undefined) { export function useCreateEnglishCourse() { const qc = useQueryClient(); return useMutation({ - mutationFn: (data: { current_level: string; target_level: string; learning_style: string[] }) => + mutationFn: (data: AiCourseCreateEnglishRequest) => aiCourseService.createEnglish(data), onSuccess: () => { qc.invalidateQueries({ queryKey: ["ai-course"] }); @@ -33,7 +36,7 @@ export function useCreateEnglishCourse() { export function useCreateIeltsCourse() { const qc = useQueryClient(); return useMutation({ - mutationFn: (data: { exam_type: string; target_band: number; skills: string[] }) => + mutationFn: (data: AiCourseCreateIeltsRequest) => aiCourseService.createIelts(data), onSuccess: () => { qc.invalidateQueries({ queryKey: ["ai-course"] }); @@ -63,8 +66,8 @@ export function useApproveQuality() { export function useRejectQuality() { const qc = useQueryClient(); return useMutation({ - mutationFn: ({ courseId, notes }: { courseId: number; notes: string }) => - aiCourseService.rejectQuality(courseId, notes), + mutationFn: ({ courseId, reason }: { courseId: number; reason: string }) => + aiCourseService.rejectQuality(courseId, reason), onSuccess: (_d, { courseId }) => { qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) }); }, @@ -89,7 +92,8 @@ export function useIeltsValidation(courseId: number | undefined) { export function useSubmitExaminerReview() { const qc = useQueryClient(); return useMutation({ - mutationFn: (data: ExaminerReview) => aiCourseService.submitExaminerReview(data), + mutationFn: (data: { logId: number; action: string; examiner_notes?: string }) => + aiCourseService.submitExaminerReview(data.logId, { action: data.action, examiner_notes: data.examiner_notes }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["ai-course"] }); }, diff --git a/frontend/src/hooks/queries/useExamSession.ts b/frontend/src/hooks/queries/useExamSession.ts index 541bb6fc..55328915 100644 --- a/frontend/src/hooks/queries/useExamSession.ts +++ b/frontend/src/hooks/queries/useExamSession.ts @@ -29,6 +29,7 @@ export function useExamAutoSave() { export function useExamSubmit() { return useMutation({ - mutationFn: (examId: number) => examSessionService.submit(examId), + mutationFn: (data: { examId: number; attempt_id: number; answers: { question_id: number; answer: unknown }[] }) => + examSessionService.submit(data.examId, { attempt_id: data.attempt_id, answers: data.answers }), }); } diff --git a/frontend/src/pages/ExamPage.tsx b/frontend/src/pages/ExamPage.tsx index 3c933bf1..0bd58588 100644 --- a/frontend/src/pages/ExamPage.tsx +++ b/frontend/src/pages/ExamPage.tsx @@ -7,7 +7,7 @@ import AiTipBanner from "@/components/ai/AiTipBanner"; export default function ExamPage() { return (
- + diff --git a/frontend/src/pages/GrammarPage.tsx b/frontend/src/pages/GrammarPage.tsx index aaa06f34..1536bb53 100644 --- a/frontend/src/pages/GrammarPage.tsx +++ b/frontend/src/pages/GrammarPage.tsx @@ -28,7 +28,7 @@ export default function GrammarPage() {

Master grammar rules essential for IELTS.

- +
diff --git a/frontend/src/pages/PaymentRecordPage.tsx b/frontend/src/pages/PaymentRecordPage.tsx index 3de52793..ddb24547 100644 --- a/frontend/src/pages/PaymentRecordPage.tsx +++ b/frontend/src/pages/PaymentRecordPage.tsx @@ -52,7 +52,7 @@ export default function PaymentRecordPage() {
- + @@ -61,7 +61,7 @@ export default function PaymentRecordPage() { - + diff --git a/frontend/src/pages/RecordPage.tsx b/frontend/src/pages/RecordPage.tsx index 7da0b1be..12179578 100644 --- a/frontend/src/pages/RecordPage.tsx +++ b/frontend/src/pages/RecordPage.tsx @@ -21,9 +21,9 @@ export default function RecordPage() {

Browse assignment and exam attempt history.

- + - +