Generation Page (complete rebuild): - Full production-parity exam generation wizard with 4 IELTS modules - Reading: AI passage gen, 5 exercise types (MCQ, Fill, Write, T/F, Match) - Listening: 4 section types, AI context gen, TTS audio gen (ElevenLabs) - Writing: Task 1/2, AI instruction gen, word limits, marks - Speaking: 3 parts, AI script gen, avatar video gen (7 avatars) - Per-module config: timer, CEFR difficulty, access, approval, rubrics - Exam submission workflow (draft/published) Exam Structures: - New encoach.exam.structure model + CRUD controller - ExamStructuresPage wired to real API AI Module (encoach_ai): - OpenAI service, ElevenLabs TTS, AWS Polly, ELAI avatars - AI settings model with Odoo config parameters - 7 generation endpoints (passage, exercises, instructions, scripts, context) Vector Module (encoach_vector): - pgvector integration for RAG-based content search - Embedding service with sentence-transformers Exam Session Fixes: - Fixed ExamSession.tsx field mapping (question_type→type, exam_title→title) - Fixed submit payload to include attempt_id and answers - Fixed normalizeType to handle null/undefined Tested: 12/12 API tests passed, browser-verified with real OpenAI calls Made-with: Cursor
344 lines
17 KiB
Python
344 lines
17 KiB
Python
"""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)
|