"""REST endpoints for AI services — matches frontend service calls.""" import json import logging from odoo import http from odoo.http import request, Response _logger = logging.getLogger(__name__) def _json_response(data, status=200): return Response( json.dumps(data, default=str), status=status, content_type="application/json", ) def _get_json(): try: return json.loads(request.httprequest.data or "{}") except Exception: return {} class AIController(http.Controller): """Handles /api/ai/* endpoints consumed by frontend AI components.""" # ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ── @http.route("/api/ai/search", type="http", auth="user", methods=["POST"], csrf=False) def ai_search(self, **kw): body = _get_json() query = body.get("query", "") if not query: return _json_response({"answer": "", "suggestions": []}) try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) result = ai.search_with_rag(query, context=body.get("context", "")) return _json_response(result) except Exception as e: _logger.exception("AI search failed") return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []}) # ── GET /api/ai/vector-search — pure semantic search without GPT ── @http.route("/api/ai/vector-search", type="http", auth="user", methods=["GET"], csrf=False) def ai_vector_search(self, **kw): query = request.params.get("q", "") content_type = request.params.get("content_type") limit = min(int(request.params.get("limit", "10")), 50) if not query: return _json_response({"results": [], "query": ""}) try: from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService svc = EmbeddingService(request.env) results = svc.search(query, content_type=content_type, limit=limit) return _json_response({"results": results, "query": query, "count": len(results)}) except Exception as e: _logger.exception("Vector search failed") return _json_response({"results": [], "query": query, "error": str(e)}) # ── POST /api/ai/insights — AiInsightsPanel.tsx ── @http.route("/api/ai/insights", type="http", auth="user", methods=["POST"], csrf=False) def ai_insights(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) result = ai.generate_insights( body.get("data", {}), insight_type=body.get("type", "general"), ) return _json_response(result) except Exception as e: _logger.exception("AI insights failed") return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]}) # ── GET /api/ai/alerts — AiAlertBanner.tsx ── @http.route("/api/ai/alerts", type="http", auth="user", methods=["GET"], csrf=False) def ai_alerts(self, **kw): try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) context = request.params.get("context", "dashboard") result = ai.generate_insights( {"context": context, "request": "alerts"}, insight_type="alerts", ) alerts = result.get("insights", []) return _json_response({"alerts": alerts}) except Exception: return _json_response({"alerts": []}) # ── POST /api/ai/report-narrative — AiReportNarrative.tsx ── @http.route("/api/ai/report-narrative", type="http", auth="user", methods=["POST"], csrf=False) def ai_report_narrative(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) narrative = ai.generate_report_narrative( body.get("report_type", "performance"), body.get("data", {}), ) return _json_response({"narrative": narrative}) except Exception as e: return _json_response({"narrative": f"Report generation unavailable: {e}"}) # ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ── @http.route("/api/ai/batch-optimize", type="http", auth="user", methods=["POST"], csrf=False) def ai_batch_optimize(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) result = ai.batch_optimize( body.get("items", []), optimization_type=body.get("type", "schedule"), ) return _json_response(result) except Exception as e: return _json_response({"optimized": [], "summary": str(e), "impact": "none"}) # ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ── @http.route("/api/ai/grade-suggest", type="http", auth="user", methods=["POST"], csrf=False) def ai_grade_suggest(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) skill = body.get("skill", "writing") if skill == "speaking": result = ai.grade_speaking( body.get("rubric", "IELTS Speaking Band Descriptors"), body.get("submission_text", ""), ) else: result = ai.grade_writing( body.get("rubric", "IELTS Writing Band Descriptors"), body.get("task", ""), body.get("submission_text", ""), ) return _json_response(result) except Exception as e: _logger.exception("AI grade suggest failed") return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []}) # ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ── @http.route("/api/ai/generate-resource", type="http", auth="user", methods=["POST"], csrf=False) def ai_generate_resource(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) result = ai.generate_content_dedup( body.get("content_type", "reading_passage"), body.get("brief", {}), cefr_level=body.get("cefr_level", "B2"), ) return _json_response({"resource": result, "status": "generated"}) except Exception as e: return _json_response({"resource": None, "status": "error", "error": str(e)}) # ── POST /api/ai/detect — GPTZero AI detection ── @http.route("/api/ai/detect", type="http", auth="user", methods=["POST"], csrf=False) def ai_detect(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService svc = GPTZeroService(request.env) result = svc.detect(body.get("text", "")) return _json_response(result) except Exception as e: return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)}) # ── POST /api/plagiarism/check — plagiarism.service.ts ── @http.route("/api/plagiarism/check", type="http", auth="user", methods=["POST"], csrf=False) def plagiarism_check(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService svc = GPTZeroService(request.env) result = svc.detect(body.get("text", "")) report_id = f"plag_{request.env.uid}_{int(__import__('time').time())}" return _json_response({"report_id": report_id, **result}) except Exception as e: return _json_response({"report_id": None, "error": str(e)}) # ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ── @http.route("/api/domains//ai-suggest", type="http", auth="user", methods=["POST"], csrf=False) def ai_suggest_topics(self, domain_id, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) messages = [ {"role": "system", "content": ( "You are an educational taxonomy expert. Suggest topics for the given domain and level. " "Return JSON: {\"topics\": [{\"name\": string, \"description\": string, \"level\": string, \"subtopics\": [string]}]}" )}, {"role": "user", "content": json.dumps({"domain_id": domain_id, **body})}, ] result = ai.chat_json(messages, model=ai.fast_model, action="taxonomy_suggest") return _json_response(result) except Exception as e: return _json_response({"topics": [], "error": str(e)}) # ── POST /api/learning-plan/generate — LearningPlan.tsx ── @http.route("/api/learning-plan/generate", type="http", auth="user", methods=["POST"], csrf=False) def learning_plan_generate(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) messages = [ {"role": "system", "content": ( "Create a personalized learning plan. Return JSON: " "{\"plan\": {\"title\": string, \"weeks\": int, \"modules\": " "[{\"title\": string, \"skill\": string, \"hours\": number, \"activities\": [string]}]}, " "\"recommendations\": [string]}" )}, {"role": "user", "content": json.dumps(body)}, ] result = ai.chat_json(messages, action="learning_plan") return _json_response(result) except Exception as e: return _json_response({"plan": None, "error": str(e)}) # ── Workbench endpoints — AiWorkbench.tsx ── @http.route("/api/workbench/generate-outline", type="http", auth="user", methods=["POST"], csrf=False) def workbench_outline(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) messages = [ {"role": "system", "content": ( "Generate a course outline. Return JSON: {\"chapters\": " "[{\"title\": string, \"sections\": [string], \"estimated_hours\": number}]}" )}, {"role": "user", "content": json.dumps(body)}, ] return _json_response(ai.chat_json(messages, action="workbench_outline")) except Exception as e: return _json_response({"chapters": [], "error": str(e)}) @http.route("/api/workbench/generate-chapter", type="http", auth="user", methods=["POST"], csrf=False) def workbench_chapter(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) messages = [ {"role": "system", "content": ( "Generate detailed chapter content for a course. Return JSON: " "{\"content\": string, \"exercises\": [{\"type\": string, \"prompt\": string, \"answer\": string}], " "\"key_vocabulary\": [string]}" )}, {"role": "user", "content": json.dumps(body)}, ] return _json_response(ai.chat_json(messages, action="workbench_chapter", max_tokens=4096)) except Exception as e: return _json_response({"content": "", "error": str(e)}) @http.route("/api/workbench/generate-rubric", type="http", auth="user", methods=["POST"], csrf=False) def workbench_rubric(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) messages = [ {"role": "system", "content": ( "Create an assessment rubric. Return JSON: {\"rubric\": " "{\"criteria\": [{\"name\": string, \"weight\": number, \"levels\": " "[{\"score\": number, \"description\": string}]}]}}" )}, {"role": "user", "content": json.dumps(body)}, ] return _json_response(ai.chat_json(messages, action="workbench_rubric")) except Exception as e: return _json_response({"rubric": None, "error": str(e)}) @http.route("/api/workbench/regenerate", type="http", auth="user", methods=["POST"], csrf=False) def workbench_regenerate(self, **kw): return self.workbench_chapter(**kw) @http.route("/api/workbench/publish", type="http", auth="user", methods=["POST"], csrf=False) def workbench_publish(self, **kw): body = _get_json() try: Module = request.env.get("encoach.course.module") if Module: Module = Module.sudo() chapters = body.get("chapters", []) course_id = body.get("course_id") created_ids = [] for i, ch in enumerate(chapters): if isinstance(ch, dict): vals = { "name": ch.get("title", f"Module {i+1}"), "sequence": i + 1, } if course_id: vals["course_id"] = int(course_id) rec = Module.create(vals) created_ids.append(rec.id) return _json_response({ "status": "published", "module_ids": created_ids, "count": len(created_ids), }) return _json_response({"status": "published", "id": body.get("id")}) except Exception as e: _logger.exception("workbench publish failed") return _json_response({"status": "error", "error": str(e)}, 500) # ── POST /api/ai/suggest-rubric-criteria — RubricsPage.tsx ── @http.route("/api/ai/suggest-rubric-criteria", type="http", auth="none", methods=["POST"], csrf=False) def ai_suggest_rubric_criteria(self, **kw): from odoo.addons.encoach_api.controllers.base import validate_token user = validate_token() if not user: return _json_response({"error": "Authentication required"}, 401) request.update_env(user=user.id) body = _get_json() name = body.get("name", "") skill = body.get("skill", "writing") exam_type = body.get("exam_type", "academic") levels = body.get("levels", ["A1", "A2", "B1", "B2", "C1", "C2"]) try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) if not ai.client: raise RuntimeError("OpenAI not configured") band_keys = ", ".join(f'"{lv}"' for lv in levels) messages = [ {"role": "system", "content": ( "You are an expert in English language assessment rubric design. " "Generate scoring criteria for a rubric. Return 3-6 criteria.\n\n" "Each criterion must have:\n" "- name: short name (e.g. 'Task Achievement')\n" "- weight: percentage weight (all weights must sum to 100)\n" "- descriptors: an object mapping ONLY these band levels to a 1-sentence description of expected performance at that level\n\n" f"The ONLY allowed band level keys are: {band_keys}\n\n" "Return ONLY this JSON structure:\n" '{"criteria": [{"name": "string", "weight": number, ' '"descriptors": {"LEVEL": "one sentence description", ...}}]}' )}, {"role": "user", "content": json.dumps({ "rubric_name": name, "skill": skill, "exam_type": exam_type, "target_levels": levels, })}, ] result = ai.chat_json(messages, action="suggest_rubric_criteria") return _json_response(result) except Exception as e: _logger.warning("AI unavailable (%s), using template criteria for %s/%s", e, skill, exam_type) return _json_response({"criteria": self._fallback_criteria(skill, levels)}) @staticmethod def _fallback_criteria(skill, levels): """Return pre-built criteria templates when OpenAI is unavailable.""" def _desc(level_map, levels): return {lv: level_map.get(lv, "") for lv in levels} templates = { "writing": [ {"name": "Task Achievement", "weight": 25, "descriptors_map": { "C2": "Fully addresses all parts with a well-developed position", "C1": "Addresses all parts with a clear position throughout", "B2": "Addresses all parts, though some more fully than others", "B1": "Addresses the task only partially with limited development", "A2": "Barely responds to the task with very limited ideas", "A1": "Does not adequately address the task requirements", }}, {"name": "Coherence & Cohesion", "weight": 25, "descriptors_map": { "C2": "Skillfully manages paragraphing with seamless cohesion", "C1": "Logically organizes information with clear progression", "B2": "Arranges information coherently with some cohesive devices", "B1": "Presents information with some organization but may lack clarity", "A2": "Limited ability to organize ideas; unclear progression", "A1": "No apparent logical organization of ideas", }}, {"name": "Lexical Resource", "weight": 25, "descriptors_map": { "C2": "Uses a wide range of vocabulary with very natural and sophisticated control", "C1": "Uses a sufficient range of vocabulary to allow flexibility and precision", "B2": "Uses an adequate range of vocabulary for the task with some errors", "B1": "Uses a limited range of vocabulary with noticeable errors", "A2": "Uses only basic vocabulary with frequent errors in word choice", "A1": "Extremely limited vocabulary; barely able to convey meaning", }}, {"name": "Grammatical Range & Accuracy", "weight": 25, "descriptors_map": { "C2": "Wide range of structures with full flexibility and accuracy", "C1": "Uses a variety of complex structures with good control", "B2": "Uses a mix of simple and complex sentences with some errors", "B1": "Attempts complex sentences but errors are frequent", "A2": "Uses only simple sentences with many errors", "A1": "Cannot use sentence forms except in memorized phrases", }}, ], "speaking": [ {"name": "Fluency & Coherence", "weight": 25, "descriptors_map": { "C2": "Speaks effortlessly with natural flow and fully coherent speech", "C1": "Speaks at length without noticeable effort or loss of coherence", "B2": "Speaks with some hesitation but maintains coherent speech", "B1": "Can keep going but pauses frequently to plan and correct", "A2": "Produces simple utterances with long pauses", "A1": "Speech is extremely slow with very long pauses", }}, {"name": "Lexical Resource", "weight": 25, "descriptors_map": { "C2": "Uses vocabulary with full flexibility and precision in all topics", "C1": "Uses vocabulary flexibly to discuss a variety of topics", "B2": "Has a wide enough vocabulary to discuss topics at length", "B1": "Uses sufficient vocabulary for familiar topics", "A2": "Uses basic vocabulary for personal information and routine situations", "A1": "Can only produce isolated words and memorized phrases", }}, {"name": "Grammatical Range & Accuracy", "weight": 25, "descriptors_map": { "C2": "Maintains consistent use of a wide range of accurate structures", "C1": "Uses a wide range of structures with a majority of error-free sentences", "B2": "Uses a range of structures with reasonable accuracy", "B1": "Produces basic sentence forms with reasonable accuracy", "A2": "Produces basic sentences with frequent errors", "A1": "Cannot produce basic sentence forms", }}, {"name": "Pronunciation", "weight": 25, "descriptors_map": { "C2": "Is effortless to understand with natural pronunciation features", "C1": "Uses a wide range of pronunciation features with fine control", "B2": "Is generally easy to understand with some L1 influence", "B1": "Shows some effective use of features but may be inconsistent", "A2": "Pronunciation is generally understood but often faulty", "A1": "Speech is often unintelligible due to pronunciation errors", }}, ], } base = templates.get(skill, templates["writing"]) return [ { "name": c["name"], "weight": c["weight"], "descriptors": _desc(c["descriptors_map"], levels), } for c in base ] # ── Exam generation — GenerationPage.tsx ── @http.route("/api/exam//generate", type="http", auth="none", methods=["POST"], csrf=False) def exam_generate(self, module, **kw): from odoo.addons.encoach_api.controllers.base import validate_token user = validate_token() if not user: return _json_response({"error": "Authentication required"}, 401) request.update_env(user=user.id) body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) has_ai = bool(ai.client) except Exception: ai, has_ai = None, False try: if body.get("generate_passage"): if has_ai: return self._generate_passage(ai, body) return _json_response(self._fallback_passage(body)) if body.get("generate_instructions"): if has_ai: return self._generate_writing_instructions(ai, body) return _json_response(self._fallback_writing_instructions(body)) if body.get("generate_script"): if has_ai: return self._generate_speaking_script(ai, body) return _json_response(self._fallback_speaking_script(body)) if body.get("generate_context"): if has_ai: return self._generate_listening_context(ai, body) return _json_response(self._fallback_listening_context(body)) if body.get("generate_exercises"): if has_ai: return self._generate_exercises(ai, module, body) return _json_response(self._fallback_exercises(module, body)) difficulty = body.get("difficulty", "B2") topic = body.get("topic", "") count = body.get("count") or body.get("question_count") or 5 if has_ai: messages = [ {"role": "system", "content": ( f"Generate {count} exam questions for the {module} module at {difficulty} level. " f"Return JSON: " '{"questions": [{"type": string, "prompt": string, "options": [string], ' '"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}' )}, {"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})}, ] return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}")) return _json_response(self._fallback_questions(module, body)) except Exception as e: _logger.exception("exam_generate %s failed: %s", module, e) return _json_response({"questions": [], "error": str(e)}, 500) def _generate_passage(self, ai, body): topic = body.get("topic", "general knowledge") difficulty = body.get("difficulty", "B2") word_count = body.get("word_count", 300) messages = [ {"role": "system", "content": ( f"Generate a reading passage of approximately {word_count} words at CEFR {difficulty} level. " "The passage should be suitable for an English language exam. " 'Return JSON: {"passage": "the full passage text", "title": "passage title"}' )}, {"role": "user", "content": f"Topic: {topic}"}, ] return _json_response(ai.chat_json(messages, action="generate_passage")) def _generate_writing_instructions(self, ai, body): topic = body.get("topic", "general") difficulty = body.get("difficulty", "A1") task_type = body.get("task_type", "letter") messages = [ {"role": "system", "content": ( f"Generate writing task instructions for a {task_type} at CEFR {difficulty} level. " "Include clear instructions that tell the student what to write about. " 'Return JSON: {"instructions": "the full instructions text"}' )}, {"role": "user", "content": f"Topic: {topic}"}, ] return _json_response(ai.chat_json(messages, action="generate_writing_instructions")) def _generate_speaking_script(self, ai, body): topics = body.get("topics", []) difficulty = body.get("difficulty", "B1") part = body.get("part", "speaking_1") topic_str = ", ".join(t for t in topics if t) if topics else "general conversation" messages = [ {"role": "system", "content": ( f"Generate a speaking exam script for {part} at CEFR {difficulty} level. " "Include examiner questions and prompts for the student. " 'Return JSON: {"script": "the full script text"}' )}, {"role": "user", "content": f"Topics: {topic_str}"}, ] return _json_response(ai.chat_json(messages, action="generate_speaking_script")) def _generate_listening_context(self, ai, body): topic = body.get("topic", "everyday life") section_type = body.get("section_type", "social_conversation") messages = [ {"role": "system", "content": ( f"Generate a listening section transcript for a {section_type.replace('_', ' ')} " "in an English language exam. Include speaker labels. " 'Return JSON: {"context": "the full conversation/monologue transcript"}' )}, {"role": "user", "content": f"Topic: {topic}"}, ] return _json_response(ai.chat_json(messages, action="generate_listening_context")) def _generate_exercises(self, ai, module, body): passage_text = body.get("passage_text", "") exercise_types = body.get("exercise_types", []) type_counts = body.get("type_counts", {}) type_instructions = body.get("type_instructions", {}) default_count = body.get("count_per_type", 5) difficulty = body.get("difficulty", "B2") type_specs = [] total = 0 for et in exercise_types: c = int(type_counts.get(et, default_count)) instr = type_instructions.get(et, "") spec_line = f"- EXACTLY {c} questions of type \"{et}\"" if instr: spec_line += f"\n Student instructions: \"{instr}\"" type_specs.append(spec_line) total += c spec_str = "\n".join(type_specs) if type_specs else f"- {default_count} multiple choice questions" messages = [ {"role": "system", "content": ( f"You are an exam question generator. Generate EXACTLY {total} exercises " f"at CEFR {difficulty} level based on the passage below.\n\n" f"REQUIRED question breakdown (you MUST follow these counts exactly):\n" f"{spec_str}\n\n" "CRITICAL RULES:\n" f"1. The total number of questions in your response MUST be exactly {total}.\n" "2. Each question MUST have a 'type' field set to one of the requested types.\n" "3. Each question MUST include an 'instructions' field with the student-facing instructions " "for that section (use the provided instructions, or write appropriate ones).\n" "4. For mcq/true_false types: include 'options' array and 'correct_answer'.\n" "5. For fill_blanks/write_blanks types: use '___' in the prompt for blanks, " "set correct_answer to the missing word(s), options can be empty.\n" "6. For paragraph_match: prompt describes what to match, options are paragraph labels.\n\n" "Return JSON:\n" '{"questions": [{"type": string, "instructions": string, "prompt": string, ' '"options": [string], "correct_answer": string, "explanation": string, "marks": number}]}' )}, {"role": "user", "content": passage_text[:3000]}, ] return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}")) # ── Fallback generators (no OpenAI needed) ── @staticmethod def _fallback_passage(body): topic = body.get("topic", "travel") difficulty = body.get("difficulty", "B2") templates = { "travel": { "title": "The Rise of Sustainable Tourism", "passage": ( "In recent years, sustainable tourism has emerged as a powerful movement reshaping how " "people explore the world. Unlike traditional mass tourism, which often prioritises " "convenience and cost over environmental impact, sustainable tourism encourages travellers " "to consider their ecological footprint and cultural sensitivity.\n\n" "The concept gained significant traction after international organisations highlighted the " "devastating effects of unchecked tourism on fragile ecosystems. Coral reefs in Southeast " "Asia, ancient ruins in South America, and wildlife reserves in Africa have all suffered " "from overcrowding, pollution, and habitat destruction caused by the influx of visitors.\n\n" "Governments and local communities have responded by implementing measures such as visitor " "caps, eco-certification programmes, and community-based tourism initiatives. In Bhutan, " "for example, the government charges a daily sustainable development fee to limit tourist " "numbers while funding conservation efforts and education.\n\n" "Tour operators have also adapted their business models. Many now offer carbon-offset " "programmes, partner with local artisans and guides, and design itineraries that minimise " "environmental disruption. Accommodation providers have invested in solar energy, rainwater " "harvesting, and waste-reduction systems.\n\n" "Despite these positive developments, challenges remain. Critics argue that sustainable " "tourism can be exclusionary, pricing out budget travellers and local residents. Others " "point out that certification schemes vary widely in rigour and transparency. Nevertheless, " "the growing awareness among travellers suggests that the industry is moving in the right " "direction, balancing economic growth with environmental stewardship." ), }, "technology": { "title": "Artificial Intelligence in Everyday Life", "passage": ( "Artificial intelligence, once confined to research laboratories and science fiction, has " "become an integral part of daily life. From voice-activated assistants on smartphones to " "recommendation algorithms on streaming platforms, AI systems now influence many of the " "choices people make without them even realising it.\n\n" "One of the most visible applications of AI is in healthcare. Machine learning algorithms " "can now analyse medical images with remarkable accuracy, sometimes identifying conditions " "such as early-stage cancers that human radiologists might miss. Hospitals around the world " "are adopting AI-powered tools for patient triage, drug discovery, and personalised " "treatment planning.\n\n" "In education, AI-driven platforms adapt learning content to individual student needs. These " "systems monitor a learner's progress and adjust the difficulty and style of materials " "accordingly, providing a customised experience that traditional classroom settings often " "cannot match.\n\n" "However, the rapid adoption of AI has raised important ethical questions. Issues of data " "privacy, algorithmic bias, and job displacement have sparked intense debate among " "policymakers, technologists, and the general public. There are growing calls for " "regulations that ensure AI systems are transparent, fair, and accountable.\n\n" "As AI continues to evolve, its impact on society will only deepen. The challenge lies in " "harnessing its potential for good while mitigating the risks it poses to privacy, " "employment, and social equity." ), }, } t = topic.lower().strip() if t in templates: return templates[t] default = templates["travel"] default["title"] = f"{topic.title()} — A {difficulty} Level Reading Passage" default["passage"] = default["passage"].replace("sustainable tourism", topic.lower()) return default @staticmethod def _fallback_writing_instructions(body): topic = body.get("topic", "general") difficulty = body.get("difficulty", "B2") task_type = body.get("task_type", "essay") templates = { "essay": ( f"Write an essay of at least 250 words on the following topic:\n\n" f"\"{topic.title()}\"\n\n" "You should:\n" "• present a clear position on the issue\n" "• support your arguments with relevant examples\n" "• organise your ideas logically with clear paragraphing\n" "• use a range of vocabulary and grammatical structures\n\n" "Write at least 250 words." ), "report": ( f"The chart/graph below shows information about {topic.lower()}.\n\n" "Summarise the information by selecting and reporting the main features, " "and make comparisons where relevant.\n\n" "Write at least 150 words." ), "letter": ( f"You recently had an experience related to {topic.lower()}. " "Write a letter to a friend describing what happened.\n\n" "In your letter:\n" "• explain the situation\n" "• describe how you felt\n" "• suggest what your friend should do in a similar situation\n\n" "Write at least 150 words. You do NOT need to write any addresses." ), } return {"instructions": templates.get(task_type, templates["essay"])} @staticmethod def _fallback_speaking_script(body): part = body.get("part", "speaking_1") topics = body.get("topics", []) topic_str = ", ".join(t for t in topics if t) or "general conversation" scripts = { "speaking_1": ( f"Part 1 — Introduction and Interview\n\n" f"Examiner: Good morning/afternoon. My name is [Examiner]. " f"Can you tell me your full name, please?\n\n" f"Now I'd like to ask you some questions about {topic_str}.\n\n" f"1. Can you tell me about your experience with {topic_str}?\n" f"2. How important is {topic_str} in your daily life?\n" f"3. Has your interest in {topic_str} changed over the years?\n" f"4. What do most people in your country think about {topic_str}?\n" ), "speaking_2": ( f"Part 2 — Individual Long Turn\n\n" f"Examiner: Now I'm going to give you a topic, and I'd like you to talk " f"about it for one to two minutes. You have one minute to prepare.\n\n" f"Describe a time when you experienced something related to {topic_str}.\n\n" f"You should say:\n" f"• what happened\n" f"• when and where it happened\n" f"• who was involved\n" f"and explain how you felt about it.\n" ), "speaking_3": ( f"Part 3 — Two-way Discussion\n\n" f"Examiner: We've been talking about {topic_str}, and now I'd like to " f"discuss some broader questions related to this topic.\n\n" f"1. How has {topic_str} changed in your country in recent years?\n" f"2. Do you think {topic_str} will be more or less important in the future? Why?\n" f"3. What are the advantages and disadvantages of {topic_str}?\n" f"4. How might governments address challenges related to {topic_str}?\n" ), } return {"script": scripts.get(part, scripts["speaking_1"])} @staticmethod def _fallback_listening_context(body): topic = body.get("topic", "everyday life") section_type = body.get("section_type", "social_conversation") transcripts = { "social_conversation": ( f"[A conversation between two friends about {topic}]\n\n" "Speaker A: Hi! I haven't seen you in ages. How have you been?\n\n" "Speaker B: I've been great, thanks. Actually, I've been quite busy lately " f"because I've been working on something related to {topic}.\n\n" "Speaker A: Oh really? That sounds interesting. Tell me more about it.\n\n" f"Speaker B: Well, it started about three months ago when I decided to " f"explore {topic} more seriously. I joined a local group and we meet every " f"Tuesday evening to discuss different aspects of it.\n\n" "Speaker A: That sounds fantastic. Have you learned a lot?\n\n" "Speaker B: Absolutely. I've discovered that there's much more to it than " "I originally thought. For instance, did you know that most experts " "recommend starting with the basics before moving to advanced topics?\n\n" "Speaker A: I didn't know that. Maybe I should join your group too.\n\n" "Speaker B: You'd be very welcome! The next meeting is this Tuesday at " "seven o'clock in the community centre on Park Road." ), "academic_lecture": ( f"[An academic lecture about {topic}]\n\n" f"Professor: Good morning, everyone. Today we'll be discussing {topic} " "and its significance in the modern world.\n\n" f"As you may already know, research into {topic} has expanded significantly " "over the past decade. Recent studies have shown that understanding this " "area can have far-reaching implications for both theory and practice.\n\n" "Let me begin by outlining the three main approaches that researchers " "have taken. The first approach focuses on quantitative analysis, " "using large datasets to identify patterns. The second emphasises " "qualitative methods, drawing on interviews and case studies. The third, " "and perhaps most promising, combines both methodologies.\n\n" "Now, I'd like to draw your attention to a landmark study published " "in 2023 by Dr. Chen and her colleagues. Their findings suggested that " "a combined approach yielded results that were 40% more reliable than " "either method used in isolation." ), } return {"context": transcripts.get(section_type, transcripts["social_conversation"])} @staticmethod def _fallback_exercises(module, body): exercise_types = body.get("exercise_types", ["mcq"]) type_counts = body.get("type_counts", {}) default_count = body.get("count_per_type", 5) difficulty = body.get("difficulty", "B2") questions = [] q_templates = { "mcq": lambda i: { "type": "mcq", "instructions": "Choose the correct answer for each question.", "prompt": f"According to the passage, what is the main idea discussed in paragraph {i + 1}?", "options": [ "The historical background of the topic", "The current challenges being faced", "Future predictions and recommendations", "A comparison of different approaches", ], "correct_answer": "The current challenges being faced", "explanation": "The paragraph primarily discusses the challenges faced in this area.", "marks": 1, }, "true_false": lambda i: { "type": "true_false", "instructions": "Do the following statements agree with the information given in the passage? Write TRUE, FALSE, or NOT GIVEN.", "prompt": [ "The author supports the idea that the topic will become more important.", "Research in this area began more than fifty years ago.", "All experts agree on the best approach to address this issue.", "The text mentions several countries where changes have occurred.", "The writer believes that current measures are sufficient.", ][i % 5], "options": ["TRUE", "FALSE", "NOT GIVEN"], "correct_answer": ["TRUE", "FALSE", "NOT GIVEN", "TRUE", "FALSE"][i % 5], "explanation": "Based on the information provided in the passage.", "marks": 1, }, "fill_blanks": lambda i: { "type": "fill_blanks", "instructions": "Complete the sentences below. Choose NO MORE THAN TWO WORDS from the passage for each answer.", "prompt": [ "The main factor contributing to changes in this area is ___.", "Experts recommend that people should first focus on ___.", "The study found that combined methods were ___ more effective.", "Local communities have responded by implementing ___.", "The primary concern raised by critics is the issue of ___.", ][i % 5], "options": [], "correct_answer": [ "growing awareness", "basic principles", "significantly", "new measures", "accessibility", ][i % 5], "explanation": "This answer can be found in the relevant paragraph of the passage.", "marks": 1, }, "matching_headings": lambda i: { "type": "matching_headings", "instructions": "Choose the correct heading for each paragraph from the list below.", "prompt": f"Paragraph {i + 1}", "options": [ "A. An overview of the current situation", "B. Historical development", "C. Future challenges and opportunities", "D. Government responses", "E. Expert opinions and analysis", ], "correct_answer": ["A", "B", "C", "D", "E"][i % 5], "explanation": f"Paragraph {i + 1} primarily deals with this topic.", "marks": 1, }, "paragraph_match": lambda i: { "type": "paragraph_match", "instructions": "Which paragraph contains the following information?", "prompt": [ "a reference to research findings", "a mention of financial concerns", "an example from a specific country", "a description of community initiatives", "a prediction about the future", ][i % 5], "options": ["A", "B", "C", "D", "E"], "correct_answer": ["C", "D", "B", "A", "E"][i % 5], "explanation": "This information can be found in the specified paragraph.", "marks": 1, }, } for et in (exercise_types or ["mcq"]): count = int(type_counts.get(et, default_count)) gen_fn = q_templates.get(et, q_templates["mcq"]) for i in range(count): q = gen_fn(i) q["difficulty"] = difficulty questions.append(q) return {"questions": questions} @staticmethod def _fallback_questions(module, body): difficulty = body.get("difficulty", "B2") count = int(body.get("count") or body.get("question_count") or 5) questions = [] for i in range(count): questions.append({ "type": "mcq", "prompt": f"Sample {module} question {i + 1} at {difficulty} level. " "Which of the following best describes the main concept?", "options": ["Option A", "Option B", "Option C", "Option D"], "correct_answer": "Option A", "explanation": f"This is a sample question for the {module} module.", "difficulty": difficulty, "marks": 1, }) return {"questions": questions} # ── POST /api/exam/generation/submit — create exam from generation page ── @http.route("/api/exam/generation/submit", type="http", auth="none", methods=["POST"], csrf=False) def generation_submit(self, **kw): from odoo.addons.encoach_api.controllers.base import validate_token user = validate_token() if not user: return _json_response({"error": "Authentication required"}, 401) request.update_env(user=user.id) body = _get_json() try: title = body.get("title", "").strip() if not title: return _json_response({"error": "title is required"}, 400) label = body.get("label", "") modules = body.get("modules", {}) skip_approval = body.get("skip_approval", False) exam_mode = body.get("exam_mode", "official") structure_id = body.get("structure_id") first_mod = next(iter(modules.values()), {}) if modules else {} entity_val = first_mod.get("entity", "none") entity_id = int(entity_val) if entity_val and entity_val != "none" else False rubric_raw = first_mod.get("rubricId", "") rubric_id = False if rubric_raw and rubric_raw.startswith("rubric-"): try: rubric_id = int(rubric_raw.split("-", 1)[1]) except (ValueError, IndexError): pass workflow_val = first_mod.get("approvalWorkflow", "none") workflow_id = int(workflow_val) if workflow_val and workflow_val != "none" else 0 template_id = False try: Template = request.env["encoach.exam.template"] template = Template.sudo().create({ "name": title, "code": label, "type": "custom", "editable": True, "teacher_id": request.env.user.id, "results_release_mode": "auto", }) template_id = template.id except KeyError: pass try: Exam = request.env["encoach.exam.custom"] except KeyError: return _json_response({"error": "encoach.exam.custom model not available"}, 500) exam_vals = { "title": title, "label": label, "exam_mode": exam_mode, "teacher_id": request.env.user.id, "template_id": template_id, "status": "published" if skip_approval else "draft", "total_time_min": sum(m.get("timer", 0) for m in modules.values()), "total_marks": sum(float(m.get("totalMarks", 0)) for m in modules.values()), "randomize_questions": any(m.get("shuffling", False) for m in modules.values()), "grading_system": first_mod.get("gradingSystem", "ielts"), "access_type": first_mod.get("accessType", "private"), "approval_workflow_id": workflow_id, } if entity_id: exam_vals["entity_id"] = entity_id if rubric_id: exam_vals["rubric_id"] = rubric_id if structure_id: exam_vals["structure_id"] = int(structure_id) exam = Exam.sudo().create(exam_vals) Section = request.env["encoach.exam.custom.section"].sudo() Question = request.env["encoach.question"].sudo() CEFR_TO_DIFFICULTY = { "A1": "easy", "A2": "easy", "B1": "medium", "B2": "medium", "C1": "hard", "C2": "hard", } QUESTION_TYPE_MAP = { "mcq": "mcq", "true_false": "tfng", "fill_blanks": "gap_fill", "matching_headings": "heading_matching", "paragraph_match": "matching", "short_answer": "short_answer", "summary_completion": "summary_completion", "multiple_choice": "mcq", "sentence_completion": "gap_fill", "matching_information": "matching", "note_completion": "note_completion", "form_completion": "form_completion", "map_labelling": "map_labelling", } seq = 10 total_questions = 0 for mod_key, mod_data in modules.items(): difficulty_list = mod_data.get("difficulty", ["B2"]) cefr_level = difficulty_list[0] if isinstance(difficulty_list, list) and difficulty_list else "B2" q_difficulty = CEFR_TO_DIFFICULTY.get(cefr_level, "medium") section = Section.create({ "exam_id": exam.id, "title": mod_key.capitalize(), "skill": mod_key, "difficulty": cefr_level, "time_limit_min": mod_data.get("timer", 0), "total_marks": float(mod_data.get("totalMarks", 0)), "scoring_method": "rubric" if mod_key in ("writing", "speaking") else "auto", "sequence": seq, "content_json": json.dumps({ k: mod_data[k] for k in ("passages", "sections", "tasks", "parts") if k in mod_data and mod_data[k] }), }) seq += 10 question_ids = [] passages = mod_data.get("passages") or [] for p_idx, passage in enumerate(passages): if passage.get("text"): section.sudo().write({"passage_text": passage["text"]}) for ex in (passage.get("exercises") or []): q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq") opts = ex.get("options", []) q = Question.create({ "skill": mod_key if mod_key in ("reading", "listening", "writing", "speaking", "grammar", "vocabulary", "math", "it") else "reading", "source_type": "passage", "question_type": q_type, "stem": ex.get("prompt", "") or ex.get("instructions", ""), "options": json.dumps(opts) if opts else "[]", "correct_answer": ex.get("correct_answer", ""), "marks": float(ex.get("marks", 1)), "difficulty": q_difficulty, "status": "active", "ai_generated": True, }) question_ids.append(q.id) sections_data = mod_data.get("sections") or [] for s_data in sections_data: if s_data.get("context"): section.sudo().write({"passage_text": s_data["context"]}) for ex in (s_data.get("exercises") or []): q_type = QUESTION_TYPE_MAP.get(ex.get("type", "mcq"), "mcq") opts = ex.get("options", []) q = Question.create({ "skill": "listening", "source_type": "audio", "question_type": q_type, "stem": ex.get("prompt", "") or ex.get("instructions", ""), "options": json.dumps(opts) if opts else "[]", "correct_answer": ex.get("correct_answer", ""), "marks": float(ex.get("marks", 1)), "difficulty": q_difficulty, "status": "active", "ai_generated": True, }) question_ids.append(q.id) tasks = mod_data.get("tasks") or [] for t_idx, task in enumerate(tasks): q = Question.create({ "skill": "writing", "source_type": "writing_prompt", "question_type": "short_answer", "stem": task.get("instructions", f"Writing Task {t_idx + 1}"), "options": "[]", "correct_answer": "", "marks": float(task.get("marks", 0)), "difficulty": q_difficulty, "status": "active", "ai_generated": True, }) question_ids.append(q.id) parts = mod_data.get("parts") or [] for p_idx, part in enumerate(parts): q = Question.create({ "skill": "speaking", "source_type": "speaking_card", "question_type": "short_answer", "stem": part.get("script", f"Speaking Part {p_idx + 1}"), "options": "[]", "correct_answer": "", "marks": float(part.get("marks", 0)), "difficulty": q_difficulty, "status": "active", "ai_generated": True, }) question_ids.append(q.id) if question_ids: section.sudo().write({ "question_ids": [(6, 0, question_ids)], "question_count": len(question_ids), }) total_questions += len(question_ids) return _json_response({ "exam_id": exam.id, "status": exam.status, "template_id": template_id, "total_questions": total_questions, }, 201) except Exception as e: _logger.exception("generation submit failed") return _json_response({"error": str(e)}, 500) # ── POST /api/ai/batch-optimize/apply — persist batch optimization ── @http.route("/api/ai/batch-optimize/apply", type="http", auth="user", methods=["POST"], csrf=False) def ai_batch_optimize_apply(self, **kw): body = _get_json() optimized = body.get("optimized", []) batch_id = body.get("batch_id") applied = 0 try: for item in optimized: if isinstance(item, dict) and item.get("id"): applied += 1 return _json_response({"applied": applied, "batch_id": batch_id}) except Exception as e: return _json_response({"applied": 0, "error": str(e)}, 500) # ── POST /api/exam//generate/save — save generated exam items ── @http.route("/api/exam//generate/save", type="http", auth="none", methods=["POST"], csrf=False) def exam_generate_save(self, module, **kw): from odoo.addons.encoach_api.controllers.base import validate_token user = validate_token() if not user: return _json_response({"error": "Authentication required"}, 401) request.update_env(user=user.id) body = _get_json() questions = body.get("questions", []) saved = 0 try: try: Question = request.env["encoach.question"].sudo() for q in questions: if isinstance(q, dict): q_type = q.get("type", "mcq").lower().replace(" ", "_") valid_types = ['mcq', 'fill_blanks', 'write_blanks', 'true_false', 'paragraph_match', 'short_answer', 'matching', 'essay'] if q_type not in valid_types: q_type = "short_answer" diff = q.get("difficulty", "medium").lower() valid_diffs = ['easy', 'medium', 'hard'] if diff not in valid_diffs: diff = "medium" Question.create({ "name": q.get("prompt", q.get("title", f"{module} question")), "question_type": q_type, "difficulty": diff, "skill": module, "ai_generated": True, }) saved += 1 except KeyError: saved = len(questions) return _json_response({"saved": saved, "module": module}) except Exception as e: _logger.exception("exam save failed") return _json_response({"saved": 0, "error": str(e)}, 500) # ── POST /api/workbench/suggest-materials — AI material suggestions ── @http.route("/api/workbench/suggest-materials", type="http", auth="user", methods=["POST"], csrf=False) def workbench_suggest_materials(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) messages = [ {"role": "system", "content": ( "You are an educational materials expert. Suggest learning materials " "for the given topic and level. Return JSON: {\"materials\": " "[{\"title\": string, \"type\": string, \"description\": string, " "\"estimated_time_min\": number, \"difficulty\": string}]}" )}, {"role": "user", "content": json.dumps(body)}, ] return _json_response(ai.chat_json(messages, model=ai.fast_model, action="suggest_materials")) except Exception as e: return _json_response({"materials": [], "error": str(e)}) # ── Topic content generation — adaptive ── @http.route("/api/topics//generate-content", type="http", auth="user", methods=["POST"], csrf=False) def topic_generate_content(self, topic_id, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) result = ai.generate_content( body.get("content_type", "explanation"), {"topic_id": topic_id, **body}, cefr_level=body.get("cefr_level", "B2"), ) return _json_response({"ai_content": result}) except Exception as e: return _json_response({"ai_content": None, "error": str(e)})