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 140ca7408d
commit b02ee8b6b7
64 changed files with 2639 additions and 264 deletions

View File

@@ -1,32 +1,31 @@
import { useEffect, useMemo } from "react";
import { useMutation } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Sparkles, TrendingUp, AlertTriangle, Trophy, Loader2 } from "lucide-react";
import { analyticsService } from "@/services/analytics.service";
import type { AiInsight } from "@/types";
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
import { useToast } from "@/hooks/use-toast";
const EMPTY_PAYLOAD: Record<string, unknown> = {};
function insightIcon(type: AiInsight["type"]) {
switch (type) {
case "positive":
return Trophy;
case "warning":
function insightIcon(severity: AiInsightItem["severity"]) {
switch (severity) {
case "critical":
return AlertTriangle;
default:
case "warning":
return TrendingUp;
default:
return Info;
}
}
function insightColor(type: AiInsight["type"]) {
switch (type) {
case "positive":
return "text-primary";
function insightColor(severity: AiInsightItem["severity"]) {
switch (severity) {
case "critical":
return "text-destructive";
case "warning":
return "text-warning";
default:
return "text-success";
return "text-primary";
}
}
@@ -51,10 +50,10 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
useEffect(() => {
mutation.mutate(data);
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when serialized payload changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [payloadKey]);
const items = mutation.data ?? [];
const items = mutation.data?.insights ?? [];
return (
<Card className="border-0 shadow-sm">
@@ -79,19 +78,19 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
)}
{!mutation.isPending && items.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{items.map((item) => {
const Icon = insightIcon(item.type);
const color = insightColor(item.type);
{items.map((item, idx) => {
const Icon = insightIcon(item.severity);
const color = insightColor(item.severity);
return (
<div key={item.id} className="rounded-lg border bg-muted/30 p-4">
<div key={idx} className="rounded-lg border bg-muted/30 p-4">
<div className="flex items-center gap-2 mb-2">
<Icon className={`h-4 w-4 ${color}`} />
<span className="text-sm font-semibold">{item.title}</span>
</div>
<p className="text-sm text-muted-foreground">{item.description}</p>
{item.metric != null && item.value != null && (
<p className="text-xs text-muted-foreground mt-2">
{item.metric}: {item.value}
{item.recommendation && (
<p className="text-xs text-muted-foreground mt-2 italic">
{item.recommendation}
</p>
)}
</div>