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
235 lines
7.6 KiB
TypeScript
235 lines
7.6 KiB
TypeScript
import { ReactNode, useMemo, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ArrowLeft, ArrowRight, Check, ChevronLeft } from "lucide-react";
|
|
|
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* Generic multi-step wizard shell.
|
|
*
|
|
* A wizard is defined as an ordered array of {@link WizardStepDef} items.
|
|
* Each step owns its own piece of the accumulated state and returns a node
|
|
* that renders the form. The shell handles:
|
|
*
|
|
* - rendering the numbered stepper (with `completed` / `active` styles)
|
|
* - Back / Next navigation
|
|
* - a final "Finish" button that calls `onFinish(state)`
|
|
* - per-step validation via `step.validate(state)` → return an error
|
|
* message or `null` when valid
|
|
* - a persistent progress bar
|
|
*
|
|
* Keeping this shell free of domain logic means every scenario wizard
|
|
* (rubric, course, structure, …) is just a tiny file that defines steps
|
|
* and wires the final submit to the right service.
|
|
*/
|
|
|
|
export interface WizardStepDef<TState> {
|
|
id: string;
|
|
/** i18n key for the step title. */
|
|
titleKey: string;
|
|
/** Optional i18n key for a short description shown under the title. */
|
|
descriptionKey?: string;
|
|
/**
|
|
* Render the step's form. Call `update(patch)` to merge fields into the
|
|
* shared state. Do **not** call `onNext` directly — the shell handles
|
|
* Next/Back; the render function should just update local fields.
|
|
*/
|
|
render: (props: StepRenderProps<TState>) => ReactNode;
|
|
/**
|
|
* Synchronous validator. Return a human-readable error message that will
|
|
* be displayed under the form and block Next, or `null` when valid.
|
|
*/
|
|
validate?: (state: TState) => string | null;
|
|
}
|
|
|
|
export interface StepRenderProps<TState> {
|
|
state: TState;
|
|
update: (patch: Partial<TState>) => void;
|
|
error: string | null;
|
|
}
|
|
|
|
export interface StepWizardProps<TState> {
|
|
/** i18n key for the wizard heading. */
|
|
titleKey: string;
|
|
/** i18n key for the short lead paragraph shown under the heading. */
|
|
subtitleKey?: string;
|
|
/** Optional back-link to the hub. */
|
|
backTo?: string;
|
|
backLabelKey?: string;
|
|
steps: WizardStepDef<TState>[];
|
|
initialState: TState;
|
|
/** Called once the user clicks Finish on the last step. */
|
|
onFinish: (state: TState) => Promise<void> | void;
|
|
/** i18n key for the Finish button label. Defaults to "wizard.finish". */
|
|
finishLabelKey?: string;
|
|
/** Disable all form controls (used by consumers while submitting). */
|
|
submitting?: boolean;
|
|
}
|
|
|
|
export function StepWizard<TState>({
|
|
titleKey,
|
|
subtitleKey,
|
|
backTo,
|
|
backLabelKey = "wizard.backToHub",
|
|
steps,
|
|
initialState,
|
|
onFinish,
|
|
finishLabelKey = "wizard.finish",
|
|
submitting = false,
|
|
}: StepWizardProps<TState>) {
|
|
const { t } = useTranslation();
|
|
const [index, setIndex] = useState(0);
|
|
const [state, setState] = useState<TState>(initialState);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [submittingInternal, setSubmittingInternal] = useState(false);
|
|
|
|
const busy = submitting || submittingInternal;
|
|
const current = steps[index];
|
|
const isLast = index === steps.length - 1;
|
|
const progress = Math.round(((index + 1) / steps.length) * 100);
|
|
|
|
const update = (patch: Partial<TState>) => {
|
|
setState((prev) => ({ ...prev, ...patch }));
|
|
// Clear validation error as soon as the user starts typing again.
|
|
if (error) setError(null);
|
|
};
|
|
|
|
const validateAndAdvance = async () => {
|
|
const msg = current.validate?.(state) ?? null;
|
|
if (msg) {
|
|
setError(msg);
|
|
return;
|
|
}
|
|
setError(null);
|
|
|
|
if (!isLast) {
|
|
setIndex((i) => i + 1);
|
|
return;
|
|
}
|
|
|
|
// Last step: submit.
|
|
try {
|
|
setSubmittingInternal(true);
|
|
await onFinish(state);
|
|
} finally {
|
|
setSubmittingInternal(false);
|
|
}
|
|
};
|
|
|
|
const stepStatus = useMemo(
|
|
() =>
|
|
steps.map((_, i) => {
|
|
if (i < index) return "done" as const;
|
|
if (i === index) return "active" as const;
|
|
return "upcoming" as const;
|
|
}),
|
|
[steps, index],
|
|
);
|
|
|
|
return (
|
|
<div className="mx-auto max-w-3xl space-y-6">
|
|
{/* Heading + back link */}
|
|
<div className="space-y-2">
|
|
{backTo && (
|
|
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
|
|
<Link to={backTo} className="flex items-center gap-1">
|
|
<ChevronLeft className="h-4 w-4" />
|
|
{t(backLabelKey)}
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
|
|
{subtitleKey && <p className="text-muted-foreground">{t(subtitleKey)}</p>}
|
|
</div>
|
|
|
|
{/* Stepper */}
|
|
<ol className="flex items-center gap-2 overflow-x-auto pb-2" role="list">
|
|
{steps.map((step, i) => {
|
|
const s = stepStatus[i];
|
|
return (
|
|
<li key={step.id} className="flex items-center gap-2 shrink-0">
|
|
<div
|
|
className={cn(
|
|
"flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold",
|
|
s === "done" && "bg-primary text-primary-foreground",
|
|
s === "active" && "bg-primary/10 text-primary ring-2 ring-primary",
|
|
s === "upcoming" && "bg-muted text-muted-foreground",
|
|
)}
|
|
aria-current={s === "active" ? "step" : undefined}
|
|
>
|
|
{s === "done" ? <Check className="h-4 w-4" /> : i + 1}
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
"text-sm whitespace-nowrap",
|
|
s === "active" ? "font-medium" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{t(step.titleKey)}
|
|
</span>
|
|
{i < steps.length - 1 && <div className="mx-1 h-px w-8 bg-border" />}
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
|
|
{/* Progress bar */}
|
|
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
|
<div
|
|
className="h-full bg-primary transition-all"
|
|
style={{ width: `${progress}%` }}
|
|
aria-hidden
|
|
/>
|
|
</div>
|
|
|
|
{/* Active step */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<h2 className="text-lg font-semibold">{t(current.titleKey)}</h2>
|
|
{current.descriptionKey && (
|
|
<p className="text-sm text-muted-foreground">{t(current.descriptionKey)}</p>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{current.render({ state, update, error })}
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Navigation */}
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIndex((i) => Math.max(0, i - 1))}
|
|
disabled={busy || index === 0}
|
|
>
|
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
|
{t("wizard.back")}
|
|
</Button>
|
|
|
|
<div className="text-xs text-muted-foreground tabular-nums">
|
|
{t("wizard.stepOf", { current: index + 1, total: steps.length })}
|
|
</div>
|
|
|
|
<Button onClick={validateAndAdvance} disabled={busy}>
|
|
{isLast ? t(finishLabelKey) : t("wizard.next")}
|
|
{!isLast && <ArrowRight className="ml-1 h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default StepWizard;
|