File size: 4,573 Bytes
65d68f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | 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<TuitionBrainPlan | null> {
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<BrainPlanWire, BrainRequestWire>("/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<TuitionBrainPlan> {
const remote = await requestTuitionPlanRemote(profile, missions);
return remote ?? deterministicTuitionPlan(profile, missions);
}
export type { TuitionBrainMission, TuitionBrainPlan, TuitionBrainProfileInput };
|