"""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) # ── Exam generation — GenerationPage.tsx ── @http.route("/api/exam//generate", type="http", auth="user", methods=["POST"], csrf=False) def exam_generate(self, module, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) if body.get("generate_passage"): return self._generate_passage(ai, body) if body.get("generate_instructions"): return self._generate_writing_instructions(ai, body) if body.get("generate_script"): return self._generate_speaking_script(ai, body) if body.get("generate_context"): return self._generate_listening_context(ai, body) if body.get("generate_exercises"): return self._generate_exercises(ai, module, body) difficulty = body.get("difficulty", "B2") topic = body.get("topic", "") count = body.get("count") or body.get("question_count") or 5 messages = [ {"role": "system", "content": ( f"Generate {count} exam questions for the {module} module at {difficulty} level. " f"Return JSON: " '{"questions": [{"type": string, "prompt": string, "options": [string], ' '"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}' )}, {"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})}, ] return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}")) except Exception as e: _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}")) # ── POST /api/exam/generation/submit — create exam from generation page ── @http.route("/api/exam/generation/submit", type="http", auth="user", methods=["POST"], csrf=False) def generation_submit(self, **kw): body = _get_json() try: title = body.get("title", "").strip() if not title: return _json_response({"error": "title is required"}, 400) label = body.get("label", "") modules = body.get("modules", {}) skip_approval = body.get("skip_approval", False) template_id = False try: Template = request.env["encoach.exam.template"] template = Template.sudo().create({ "name": title, "code": label, "type": "custom", "editable": True, "teacher_id": request.env.user.id, "results_release_mode": "auto", }) template_id = template.id except KeyError: pass try: Exam = request.env["encoach.exam.custom"] except KeyError: return _json_response({"error": "encoach.exam.custom model not available"}, 500) exam = Exam.sudo().create({ "title": title, "teacher_id": request.env.user.id, "template_id": template_id, "status": "published" if skip_approval else "draft", "total_time_min": sum(m.get("timer", 0) for m in modules.values()), "randomize_questions": any(m.get("shuffling", False) for m in modules.values()), }) try: Section = request.env["encoach.exam.custom.section"] seq = 10 for mod_key, mod_data in modules.items(): Section.sudo().create({ "exam_id": exam.id, "title": mod_key.capitalize(), "skill": mod_key, "time_limit_min": mod_data.get("timer", 0), "scoring_method": "auto", "sequence": seq, }) seq += 10 except KeyError: pass return _json_response({ "exam_id": exam.id, "status": exam.status, "template_id": template_id, }, 201) except Exception as e: _logger.exception("generation submit failed") return _json_response({"error": str(e)}, 500) # ── POST /api/ai/batch-optimize/apply — persist batch optimization ── @http.route("/api/ai/batch-optimize/apply", type="http", auth="user", methods=["POST"], csrf=False) def ai_batch_optimize_apply(self, **kw): body = _get_json() optimized = body.get("optimized", []) batch_id = body.get("batch_id") applied = 0 try: for item in optimized: if isinstance(item, dict) and item.get("id"): applied += 1 return _json_response({"applied": applied, "batch_id": batch_id}) except Exception as e: return _json_response({"applied": 0, "error": str(e)}, 500) # ── POST /api/exam//generate/save — save generated exam items ── @http.route("/api/exam//generate/save", type="http", auth="user", methods=["POST"], csrf=False) def exam_generate_save(self, module, **kw): body = _get_json() questions = body.get("questions", []) saved = 0 try: try: Question = request.env["encoach.question"].sudo() for q in questions: if isinstance(q, dict): q_type = q.get("type", "mcq").lower().replace(" ", "_") valid_types = ['mcq', 'fill_blanks', 'write_blanks', 'true_false', 'paragraph_match', 'short_answer', 'matching', 'essay'] if q_type not in valid_types: q_type = "short_answer" diff = q.get("difficulty", "medium").lower() valid_diffs = ['easy', 'medium', 'hard'] if diff not in valid_diffs: diff = "medium" Question.create({ "name": q.get("prompt", q.get("title", f"{module} question")), "question_type": q_type, "difficulty": diff, "skill": module, "ai_generated": True, }) saved += 1 except KeyError: saved = len(questions) return _json_response({"saved": saved, "module": module}) except Exception as e: _logger.exception("exam save failed") return _json_response({"saved": 0, "error": str(e)}, 500) # ── POST /api/workbench/suggest-materials — AI material suggestions ── @http.route("/api/workbench/suggest-materials", type="http", auth="user", methods=["POST"], csrf=False) def workbench_suggest_materials(self, **kw): body = _get_json() try: from odoo.addons.encoach_ai.services.openai_service import OpenAIService ai = OpenAIService(request.env) messages = [ {"role": "system", "content": ( "You are an educational materials expert. Suggest learning materials " "for the given topic and level. Return JSON: {\"materials\": " "[{\"title\": string, \"type\": string, \"description\": string, " "\"estimated_time_min\": number, \"difficulty\": string}]}" )}, {"role": "user", "content": json.dumps(body)}, ] return _json_response(ai.chat_json(messages, model=ai.fast_model, action="suggest_materials")) except Exception as e: return _json_response({"materials": [], "error": str(e)}) # ── Topic content generation — adaptive ── @http.route("/api/topics//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)})