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]] )