Files
encoach_frontend_v4/src/pages/admin/wizards/CoursePlanWizard.tsx
Yamen Ahmad 45408acd5c feat(ai): LangGraph as core runtime + AI Agents/Tools console + full-demo seed
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
2026-04-25 03:14:22 +04:00

374 lines
12 KiB
TypeScript

import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { X } from "lucide-react";
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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type { CoursePlanGenerateBrief } from "@/types";
/**
* AI course-plan generation wizard.
*
* Four steps:
* 1. Basics — title, CEFR level, total weeks, contact hours.
* 2. Coverage — skills division (e.g. "10 hrs Reading+Writing /
* 8 hrs Listening+Speaking"), learner profile.
* 3. Scope — grammar focus chips, resource citations chips,
* optional free-form notes.
* 4. Review — finish → POST /api/ai/course-plan which triggers
* the OpenAI call, persists the plan, and returns
* the finished record; we navigate to the detail
* page so the user can start generating Week 1
* materials immediately.
*/
interface CoursePlanWizardState {
title: string;
cefr_level: string;
total_weeks: number;
contact_hours_per_week: number;
skills_division: string;
learner_profile: string;
grammar_focus: string[];
resources: string[];
notes: string;
}
const CEFR_OPTIONS = [
{ 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" },
];
export default function CoursePlanWizard() {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: (brief: CoursePlanGenerateBrief) =>
coursePlanService.generate(brief),
onSuccess: (resp) => {
qc.invalidateQueries({ queryKey: ["course-plans"] });
toast.success(t("coursePlan.generateSuccess"));
const id = resp?.data?.id;
if (id) navigate(`/admin/course-plans/${id}`);
else navigate("/admin/course-plans");
},
onError: (err) => {
toast.error(describeApiError(err, t("coursePlan.generateFailed")));
},
});
const steps: WizardStepDef<CoursePlanWizardState>[] = [
{
id: "basics",
titleKey: "coursePlan.wizard.steps.basics",
descriptionKey: "coursePlan.wizard.steps.basicsDesc",
validate: (s) => {
if (!s.title.trim()) return t("coursePlan.wizard.errors.titleRequired");
if (!s.cefr_level) return t("coursePlan.wizard.errors.cefrRequired");
if (!s.total_weeks || s.total_weeks < 1)
return t("coursePlan.wizard.errors.weeksRange");
return null;
},
render: ({ state, update }) => (
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Label htmlFor="title">{t("coursePlan.wizard.fields.title")}</Label>
<Input
id="title"
placeholder="General English 1"
value={state.title}
onChange={(e) => update({ title: e.target.value })}
/>
</div>
<div>
<Label>{t("coursePlan.wizard.fields.cefr")}</Label>
<Select
value={state.cefr_level}
onValueChange={(v) => update({ cefr_level: v })}
>
<SelectTrigger>
<SelectValue placeholder={t("coursePlan.wizard.fields.cefrPlaceholder")} />
</SelectTrigger>
<SelectContent>
{CEFR_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="weeks">{t("coursePlan.wizard.fields.totalWeeks")}</Label>
<Input
id="weeks"
type="number"
min={1}
max={30}
value={state.total_weeks}
onChange={(e) => update({ total_weeks: Number(e.target.value) || 0 })}
/>
</div>
<div>
<Label htmlFor="hours">{t("coursePlan.wizard.fields.contactHours")}</Label>
<Input
id="hours"
type="number"
min={1}
max={40}
value={state.contact_hours_per_week}
onChange={(e) =>
update({ contact_hours_per_week: Number(e.target.value) || 0 })
}
/>
</div>
</div>
),
},
{
id: "coverage",
titleKey: "coursePlan.wizard.steps.coverage",
descriptionKey: "coursePlan.wizard.steps.coverageDesc",
render: ({ state, update }) => (
<div className="grid gap-4">
<div>
<Label htmlFor="skills">
{t("coursePlan.wizard.fields.skillsDivision")}
</Label>
<Input
id="skills"
placeholder="10 hrs/wk Reading & Writing + 8 hrs/wk Listening & Speaking"
value={state.skills_division}
onChange={(e) => update({ skills_division: e.target.value })}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t("coursePlan.wizard.fields.skillsDivisionHint")}
</p>
</div>
<div>
<Label htmlFor="profile">
{t("coursePlan.wizard.fields.learnerProfile")}
</Label>
<Textarea
id="profile"
rows={3}
placeholder={t("coursePlan.wizard.fields.learnerProfilePlaceholder")}
value={state.learner_profile}
onChange={(e) => update({ learner_profile: e.target.value })}
/>
</div>
</div>
),
},
{
id: "scope",
titleKey: "coursePlan.wizard.steps.scope",
descriptionKey: "coursePlan.wizard.steps.scopeDesc",
render: ({ state, update }) => (
<div className="grid gap-4">
<ChipInput
label={t("coursePlan.wizard.fields.grammarFocus")}
placeholder={t("coursePlan.wizard.fields.grammarFocusPlaceholder")}
values={state.grammar_focus}
onChange={(next) => update({ grammar_focus: next })}
/>
<ChipInput
label={t("coursePlan.wizard.fields.resources")}
placeholder={t("coursePlan.wizard.fields.resourcesPlaceholder")}
values={state.resources}
onChange={(next) => update({ resources: next })}
/>
<div>
<Label htmlFor="notes">{t("coursePlan.wizard.fields.notes")}</Label>
<Textarea
id="notes"
rows={3}
placeholder={t("coursePlan.wizard.fields.notesPlaceholder")}
value={state.notes}
onChange={(e) => update({ notes: e.target.value })}
/>
</div>
</div>
),
},
{
id: "review",
titleKey: "coursePlan.wizard.steps.review",
descriptionKey: "coursePlan.wizard.steps.reviewDesc",
render: ({ state }) => (
<div className="space-y-3 text-sm">
<ReviewRow label={t("coursePlan.wizard.fields.title")} value={state.title} />
<ReviewRow
label={t("coursePlan.wizard.fields.cefr")}
value={
CEFR_OPTIONS.find((o) => o.value === state.cefr_level)?.label ||
state.cefr_level ||
"—"
}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.totalWeeks")}
value={String(state.total_weeks || "—")}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.contactHours")}
value={String(state.contact_hours_per_week || "—")}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.skillsDivision")}
value={state.skills_division || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.learnerProfile")}
value={state.learner_profile || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.grammarFocus")}
value={state.grammar_focus.join(", ") || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.resources")}
value={state.resources.join(", ") || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.notes")}
value={state.notes || "—"}
/>
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{t("coursePlan.wizard.reviewHint")}
</div>
</div>
),
},
];
return (
<StepWizard<CoursePlanWizardState>
titleKey="coursePlan.wizard.title"
subtitleKey="coursePlan.wizard.subtitle"
backTo="/admin/smart-wizard"
steps={steps}
submitting={mutation.isPending}
finishLabelKey="coursePlan.wizard.finish"
initialState={{
title: "",
cefr_level: "a2",
total_weeks: 12,
contact_hours_per_week: 18,
skills_division: "",
learner_profile: "",
grammar_focus: [],
resources: [],
notes: "",
}}
onFinish={async (state) => {
await mutation.mutateAsync({
title: state.title.trim(),
cefr_level: state.cefr_level,
total_weeks: state.total_weeks,
contact_hours_per_week: state.contact_hours_per_week,
skills_division: state.skills_division.trim() || undefined,
learner_profile: state.learner_profile.trim() || undefined,
grammar_focus: state.grammar_focus.length
? state.grammar_focus
: undefined,
resources: state.resources.length ? state.resources : undefined,
notes: state.notes.trim() || undefined,
});
}}
/>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-3 gap-2">
<div className="col-span-1 text-muted-foreground">{label}</div>
<div className="col-span-2 font-medium break-words">{value}</div>
</div>
);
}
/**
* Tiny chip input: press Enter (or comma) to commit a value. Used for
* grammar focus items and resource citations. Kept local because it's
* not worth promoting to a reusable component yet.
*/
function ChipInput({
label,
placeholder,
values,
onChange,
}: {
label: string;
placeholder?: string;
values: string[];
onChange: (next: string[]) => void;
}) {
const [draft, setDraft] = useState("");
const commit = () => {
const clean = draft.trim();
if (!clean) return;
if (!values.includes(clean)) onChange([...values, clean]);
setDraft("");
};
return (
<div>
<Label>{label}</Label>
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded-md border px-2 py-2">
{values.map((v, i) => (
<Badge key={`${v}-${i}`} variant="secondary" className="gap-1">
{v}
<button
type="button"
className="ml-1 rounded-sm hover:bg-destructive/20"
onClick={() => onChange(values.filter((_, idx) => idx !== i))}
aria-label={`Remove ${v}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
<Input
placeholder={placeholder}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commit();
} else if (e.key === "Backspace" && !draft && values.length) {
onChange(values.slice(0, -1));
}
}}
onBlur={commit}
className="flex-1 border-0 shadow-none focus-visible:ring-0 h-8 min-w-[8rem] px-1"
/>
</div>
</div>
);
}