import { apiPost } from "@/lib/api/client"; import { shouldUseDemoData } from "@/lib/api/mode"; import { deterministicTuitionPlan, type TuitionBrainMission, type TuitionBrainPlan, type TuitionBrainProfileInput, } from "@/components/tuition/chapter-dashboard/tuitionBrainPlan"; /** * The real tuition brain client. * * `planTodaysTuition` is what the UI calls: it asks the backend LLM to reason * over the student's profile + their REAL curated missions (restate what they * need, pick today's focus, why), and falls back to the deterministic plan when * logged out or the backend/LLM is unavailable. Either way the recommendation * is always a real mission — the backend validates the LLM pick against the * candidates, and the fallback picks from them directly. */ type BrainMissionWire = { chapter_id: string; chapter_title: string; mission_id: string; mission_title: string; completed: boolean; importance: number; }; type BrainPlanWire = { restatement: string; focus_reason: string; recommended_chapter_id: string | null; recommended_mission_id: string | null; study_tip: string; grounded: boolean; is_fallback: boolean; }; type BrainRequestWire = { profile: { class_level: string; board: string; subject: string; goal: string | null; daily_minutes: number | null; exam_date: string | null; weak_topics: string[]; low_mastery_topics: string[]; recent_mistakes: string[]; unfinished_mission_keys: string[]; revision_due_mission_keys: string[]; days_until_exam: number | null; language: string; }; candidate_missions: BrainMissionWire[]; }; function toWireMissions(missions: TuitionBrainMission[]): BrainMissionWire[] { return missions.slice(0, 80).map((m) => ({ chapter_id: m.chapterId, chapter_title: m.chapterTitle, mission_id: m.missionId, mission_title: m.missionTitle, completed: m.completed, importance: m.importance ?? 1, })); } function fromWirePlan(wire: BrainPlanWire): TuitionBrainPlan { return { restatement: wire.restatement, focusReason: wire.focus_reason, recommendedChapterId: wire.recommended_chapter_id, recommendedMissionId: wire.recommended_mission_id, studyTip: wire.study_tip, grounded: wire.grounded, isFallback: wire.is_fallback, }; } /** * Best-effort remote call. Returns null on failure (backend down, offline, * over budget) so the caller falls back to the deterministic plan. * * No session gate: the /tuition-brain/plan endpoint serves anonymous beta * students too (bounded by the backend's daily provider budget), so the * login-less beta flow can still reach the real brain. */ export async function requestTuitionPlanRemote( profile: TuitionBrainProfileInput, missions: TuitionBrainMission[], ): Promise { if (shouldUseDemoData()) return null; try { const body: BrainRequestWire = { profile: { class_level: profile.classLevel, board: profile.board, subject: profile.subject, goal: profile.goal ?? null, daily_minutes: profile.dailyMinutes ?? null, exam_date: profile.examDate ?? null, weak_topics: profile.weakTopics, low_mastery_topics: profile.lowMasteryTopics ?? [], recent_mistakes: profile.recentMistakes ?? [], unfinished_mission_keys: profile.unfinishedMissionKeys ?? [], revision_due_mission_keys: profile.revisionDueMissionKeys ?? [], days_until_exam: profile.daysUntilExam ?? null, language: profile.language ?? "English", }, candidate_missions: toWireMissions(missions), }; const wire = await apiPost("/tuition-brain/plan", body); // Guard: never trust a recommendation that isn't one of the missions we sent. if (wire.recommended_mission_id) { const isReal = missions.some( (m) => m.chapterId === wire.recommended_chapter_id && m.missionId === wire.recommended_mission_id, ); if (!isReal) return null; } return fromWirePlan(wire); } catch { return null; } } /** The UI entry point: real brain when possible, deterministic otherwise. */ export async function planTodaysTuition( profile: TuitionBrainProfileInput, missions: TuitionBrainMission[], ): Promise { const remote = await requestTuitionPlanRemote(profile, missions); return remote ?? deterministicTuitionPlan(profile, missions); } export type { TuitionBrainMission, TuitionBrainPlan, TuitionBrainProfileInput };