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
This commit is contained in:
175
src/pages/admin/AdminCoursePlans.tsx
Normal file
175
src/pages/admin/AdminCoursePlans.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BookOpen,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Calendar,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { coursePlanService } from "@/services/coursePlan.service";
|
||||
import { describeApiError } from "@/lib/api-client";
|
||||
|
||||
/**
|
||||
* Admin list of AI-generated course plans.
|
||||
*
|
||||
* Each plan row shows name + CEFR + weeks + status, plus an "Open"
|
||||
* button that deep-links to the detail page. A prominent "Generate new"
|
||||
* CTA routes to the Smart Wizard's CoursePlanWizard.
|
||||
*/
|
||||
export default function AdminCoursePlans() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["course-plans", { search }],
|
||||
queryFn: () =>
|
||||
coursePlanService.list({
|
||||
page: 0,
|
||||
size: 50,
|
||||
search: search || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (id: number) => coursePlanService.remove(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["course-plans"] });
|
||||
toast.success(t("coursePlan.deleted"));
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(describeApiError(err, t("coursePlan.deleteFailed"))),
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t("coursePlan.listTitle")}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-2xl">
|
||||
{t("coursePlan.listSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/admin/smart-wizard/course-plan")}>
|
||||
<Sparkles className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.generateNew")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-sm">
|
||||
<Input
|
||||
placeholder={t("coursePlan.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-40 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{describeApiError(error, t("coursePlan.loadFailed"))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && items.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center gap-3 py-12">
|
||||
<Sparkles className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{t("coursePlan.emptyTitle")}</div>
|
||||
<div className="text-sm text-muted-foreground max-w-md">
|
||||
{t("coursePlan.emptySubtitle")}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/admin/smart-wizard/course-plan")}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("coursePlan.generateNew")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && items.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((plan) => (
|
||||
<Card key={plan.id} className="flex flex-col">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base line-clamp-2">{plan.name}</CardTitle>
|
||||
<Badge variant={plan.status === "approved" ? "default" : "outline"}>
|
||||
{t(`coursePlan.status.${plan.status}`, plan.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{plan.description || t("coursePlan.noDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge variant="secondary" className="text-[10px] uppercase">
|
||||
{plan.cefr_level || "—"}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
{t("coursePlan.materialsCount", { count: plan.material_count })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="default" size="sm" className="flex-1">
|
||||
<Link to={`/admin/course-plans/${plan.id}`}>
|
||||
{t("coursePlan.open")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
if (!window.confirm(t("coursePlan.confirmDelete", { name: plan.name }))) return;
|
||||
removeMut.mutate(plan.id);
|
||||
}}
|
||||
aria-label={t("coursePlan.delete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user