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:
Yamen Ahmad
2026-04-25 03:13:55 +04:00
parent bbb2b44ae0
commit 45408acd5c
29 changed files with 5521 additions and 27 deletions

View 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>
);
}