Full backend implementation with custom Odoo modules: - encoach_api: Core API, user management, JWT auth - encoach_exam: Exam generation (reading, writing, listening, speaking) - encoach_evaluate: AI-powered evaluation (writing, speaking) - encoach_training: Training tips and walkthrough - encoach_storage: File storage management - encoach_payment: Stripe, PayPal, Paymob integration - encoach_mail: Email notifications Made-with: Cursor
178 lines
5.8 KiB
Python
178 lines
5.8 KiB
Python
import json
|
|
import logging
|
|
import pickle
|
|
|
|
import numpy as np
|
|
|
|
from odoo.addons.encoach_ai.models.constants import GPT_MODELS, TEMPERATURE
|
|
from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
TIPS_PROMPT = (
|
|
"You are an IELTS tutor. Based on the context, the question, the "
|
|
"student's wrong answer, and the correct answer, provide a concise, "
|
|
"helpful tip to help the student improve. Use the following reference "
|
|
"tips to inform your response but adapt them to the specific situation.\n\n"
|
|
"Reference tips:\n{tips}\n\n"
|
|
"Context: {context}\n"
|
|
"Question: {question}\n"
|
|
"Student's answer: {answer}\n"
|
|
"Correct answer: {correct_answer}\n\n"
|
|
"Provide your response as JSON: "
|
|
'{{"tips": "your personalized tip text"}}'
|
|
)
|
|
|
|
TRAINING_PROMPT = (
|
|
"You are an IELTS training advisor. Analyze the student's exam history "
|
|
"and generate personalized training recommendations.\n\n"
|
|
"Student stats:\n{stats}\n\n"
|
|
"Generate a JSON response with:\n"
|
|
'{{"exams": [recommended practice exams], '
|
|
'"tips": [personalized tips], '
|
|
'"weakAreas": [identified weak areas with suggestions]}}'
|
|
)
|
|
|
|
|
|
class EncoachTrainingService:
|
|
|
|
def __init__(self, env):
|
|
self.env = env
|
|
self.ai = EncoachOpenAIService(env)
|
|
self._faiss_index = None
|
|
self._tip_ids = None
|
|
|
|
def fetch_tips(self, context, question, answer, correct_answer):
|
|
"""Retrieve relevant tips using FAISS + generate personalized tip via GPT."""
|
|
query = f"{context} {question} {answer}"
|
|
similar_tips = self._query_faiss(query, category=None, top_k=5)
|
|
|
|
tips_text = "\n".join(
|
|
f"- [{t.category}] {t.content}" for t in similar_tips
|
|
)
|
|
|
|
prompt = TIPS_PROMPT.format(
|
|
tips=tips_text,
|
|
context=context,
|
|
question=question,
|
|
answer=answer,
|
|
correct_answer=correct_answer,
|
|
)
|
|
|
|
result = self.ai.prediction(
|
|
model=GPT_MODELS["grading"],
|
|
messages=[
|
|
{"role": "system", "content": "You are a helpful IELTS tutor. Output JSON."},
|
|
{"role": "user", "content": prompt},
|
|
],
|
|
temperature=TEMPERATURE["tips"],
|
|
check_blacklisted=False,
|
|
)
|
|
return result
|
|
|
|
def get_training_content(self, user_id, stats):
|
|
"""Generate full training content for a user based on their exam stats."""
|
|
prompt = TRAINING_PROMPT.format(stats=json.dumps(stats))
|
|
|
|
result = self.ai.prediction(
|
|
model=GPT_MODELS["grading"],
|
|
messages=[
|
|
{"role": "system", "content": "You are an IELTS training advisor. Output JSON."},
|
|
{"role": "user", "content": prompt},
|
|
],
|
|
temperature=TEMPERATURE["tips"],
|
|
check_blacklisted=False,
|
|
)
|
|
|
|
if result:
|
|
training = self.env["encoach.training"].sudo().create({
|
|
"user_id": user_id,
|
|
"exams": result.get("exams"),
|
|
"tips": result.get("tips"),
|
|
"weak_areas": result.get("weakAreas"),
|
|
})
|
|
return training
|
|
return None
|
|
|
|
def _query_faiss(self, query, category=None, top_k=5):
|
|
"""Search FAISS index for similar training tips.
|
|
|
|
Falls back to keyword search if FAISS is not available.
|
|
"""
|
|
try:
|
|
import faiss
|
|
from sentence_transformers import SentenceTransformer
|
|
except ImportError:
|
|
_logger.warning("FAISS/sentence-transformers not installed, falling back to keyword search")
|
|
return self._keyword_fallback(query, category, top_k)
|
|
|
|
self._ensure_faiss_index()
|
|
if self._faiss_index is None:
|
|
return self._keyword_fallback(query, category, top_k)
|
|
|
|
model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
query_vec = model.encode([query]).astype(np.float32)
|
|
|
|
distances, indices = self._faiss_index.search(query_vec, top_k * 2)
|
|
|
|
tip_ids = [self._tip_ids[i] for i in indices[0] if i < len(self._tip_ids)]
|
|
domain = [("id", "in", tip_ids)]
|
|
if category:
|
|
domain.append(("category", "=", category))
|
|
|
|
tips = self.env["encoach.training.tip"].sudo().search(domain, limit=top_k)
|
|
return tips
|
|
|
|
def _ensure_faiss_index(self):
|
|
"""Build or load the FAISS index from stored tip embeddings."""
|
|
if self._faiss_index is not None:
|
|
return
|
|
|
|
try:
|
|
import faiss
|
|
except ImportError:
|
|
return
|
|
|
|
tips = self.env["encoach.training.tip"].sudo().search(
|
|
[("embedding", "!=", False)]
|
|
)
|
|
if not tips:
|
|
return
|
|
|
|
vectors = []
|
|
ids = []
|
|
for tip in tips:
|
|
vec = pickle.loads(tip.embedding)
|
|
vectors.append(vec)
|
|
ids.append(tip.id)
|
|
|
|
matrix = np.array(vectors, dtype=np.float32)
|
|
|
|
dim = matrix.shape[1]
|
|
index = faiss.IndexFlatL2(dim)
|
|
index.add(matrix)
|
|
|
|
self._faiss_index = index
|
|
self._tip_ids = ids
|
|
|
|
def _keyword_fallback(self, query, category, top_k):
|
|
"""Simple keyword-based search when FAISS is unavailable."""
|
|
domain = []
|
|
if category:
|
|
domain.append(("category", "=", category))
|
|
|
|
tips = self.env["encoach.training.tip"].sudo().search(domain, limit=200)
|
|
|
|
query_words = set(query.lower().split())
|
|
scored = []
|
|
for tip in tips:
|
|
content_words = set(tip.content.lower().split())
|
|
overlap = len(query_words & content_words)
|
|
if overlap > 0:
|
|
scored.append((overlap, tip))
|
|
|
|
scored.sort(key=lambda x: x[0], reverse=True)
|
|
return self.env["encoach.training.tip"].browse(
|
|
[t.id for _, t in scored[:top_k]]
|
|
)
|