Core AI runtime - New encoach.ai.agent + encoach.ai.tool models with M2M tool binding, graph topology (simple|plan_review_revise|rag|react), model + fallback, temperature, max_tokens, response_format, max_revisions, quality checks and system prompt fields. - services/agent_runtime.py compiles a langgraph.StateGraph per agent and caches the build per (key, write_date). Emits a structured trace (output, tool_calls, retrieval_hits, revisions, quality_issues, ms, model_used, fallback_used) and auto-falls-back on rate-limit/5xx. - services/agent_tools.py registers 11 tool handlers wrapping existing services: resources.search, rubric.fetch, outcomes.fetch, student.profile, quality.cefr_check, quality.ai_detect, quality.content_gate, course_plan.save (mutates), course_plan.save_materials (mutates), scoring.grade_writing, scoring.grade_speaking. - 7 default agents seeded via data/agents_defaults.xml: course_planner, course_week_materials, exam_generator, exercise_generator, lms_tutor, writing_grader, speaking_grader. - Feature flag encoach_ai.use_langgraph_runtime (default True). - encoach_ai_course pipeline now routes through AgentRuntime when on, legacy SDK path kept as fallback. Admin UI - /admin/ai/prompts is now a tabbed Agents | Tools | Prompts console. - AIAgentsPanel: card grid + config dialog (model/temp/graph/tools/ system prompt) + built-in Test Runner showing live trace. - AIToolsPanel: registry table with category badges, mutates flag, schema viewer, edit dialog. - New /api/ai/agents* and /api/ai/tools* controller (list/get/update/ test, list-tools, toggle-tool). - Sidebar label nav.aiPrompts -> nav.aiAgents (AI Agents and Tools). - EN + AR (RTL) translations for ~80 new keys. Smart Wizard pages - /admin/quick-setup hub + CourseWizard, CoursePlanWizard, RubricWizard, ExamStructureWizard step-by-step flows. - /admin/course-plans list + detail pages. - /teacher/quick-setup mirror. Full demo seed + 8-role E2E - seed_full_demo.py adds the 5 missing user_types (approver, corporate, mastercorporate, agent, developer), activates a 2-stage exam-approval workflow with one pending request, creates a GE1-aligned 12-week B1 course plan with 6 detailed Week-1 materials (reading 400w, writing, listening 4-min script, speaking, grammar present simple vs continuous, vocabulary), and inserts sample ai.log + ai.feedback rows. - reset_demo_passwords.py forces every demo login back to canonical passwords (admin123/teacher123/student123/approver123/corporate123/ master123/agent123/dev123). - e2e_full_scenario.py: 46/46 PASS read-only API smoke across all 8 roles, including a live LangGraph round-trip on writing_grader. - e2e_approval_chain.py: 6/6 PASS mutation E2E - approver approves stage 1, admin approves stage 2, linked encoach.exam.custom flips to status=published, verified via psql. Docs - docs/PROJECT_SUMMARY.md updated to 2026-04-25: new Latest events bullets, refreshed credentials table, full sections 22 (LangGraph runtime) and 23 (full demo seed + 8-role E2E). - docs/ENCOACH_FULL_DEMO_QA_REPORT.md added with credentials, per-endpoint PASS/FAIL, mutation chain proof, LangGraph live output. - backend/GE1 Course Outline_ Fall AY25-26.pdf vendored as the reference outline the GE1 plan/materials are aligned to. Dependencies - requirements.txt: langgraph>=0.2.0, langchain-core>=0.3.0. - encoach_ai/__manifest__.py: external_dependencies updated. Made-with: Cursor
272 lines
9.2 KiB
TypeScript
272 lines
9.2 KiB
TypeScript
import { useNavigate } from "react-router-dom";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
|
|
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { lmsService } from "@/services/lms.service";
|
|
import { describeApiError } from "@/lib/api-client";
|
|
import type { CourseCreateRequest } from "@/types";
|
|
|
|
/**
|
|
* Course creation wizard (3 steps).
|
|
*
|
|
* 1. Basics — title, code (auto-derived from title by default), description
|
|
* 2. Level & capacity — difficulty, CEFR level, max capacity
|
|
* 3. Review → Finish posts to lmsService.createCourse
|
|
*
|
|
* Mirrors the fields of the existing AdminCourses "New course" dialog but
|
|
* surfaced as a guided flow. Fields the admin normally leaves blank
|
|
* (topic_ids, tag_ids, subject_id) are omitted here; they can be refined
|
|
* on the full course page after the course is created.
|
|
*/
|
|
|
|
interface CourseWizardState {
|
|
title: string;
|
|
code: string;
|
|
description: string;
|
|
difficulty_level: "beginner" | "intermediate" | "advanced" | "";
|
|
cefr_level: string;
|
|
max_capacity: number;
|
|
}
|
|
|
|
const INITIAL: CourseWizardState = {
|
|
title: "",
|
|
code: "",
|
|
description: "",
|
|
difficulty_level: "",
|
|
cefr_level: "",
|
|
max_capacity: 30,
|
|
};
|
|
|
|
/**
|
|
* Odoo `op.course.cefr_level` is a Selection field with lowercase keys
|
|
* (`pre_a1`, `a1`, `a2`, …). Sending `"A2"` raises
|
|
* `Wrong value for op.course.cefr_level` (HTTP 500). We display the
|
|
* uppercase label but always submit the lowercase key.
|
|
*/
|
|
const CEFR_LEVELS: { value: string; label: string }[] = [
|
|
{ value: "pre_a1", label: "Pre-A1" },
|
|
{ value: "a1", label: "A1" },
|
|
{ value: "a2", label: "A2" },
|
|
{ value: "b1", label: "B1" },
|
|
{ value: "b2", label: "B2" },
|
|
{ value: "c1", label: "C1" },
|
|
{ value: "c2", label: "C2" },
|
|
];
|
|
|
|
function deriveCode(title: string): string {
|
|
return title.trim().toUpperCase().replace(/\s+/g, "-").slice(0, 16);
|
|
}
|
|
|
|
export default function CourseWizard() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: (data: CourseCreateRequest) => lmsService.createCourse(data),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
|
|
toast.success(t("wizard.course.toastSuccess"));
|
|
navigate("/admin/courses");
|
|
},
|
|
onError: (err: unknown) => {
|
|
toast.error(describeApiError(err, t("wizard.course.toastError")));
|
|
},
|
|
});
|
|
|
|
const steps: WizardStepDef<CourseWizardState>[] = [
|
|
{
|
|
id: "basics",
|
|
titleKey: "wizard.course.step1.title",
|
|
descriptionKey: "wizard.course.step1.description",
|
|
validate: (s) => {
|
|
if (!s.title.trim()) return t("wizard.course.errors.titleRequired");
|
|
return null;
|
|
},
|
|
render: ({ state, update }) => (
|
|
<div className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="course-title">{t("wizard.course.labels.title")}</Label>
|
|
<Input
|
|
id="course-title"
|
|
value={state.title}
|
|
onChange={(e) => update({ title: e.target.value })}
|
|
placeholder={t("wizard.course.placeholders.title")}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="course-code">{t("wizard.course.labels.code")}</Label>
|
|
<Input
|
|
id="course-code"
|
|
value={state.code}
|
|
onChange={(e) => update({ code: e.target.value })}
|
|
placeholder={deriveCode(state.title) || t("wizard.course.placeholders.code")}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">{t("wizard.course.codeHint")}</p>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="course-desc">{t("wizard.course.labels.description")}</Label>
|
|
<Textarea
|
|
id="course-desc"
|
|
value={state.description}
|
|
onChange={(e) => update({ description: e.target.value })}
|
|
placeholder={t("wizard.course.placeholders.description")}
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
|
|
{
|
|
id: "level",
|
|
titleKey: "wizard.course.step2.title",
|
|
descriptionKey: "wizard.course.step2.description",
|
|
validate: (s) => {
|
|
if (s.max_capacity < 1) return t("wizard.course.errors.capacityRequired");
|
|
return null;
|
|
},
|
|
render: ({ state, update }) => (
|
|
<div className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label>{t("wizard.course.labels.difficulty")}</Label>
|
|
<Select
|
|
value={state.difficulty_level || undefined}
|
|
onValueChange={(v) =>
|
|
update({ difficulty_level: v as CourseWizardState["difficulty_level"] })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t("wizard.course.placeholders.difficulty")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="beginner">{t("wizard.course.difficulty.beginner")}</SelectItem>
|
|
<SelectItem value="intermediate">
|
|
{t("wizard.course.difficulty.intermediate")}
|
|
</SelectItem>
|
|
<SelectItem value="advanced">{t("wizard.course.difficulty.advanced")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">{t("wizard.course.difficultyHint")}</p>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>{t("wizard.course.labels.cefrLevel")}</Label>
|
|
<Select
|
|
value={state.cefr_level || undefined}
|
|
onValueChange={(v) => update({ cefr_level: v })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t("wizard.course.placeholders.cefr")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CEFR_LEVELS.map((lvl) => (
|
|
<SelectItem key={lvl.value} value={lvl.value}>
|
|
{lvl.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">{t("wizard.course.cefrHint")}</p>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="course-capacity">{t("wizard.course.labels.capacity")}</Label>
|
|
<Input
|
|
id="course-capacity"
|
|
type="number"
|
|
min={1}
|
|
value={state.max_capacity}
|
|
onChange={(e) => update({ max_capacity: Number(e.target.value) || 0 })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
|
|
{
|
|
id: "review",
|
|
titleKey: "wizard.course.step3.title",
|
|
descriptionKey: "wizard.course.step3.description",
|
|
render: ({ state }) => (
|
|
<div className="space-y-4 text-sm">
|
|
<ReviewRow label={t("wizard.course.labels.title")} value={state.title} />
|
|
<ReviewRow
|
|
label={t("wizard.course.labels.code")}
|
|
value={state.code || deriveCode(state.title)}
|
|
/>
|
|
{state.description && (
|
|
<ReviewRow
|
|
label={t("wizard.course.labels.description")}
|
|
value={state.description}
|
|
wrap
|
|
/>
|
|
)}
|
|
<ReviewRow
|
|
label={t("wizard.course.labels.difficulty")}
|
|
value={
|
|
state.difficulty_level
|
|
? t(`wizard.course.difficulty.${state.difficulty_level}`)
|
|
: "—"
|
|
}
|
|
/>
|
|
<ReviewRow
|
|
label={t("wizard.course.labels.cefrLevel")}
|
|
value={
|
|
CEFR_LEVELS.find((l) => l.value === state.cefr_level)?.label || "—"
|
|
}
|
|
/>
|
|
<ReviewRow
|
|
label={t("wizard.course.labels.capacity")}
|
|
value={String(state.max_capacity)}
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<StepWizard<CourseWizardState>
|
|
titleKey="wizard.course.title"
|
|
subtitleKey="wizard.course.subtitle"
|
|
backTo="/admin/smart-wizard"
|
|
steps={steps}
|
|
initialState={INITIAL}
|
|
submitting={createMut.isPending}
|
|
finishLabelKey="wizard.course.finish"
|
|
onFinish={async (state) => {
|
|
const payload: Partial<CourseCreateRequest> = {
|
|
title: state.title.trim(),
|
|
code: state.code.trim() || deriveCode(state.title),
|
|
description: state.description,
|
|
max_capacity: state.max_capacity,
|
|
difficulty_level: state.difficulty_level || undefined,
|
|
cefr_level: state.cefr_level || undefined,
|
|
};
|
|
await createMut.mutateAsync(payload as CourseCreateRequest);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ReviewRow({ label, value, wrap }: { label: string; value: string; wrap?: boolean }) {
|
|
return (
|
|
<div className="grid grid-cols-[140px_1fr] gap-2">
|
|
<div className="text-muted-foreground">{label}</div>
|
|
<div className={wrap ? "whitespace-pre-wrap" : ""}>{value || "—"}</div>
|
|
</div>
|
|
);
|
|
}
|