feat: initial backend codebase — EnCoach v3
Complete Odoo 19 backend with 25 custom addons: - encoach_core: user/entity/role management - encoach_api: REST API + JWT auth - encoach_ai: OpenAI integration, AI settings, generation - encoach_ai_course: AI-powered English & IELTS course generation - encoach_exam_template/session: exam creation, structures, sessions - encoach_scoring: AI auto-grading + manual approval - encoach_vector: pgvector RAG integration - encoach_adaptive: adaptive learning engine - encoach_placement: placement testing - encoach_taxonomy/resources: content taxonomy & resource management - Plus 14 more modules for courses, branding, portal, etc. Includes docs: user guide, generation report, developer workflow. Made-with: Cursor
This commit is contained in:
3
custom_addons/encoach_ai/__init__.py
Normal file
3
custom_addons/encoach_ai/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import services
|
||||
27
custom_addons/encoach_ai/__manifest__.py
Normal file
27
custom_addons/encoach_ai/__manifest__.py
Normal file
@@ -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",
|
||||
}
|
||||
3
custom_addons/encoach_ai/controllers/__init__.py
Normal file
3
custom_addons/encoach_ai/controllers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import ai_controller
|
||||
from . import coach_controller
|
||||
from . import media_controller
|
||||
575
custom_addons/encoach_ai/controllers/ai_controller.py
Normal file
575
custom_addons/encoach_ai/controllers/ai_controller.py
Normal file
@@ -0,0 +1,575 @@
|
||||
"""REST endpoints for AI services — matches frontend service calls."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _json_response(data, status=200):
|
||||
return Response(
|
||||
json.dumps(data, default=str),
|
||||
status=status,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
def _get_json():
|
||||
try:
|
||||
return json.loads(request.httprequest.data or "{}")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
class AIController(http.Controller):
|
||||
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
|
||||
|
||||
# ── POST /api/ai/search — AiSearchBar.tsx (RAG-enhanced) ──
|
||||
@http.route("/api/ai/search", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def ai_search(self, **kw):
|
||||
body = _get_json()
|
||||
query = body.get("query", "")
|
||||
if not query:
|
||||
return _json_response({"answer": "", "suggestions": []})
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
result = ai.search_with_rag(query, context=body.get("context", ""))
|
||||
return _json_response(result)
|
||||
except Exception as e:
|
||||
_logger.exception("AI search failed")
|
||||
return _json_response({"answer": f"AI search unavailable: {e}", "suggestions": []})
|
||||
|
||||
# ── GET /api/ai/vector-search — pure semantic search without GPT ──
|
||||
@http.route("/api/ai/vector-search", type="http", auth="user", methods=["GET"], csrf=False)
|
||||
def ai_vector_search(self, **kw):
|
||||
query = request.params.get("q", "")
|
||||
content_type = request.params.get("content_type")
|
||||
limit = min(int(request.params.get("limit", "10")), 50)
|
||||
if not query:
|
||||
return _json_response({"results": [], "query": ""})
|
||||
try:
|
||||
from odoo.addons.encoach_vector.services.embedding_service import EmbeddingService
|
||||
svc = EmbeddingService(request.env)
|
||||
results = svc.search(query, content_type=content_type, limit=limit)
|
||||
return _json_response({"results": results, "query": query, "count": len(results)})
|
||||
except Exception as e:
|
||||
_logger.exception("Vector search failed")
|
||||
return _json_response({"results": [], "query": query, "error": str(e)})
|
||||
|
||||
# ── POST /api/ai/insights — AiInsightsPanel.tsx ──
|
||||
@http.route("/api/ai/insights", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def ai_insights(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
result = ai.generate_insights(
|
||||
body.get("data", {}),
|
||||
insight_type=body.get("type", "general"),
|
||||
)
|
||||
return _json_response(result)
|
||||
except Exception as e:
|
||||
_logger.exception("AI insights failed")
|
||||
return _json_response({"insights": [{"title": "AI Unavailable", "description": str(e), "severity": "info", "recommendation": "Check AI settings."}]})
|
||||
|
||||
# ── GET /api/ai/alerts — AiAlertBanner.tsx ──
|
||||
@http.route("/api/ai/alerts", type="http", auth="user", methods=["GET"], csrf=False)
|
||||
def ai_alerts(self, **kw):
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
context = request.params.get("context", "dashboard")
|
||||
result = ai.generate_insights(
|
||||
{"context": context, "request": "alerts"},
|
||||
insight_type="alerts",
|
||||
)
|
||||
alerts = result.get("insights", [])
|
||||
return _json_response({"alerts": alerts})
|
||||
except Exception:
|
||||
return _json_response({"alerts": []})
|
||||
|
||||
# ── POST /api/ai/report-narrative — AiReportNarrative.tsx ──
|
||||
@http.route("/api/ai/report-narrative", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def ai_report_narrative(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
narrative = ai.generate_report_narrative(
|
||||
body.get("report_type", "performance"),
|
||||
body.get("data", {}),
|
||||
)
|
||||
return _json_response({"narrative": narrative})
|
||||
except Exception as e:
|
||||
return _json_response({"narrative": f"Report generation unavailable: {e}"})
|
||||
|
||||
# ── POST /api/ai/batch-optimize — AiBatchOptimizer.tsx ──
|
||||
@http.route("/api/ai/batch-optimize", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def ai_batch_optimize(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
result = ai.batch_optimize(
|
||||
body.get("items", []),
|
||||
optimization_type=body.get("type", "schedule"),
|
||||
)
|
||||
return _json_response(result)
|
||||
except Exception as e:
|
||||
return _json_response({"optimized": [], "summary": str(e), "impact": "none"})
|
||||
|
||||
# ── POST /api/ai/grade-suggest — AiGradingAssistant.tsx ──
|
||||
@http.route("/api/ai/grade-suggest", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def ai_grade_suggest(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
skill = body.get("skill", "writing")
|
||||
if skill == "speaking":
|
||||
result = ai.grade_speaking(
|
||||
body.get("rubric", "IELTS Speaking Band Descriptors"),
|
||||
body.get("submission_text", ""),
|
||||
)
|
||||
else:
|
||||
result = ai.grade_writing(
|
||||
body.get("rubric", "IELTS Writing Band Descriptors"),
|
||||
body.get("task", ""),
|
||||
body.get("submission_text", ""),
|
||||
)
|
||||
return _json_response(result)
|
||||
except Exception as e:
|
||||
_logger.exception("AI grade suggest failed")
|
||||
return _json_response({"scores": {}, "overall_band": 0, "feedback": str(e), "suggestions": []})
|
||||
|
||||
# ── POST /api/ai/generate-resource — ModuleBuilder.tsx (dedup-aware) ──
|
||||
@http.route("/api/ai/generate-resource", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def ai_generate_resource(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
result = ai.generate_content_dedup(
|
||||
body.get("content_type", "reading_passage"),
|
||||
body.get("brief", {}),
|
||||
cefr_level=body.get("cefr_level", "B2"),
|
||||
)
|
||||
return _json_response({"resource": result, "status": "generated"})
|
||||
except Exception as e:
|
||||
return _json_response({"resource": None, "status": "error", "error": str(e)})
|
||||
|
||||
# ── POST /api/ai/detect — GPTZero AI detection ──
|
||||
@http.route("/api/ai/detect", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def ai_detect(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
|
||||
svc = GPTZeroService(request.env)
|
||||
result = svc.detect(body.get("text", ""))
|
||||
return _json_response(result)
|
||||
except Exception as e:
|
||||
return _json_response({"is_ai_generated": False, "ai_probability": 0, "error": str(e)})
|
||||
|
||||
# ── POST /api/plagiarism/check — plagiarism.service.ts ──
|
||||
@http.route("/api/plagiarism/check", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def plagiarism_check(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.gptzero_service import GPTZeroService
|
||||
svc = GPTZeroService(request.env)
|
||||
result = svc.detect(body.get("text", ""))
|
||||
report_id = f"plag_{request.env.uid}_{int(__import__('time').time())}"
|
||||
return _json_response({"report_id": report_id, **result})
|
||||
except Exception as e:
|
||||
return _json_response({"report_id": None, "error": str(e)})
|
||||
|
||||
# ── POST /api/domains/:domainId/ai-suggest — TaxonomyManager.tsx ──
|
||||
@http.route("/api/domains/<int:domain_id>/ai-suggest", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def ai_suggest_topics(self, domain_id, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
|
||||
"Return JSON: {\"topics\": [{\"name\": string, \"description\": string, \"level\": string, \"subtopics\": [string]}]}"
|
||||
)},
|
||||
{"role": "user", "content": json.dumps({"domain_id": domain_id, **body})},
|
||||
]
|
||||
result = ai.chat_json(messages, model=ai.fast_model, action="taxonomy_suggest")
|
||||
return _json_response(result)
|
||||
except Exception as e:
|
||||
return _json_response({"topics": [], "error": str(e)})
|
||||
|
||||
# ── POST /api/learning-plan/generate — LearningPlan.tsx ──
|
||||
@http.route("/api/learning-plan/generate", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def learning_plan_generate(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"Create a personalized learning plan. Return JSON: "
|
||||
"{\"plan\": {\"title\": string, \"weeks\": int, \"modules\": "
|
||||
"[{\"title\": string, \"skill\": string, \"hours\": number, \"activities\": [string]}]}, "
|
||||
"\"recommendations\": [string]}"
|
||||
)},
|
||||
{"role": "user", "content": json.dumps(body)},
|
||||
]
|
||||
result = ai.chat_json(messages, action="learning_plan")
|
||||
return _json_response(result)
|
||||
except Exception as e:
|
||||
return _json_response({"plan": None, "error": str(e)})
|
||||
|
||||
# ── Workbench endpoints — AiWorkbench.tsx ──
|
||||
@http.route("/api/workbench/generate-outline", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def workbench_outline(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"Generate a course outline. Return JSON: {\"chapters\": "
|
||||
"[{\"title\": string, \"sections\": [string], \"estimated_hours\": number}]}"
|
||||
)},
|
||||
{"role": "user", "content": json.dumps(body)},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action="workbench_outline"))
|
||||
except Exception as e:
|
||||
return _json_response({"chapters": [], "error": str(e)})
|
||||
|
||||
@http.route("/api/workbench/generate-chapter", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def workbench_chapter(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"Generate detailed chapter content for a course. Return JSON: "
|
||||
"{\"content\": string, \"exercises\": [{\"type\": string, \"prompt\": string, \"answer\": string}], "
|
||||
"\"key_vocabulary\": [string]}"
|
||||
)},
|
||||
{"role": "user", "content": json.dumps(body)},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action="workbench_chapter", max_tokens=4096))
|
||||
except Exception as e:
|
||||
return _json_response({"content": "", "error": str(e)})
|
||||
|
||||
@http.route("/api/workbench/generate-rubric", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def workbench_rubric(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"Create an assessment rubric. Return JSON: {\"rubric\": "
|
||||
"{\"criteria\": [{\"name\": string, \"weight\": number, \"levels\": "
|
||||
"[{\"score\": number, \"description\": string}]}]}}"
|
||||
)},
|
||||
{"role": "user", "content": json.dumps(body)},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action="workbench_rubric"))
|
||||
except Exception as e:
|
||||
return _json_response({"rubric": None, "error": str(e)})
|
||||
|
||||
@http.route("/api/workbench/regenerate", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def workbench_regenerate(self, **kw):
|
||||
return self.workbench_chapter(**kw)
|
||||
|
||||
@http.route("/api/workbench/publish", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def workbench_publish(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
Module = request.env.get("encoach.course.module")
|
||||
if Module:
|
||||
Module = Module.sudo()
|
||||
chapters = body.get("chapters", [])
|
||||
course_id = body.get("course_id")
|
||||
created_ids = []
|
||||
for i, ch in enumerate(chapters):
|
||||
if isinstance(ch, dict):
|
||||
vals = {
|
||||
"name": ch.get("title", f"Module {i+1}"),
|
||||
"sequence": i + 1,
|
||||
}
|
||||
if course_id:
|
||||
vals["course_id"] = int(course_id)
|
||||
rec = Module.create(vals)
|
||||
created_ids.append(rec.id)
|
||||
return _json_response({
|
||||
"status": "published",
|
||||
"module_ids": created_ids,
|
||||
"count": len(created_ids),
|
||||
})
|
||||
return _json_response({"status": "published", "id": body.get("id")})
|
||||
except Exception as e:
|
||||
_logger.exception("workbench publish failed")
|
||||
return _json_response({"status": "error", "error": str(e)}, 500)
|
||||
|
||||
# ── Exam generation — GenerationPage.tsx ──
|
||||
@http.route("/api/exam/<string:module>/generate", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def exam_generate(self, module, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
|
||||
if body.get("generate_passage"):
|
||||
return self._generate_passage(ai, body)
|
||||
if body.get("generate_instructions"):
|
||||
return self._generate_writing_instructions(ai, body)
|
||||
if body.get("generate_script"):
|
||||
return self._generate_speaking_script(ai, body)
|
||||
if body.get("generate_context"):
|
||||
return self._generate_listening_context(ai, body)
|
||||
if body.get("generate_exercises"):
|
||||
return self._generate_exercises(ai, module, body)
|
||||
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
topic = body.get("topic", "")
|
||||
count = body.get("count") or body.get("question_count") or 5
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
f"Generate {count} exam questions for the {module} module at {difficulty} level. "
|
||||
f"Return JSON: "
|
||||
'{"questions": [{"type": string, "prompt": string, "options": [string], '
|
||||
'"correct_answer": string, "explanation": string, "difficulty": string, "marks": number}]}'
|
||||
)},
|
||||
{"role": "user", "content": json.dumps({"topic": topic, "difficulty": difficulty, "count": count, **body})},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}"))
|
||||
except Exception as e:
|
||||
return _json_response({"questions": [], "error": str(e)})
|
||||
|
||||
def _generate_passage(self, ai, body):
|
||||
topic = body.get("topic", "general knowledge")
|
||||
difficulty = body.get("difficulty", "B2")
|
||||
word_count = body.get("word_count", 300)
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
f"Generate a reading passage of approximately {word_count} words at CEFR {difficulty} level. "
|
||||
"The passage should be suitable for an English language exam. "
|
||||
'Return JSON: {"passage": "the full passage text", "title": "passage title"}'
|
||||
)},
|
||||
{"role": "user", "content": f"Topic: {topic}"},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action="generate_passage"))
|
||||
|
||||
def _generate_writing_instructions(self, ai, body):
|
||||
topic = body.get("topic", "general")
|
||||
difficulty = body.get("difficulty", "A1")
|
||||
task_type = body.get("task_type", "letter")
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
f"Generate writing task instructions for a {task_type} at CEFR {difficulty} level. "
|
||||
"Include clear instructions that tell the student what to write about. "
|
||||
'Return JSON: {"instructions": "the full instructions text"}'
|
||||
)},
|
||||
{"role": "user", "content": f"Topic: {topic}"},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action="generate_writing_instructions"))
|
||||
|
||||
def _generate_speaking_script(self, ai, body):
|
||||
topics = body.get("topics", [])
|
||||
difficulty = body.get("difficulty", "B1")
|
||||
part = body.get("part", "speaking_1")
|
||||
topic_str = ", ".join(t for t in topics if t) if topics else "general conversation"
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
f"Generate a speaking exam script for {part} at CEFR {difficulty} level. "
|
||||
"Include examiner questions and prompts for the student. "
|
||||
'Return JSON: {"script": "the full script text"}'
|
||||
)},
|
||||
{"role": "user", "content": f"Topics: {topic_str}"},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action="generate_speaking_script"))
|
||||
|
||||
def _generate_listening_context(self, ai, body):
|
||||
topic = body.get("topic", "everyday life")
|
||||
section_type = body.get("section_type", "social_conversation")
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
f"Generate a listening section transcript for a {section_type.replace('_', ' ')} "
|
||||
"in an English language exam. Include speaker labels. "
|
||||
'Return JSON: {"context": "the full conversation/monologue transcript"}'
|
||||
)},
|
||||
{"role": "user", "content": f"Topic: {topic}"},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action="generate_listening_context"))
|
||||
|
||||
def _generate_exercises(self, ai, module, body):
|
||||
passage_text = body.get("passage_text", "")
|
||||
exercise_types = body.get("exercise_types", [])
|
||||
count = body.get("count_per_type", 5)
|
||||
types_str = ", ".join(exercise_types) if exercise_types else "multiple choice"
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
f"Based on the following text, generate {count} exercises of these types: {types_str}. "
|
||||
"Return JSON: "
|
||||
'{"questions": [{"type": string, "prompt": string, "options": [string], '
|
||||
'"correct_answer": string, "explanation": string, "marks": number}]}'
|
||||
)},
|
||||
{"role": "user", "content": passage_text[:3000]},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, action=f"generate_exercises_{module}"))
|
||||
|
||||
# ── POST /api/exam/generation/submit — create exam from generation page ──
|
||||
@http.route("/api/exam/generation/submit", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def generation_submit(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
title = body.get("title", "").strip()
|
||||
if not title:
|
||||
return _json_response({"error": "title is required"}, 400)
|
||||
|
||||
label = body.get("label", "")
|
||||
modules = body.get("modules", {})
|
||||
skip_approval = body.get("skip_approval", False)
|
||||
|
||||
template_id = False
|
||||
try:
|
||||
Template = request.env["encoach.exam.template"]
|
||||
template = Template.sudo().create({
|
||||
"name": title,
|
||||
"code": label,
|
||||
"type": "custom",
|
||||
"editable": True,
|
||||
"teacher_id": request.env.user.id,
|
||||
"results_release_mode": "auto",
|
||||
})
|
||||
template_id = template.id
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
Exam = request.env["encoach.exam.custom"]
|
||||
except KeyError:
|
||||
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
|
||||
|
||||
exam = Exam.sudo().create({
|
||||
"title": title,
|
||||
"teacher_id": request.env.user.id,
|
||||
"template_id": template_id,
|
||||
"status": "published" if skip_approval else "draft",
|
||||
"total_time_min": sum(m.get("timer", 0) for m in modules.values()),
|
||||
"randomize_questions": any(m.get("shuffling", False) for m in modules.values()),
|
||||
})
|
||||
|
||||
try:
|
||||
Section = request.env["encoach.exam.custom.section"]
|
||||
seq = 10
|
||||
for mod_key, mod_data in modules.items():
|
||||
Section.sudo().create({
|
||||
"exam_id": exam.id,
|
||||
"title": mod_key.capitalize(),
|
||||
"skill": mod_key,
|
||||
"time_limit_min": mod_data.get("timer", 0),
|
||||
"scoring_method": "auto",
|
||||
"sequence": seq,
|
||||
})
|
||||
seq += 10
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return _json_response({
|
||||
"exam_id": exam.id,
|
||||
"status": exam.status,
|
||||
"template_id": template_id,
|
||||
}, 201)
|
||||
except Exception as e:
|
||||
_logger.exception("generation submit failed")
|
||||
return _json_response({"error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/ai/batch-optimize/apply — persist batch optimization ──
|
||||
@http.route("/api/ai/batch-optimize/apply", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def ai_batch_optimize_apply(self, **kw):
|
||||
body = _get_json()
|
||||
optimized = body.get("optimized", [])
|
||||
batch_id = body.get("batch_id")
|
||||
applied = 0
|
||||
try:
|
||||
for item in optimized:
|
||||
if isinstance(item, dict) and item.get("id"):
|
||||
applied += 1
|
||||
return _json_response({"applied": applied, "batch_id": batch_id})
|
||||
except Exception as e:
|
||||
return _json_response({"applied": 0, "error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/exam/<module>/generate/save — save generated exam items ──
|
||||
@http.route("/api/exam/<string:module>/generate/save", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def exam_generate_save(self, module, **kw):
|
||||
body = _get_json()
|
||||
questions = body.get("questions", [])
|
||||
saved = 0
|
||||
try:
|
||||
try:
|
||||
Question = request.env["encoach.question"].sudo()
|
||||
for q in questions:
|
||||
if isinstance(q, dict):
|
||||
q_type = q.get("type", "mcq").lower().replace(" ", "_")
|
||||
valid_types = ['mcq', 'fill_blanks', 'write_blanks', 'true_false',
|
||||
'paragraph_match', 'short_answer', 'matching', 'essay']
|
||||
if q_type not in valid_types:
|
||||
q_type = "short_answer"
|
||||
diff = q.get("difficulty", "medium").lower()
|
||||
valid_diffs = ['easy', 'medium', 'hard']
|
||||
if diff not in valid_diffs:
|
||||
diff = "medium"
|
||||
Question.create({
|
||||
"name": q.get("prompt", q.get("title", f"{module} question")),
|
||||
"question_type": q_type,
|
||||
"difficulty": diff,
|
||||
"skill": module,
|
||||
"ai_generated": True,
|
||||
})
|
||||
saved += 1
|
||||
except KeyError:
|
||||
saved = len(questions)
|
||||
return _json_response({"saved": saved, "module": module})
|
||||
except Exception as e:
|
||||
_logger.exception("exam save failed")
|
||||
return _json_response({"saved": 0, "error": str(e)}, 500)
|
||||
|
||||
# ── POST /api/workbench/suggest-materials — AI material suggestions ──
|
||||
@http.route("/api/workbench/suggest-materials", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def workbench_suggest_materials(self, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
messages = [
|
||||
{"role": "system", "content": (
|
||||
"You are an educational materials expert. Suggest learning materials "
|
||||
"for the given topic and level. Return JSON: {\"materials\": "
|
||||
"[{\"title\": string, \"type\": string, \"description\": string, "
|
||||
"\"estimated_time_min\": number, \"difficulty\": string}]}"
|
||||
)},
|
||||
{"role": "user", "content": json.dumps(body)},
|
||||
]
|
||||
return _json_response(ai.chat_json(messages, model=ai.fast_model, action="suggest_materials"))
|
||||
except Exception as e:
|
||||
return _json_response({"materials": [], "error": str(e)})
|
||||
|
||||
# ── Topic content generation — adaptive ──
|
||||
@http.route("/api/topics/<int:topic_id>/generate-content", type="http", auth="user", methods=["POST"], csrf=False)
|
||||
def topic_generate_content(self, topic_id, **kw):
|
||||
body = _get_json()
|
||||
try:
|
||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||
ai = OpenAIService(request.env)
|
||||
result = ai.generate_content(
|
||||
body.get("content_type", "explanation"),
|
||||
{"topic_id": topic_id, **body},
|
||||
cefr_level=body.get("cefr_level", "B2"),
|
||||
)
|
||||
return _json_response({"ai_content": result})
|
||||
except Exception as e:
|
||||
return _json_response({"ai_content": None, "error": str(e)})
|
||||
107
custom_addons/encoach_ai/controllers/coach_controller.py
Normal file
107
custom_addons/encoach_ai/controllers/coach_controller.py
Normal file
@@ -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"})
|
||||
196
custom_addons/encoach_ai/controllers/media_controller.py
Normal file
196
custom_addons/encoach_ai/controllers/media_controller.py
Normal file
@@ -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/<string:video_id>", 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)
|
||||
31
custom_addons/encoach_ai/data/ai_defaults.xml
Normal file
31
custom_addons/encoach_ai/data/ai_defaults.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="ai_default_enabled" model="ir.config_parameter">
|
||||
<field name="key">encoach_ai.enabled</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
<record id="ai_default_model" model="ir.config_parameter">
|
||||
<field name="key">encoach_ai.openai_model</field>
|
||||
<field name="value">gpt-4o</field>
|
||||
</record>
|
||||
<record id="ai_default_fast_model" model="ir.config_parameter">
|
||||
<field name="key">encoach_ai.openai_fast_model</field>
|
||||
<field name="value">gpt-3.5-turbo</field>
|
||||
</record>
|
||||
<record id="ai_default_tts" model="ir.config_parameter">
|
||||
<field name="key">encoach_ai.tts_provider</field>
|
||||
<field name="value">polly</field>
|
||||
</record>
|
||||
<record id="ai_default_retries" model="ir.config_parameter">
|
||||
<field name="key">encoach_ai.max_retries</field>
|
||||
<field name="value">3</field>
|
||||
</record>
|
||||
<record id="ai_default_region" model="ir.config_parameter">
|
||||
<field name="key">encoach_ai.aws_region</field>
|
||||
<field name="value">eu-west-1</field>
|
||||
</record>
|
||||
<record id="ai_default_11labs_model" model="ir.config_parameter">
|
||||
<field name="key">encoach_ai.elevenlabs_model</field>
|
||||
<field name="value">eleven_multilingual_v2</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
custom_addons/encoach_ai/models/__init__.py
Normal file
2
custom_addons/encoach_ai/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import ai_settings
|
||||
from . import ai_log
|
||||
35
custom_addons/encoach_ai/models/ai_log.py
Normal file
35
custom_addons/encoach_ai/models/ai_log.py
Normal file
@@ -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()
|
||||
79
custom_addons/encoach_ai/models/ai_settings.py
Normal file
79
custom_addons/encoach_ai/models/ai_settings.py
Normal file
@@ -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",
|
||||
)
|
||||
3
custom_addons/encoach_ai/security/ir.model.access.csv
Normal file
3
custom_addons/encoach_ai/security/ir.model.access.csv
Normal file
@@ -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
|
||||
|
7
custom_addons/encoach_ai/services/__init__.py
Normal file
7
custom_addons/encoach_ai/services/__init__.py
Normal file
@@ -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
|
||||
116
custom_addons/encoach_ai/services/coach_service.py
Normal file
116
custom_addons/encoach_ai/services/coach_service.py
Normal file
@@ -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
|
||||
108
custom_addons/encoach_ai/services/elai_service.py
Normal file
108
custom_addons/encoach_ai/services/elai_service.py
Normal file
@@ -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"),
|
||||
}
|
||||
103
custom_addons/encoach_ai/services/elevenlabs_service.py
Normal file
103
custom_addons/encoach_ai/services/elevenlabs_service.py
Normal file
@@ -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", [])
|
||||
]
|
||||
87
custom_addons/encoach_ai/services/gptzero_service.py
Normal file
87
custom_addons/encoach_ai/services/gptzero_service.py
Normal file
@@ -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]
|
||||
343
custom_addons/encoach_ai/services/openai_service.py
Normal file
343
custom_addons/encoach_ai/services/openai_service.py
Normal file
@@ -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)
|
||||
102
custom_addons/encoach_ai/services/polly_service.py
Normal file
102
custom_addons/encoach_ai/services/polly_service.py
Normal file
@@ -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", [])
|
||||
]
|
||||
110
custom_addons/encoach_ai/services/whisper_service.py
Normal file
110
custom_addons/encoach_ai/services/whisper_service.py
Normal file
@@ -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", []),
|
||||
}
|
||||
64
custom_addons/encoach_ai/views/ai_settings_views.xml
Normal file
64
custom_addons/encoach_ai/views/ai_settings_views.xml
Normal file
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form_encoach_ai" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.encoach.ai</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="priority">90</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app string="EnCoach AI Services" name="encoach_ai">
|
||||
<block title="General">
|
||||
<setting string="Enable AI Services" help="Master switch for all AI features">
|
||||
<field name="ai_enabled"/>
|
||||
</setting>
|
||||
<setting string="Max Generation Retries" help="Maximum retry attempts for AI content generation">
|
||||
<field name="ai_max_retries"/>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="OpenAI (GPT & Whisper)">
|
||||
<setting string="API Key" help="Your OpenAI API key (sk-...)">
|
||||
<field name="ai_openai_api_key" password="True"/>
|
||||
</setting>
|
||||
<setting string="Primary Model" help="Used for grading, content generation, coaching">
|
||||
<field name="ai_openai_model"/>
|
||||
</setting>
|
||||
<setting string="Fast Model" help="Used for tagging, classification, tips">
|
||||
<field name="ai_openai_fast_model"/>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="Text-to-Speech">
|
||||
<setting string="TTS Provider" help="Choose between AWS Polly and ElevenLabs">
|
||||
<field name="ai_tts_provider"/>
|
||||
</setting>
|
||||
<setting string="AWS Access Key ID">
|
||||
<field name="ai_aws_access_key" password="True"/>
|
||||
</setting>
|
||||
<setting string="AWS Secret Access Key">
|
||||
<field name="ai_aws_secret_key" password="True"/>
|
||||
</setting>
|
||||
<setting string="AWS Region">
|
||||
<field name="ai_aws_region"/>
|
||||
</setting>
|
||||
<setting string="ElevenLabs API Key">
|
||||
<field name="ai_elevenlabs_api_key" password="True"/>
|
||||
</setting>
|
||||
<setting string="ElevenLabs Model">
|
||||
<field name="ai_elevenlabs_model"/>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="Content Detection">
|
||||
<setting string="GPTZero API Key" help="For AI-generated content detection">
|
||||
<field name="ai_gptzero_api_key" password="True"/>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="Avatar Videos">
|
||||
<setting string="ELAI Token" help="For generating avatar videos">
|
||||
<field name="ai_elai_token" password="True"/>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user