feat: Generation Page AI workflows + AI/Vector modules + exam session fixes

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
This commit is contained in:
Yamen Ahmad
2026-04-11 14:27:03 +04:00
parent 0c8443256d
commit 6a62a43d61
34 changed files with 2261 additions and 77 deletions

View File

@@ -0,0 +1 @@
from . import embedding

View File

@@ -0,0 +1,121 @@
"""Odoo model for storing vector embeddings via pgvector."""
import json
import logging
from odoo import api, models, fields
_logger = logging.getLogger(__name__)
VECTOR_DIM = 384 # all-MiniLM-L6-v2 output dimension
class EncoachEmbedding(models.Model):
_name = 'encoach.embedding'
_description = 'Vector Embedding'
_order = 'create_date desc'
content_type = fields.Selection([
('course', 'Course'),
('resource', 'Resource'),
('question', 'Question'),
('module', 'Module'),
('topic', 'Topic'),
('feedback', 'Feedback'),
('generation_log', 'Generation Log'),
], required=True, index=True)
content_id = fields.Integer(required=True, index=True)
content_text = fields.Text()
metadata_json = fields.Text(default='{}')
_content_unique = models.Constraint(
'UNIQUE(content_type, content_id)',
'Each content item can only have one embedding.',
)
@api.model
def _auto_init(self):
res = super()._auto_init()
cr = self.env.cr
cr.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'")
if not cr.fetchone():
try:
cr.execute("CREATE EXTENSION IF NOT EXISTS vector")
_logger.info("pgvector extension created")
except Exception:
_logger.warning(
"Could not create pgvector extension — run "
"'CREATE EXTENSION vector' as a superuser",
exc_info=True,
)
return res
cr.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'encoach_embedding' AND column_name = 'embedding'
""")
if not cr.fetchone():
cr.execute(
f"ALTER TABLE encoach_embedding ADD COLUMN embedding vector({VECTOR_DIM})"
)
cr.execute(
"CREATE INDEX IF NOT EXISTS encoach_embedding_vec_idx "
"ON encoach_embedding USING ivfflat (embedding vector_cosine_ops) "
"WITH (lists = 100)"
)
_logger.info("Vector column and index created on encoach_embedding")
return res
def set_embedding(self, vector):
"""Store a vector embedding for this record."""
self.ensure_one()
vec_str = '[' + ','.join(str(v) for v in vector) + ']'
self.env.cr.execute(
"UPDATE encoach_embedding SET embedding = %s WHERE id = %s",
(vec_str, self.id),
)
@api.model
def cron_reindex(self):
"""Cron entry point for periodic re-indexing."""
from odoo.addons.encoach_vector.services.indexer import index_all
return index_all(self.env)
@api.model
def similarity_search(self, query_vector, *, content_type=None, limit=10):
"""Find similar embeddings using cosine distance."""
vec_str = '[' + ','.join(str(v) for v in query_vector) + ']'
where = "WHERE embedding IS NOT NULL"
params = [vec_str, limit]
if content_type:
where += " AND content_type = %s"
params = [vec_str, content_type, limit]
query = f"""
SELECT id, content_type, content_id, content_text, metadata_json,
1 - (embedding <=> %s::vector) AS similarity
FROM encoach_embedding
{where}
ORDER BY embedding <=> %s::vector
LIMIT %s
"""
if content_type:
self.env.cr.execute(query, (vec_str, content_type, vec_str, limit))
else:
self.env.cr.execute(query, (vec_str, vec_str, limit))
results = []
for row in self.env.cr.dictfetchall():
metadata = {}
try:
metadata = json.loads(row['metadata_json'] or '{}')
except (json.JSONDecodeError, TypeError):
pass
results.append({
'id': row['id'],
'content_type': row['content_type'],
'content_id': row['content_id'],
'text': row['content_text'],
'metadata': metadata,
'similarity': round(row['similarity'], 4),
})
return results