EnCoach Odoo 19 custom modules
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
This commit is contained in:
1
encoach_training/services/__init__.py
Normal file
1
encoach_training/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import training_service
|
||||
177
encoach_training/services/training_service.py
Normal file
177
encoach_training/services/training_service.py
Normal file
@@ -0,0 +1,177 @@
|
||||
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]]
|
||||
)
|
||||
Reference in New Issue
Block a user