19
apps/web/src/app/api/library/context/route.ts
Normal file
19
apps/web/src/app/api/library/context/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { handleApi } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
formatLibraryContextForPrompt,
|
||||||
|
getLibraryContextForTopic,
|
||||||
|
} from "@/lib/services/library-context";
|
||||||
|
import { requireUser } from "@/lib/services/user";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
return handleApi(async () => {
|
||||||
|
const { user } = await requireUser();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const topic = searchParams.get("topic")?.trim() ?? "";
|
||||||
|
const payload = await getLibraryContextForTopic(user.id, topic);
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
formatted: formatLibraryContextForPrompt(payload),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { handleApi } from "@/lib/api";
|
import { handleApi } from "@/lib/api";
|
||||||
import { createTeacherLesson, getTeacherHistory } from "@/lib/services/teacher";
|
import { ServiceUnavailableError } from "@/lib/errors";
|
||||||
|
import {
|
||||||
|
createTeacherLesson,
|
||||||
|
getTeacherHistory,
|
||||||
|
TeacherAiUnavailableError,
|
||||||
|
} from "@/lib/services/teacher";
|
||||||
import { requireUser } from "@/lib/services/user";
|
import { requireUser } from "@/lib/services/user";
|
||||||
import { parseJsonBody } from "@/lib/validation";
|
import { parseJsonBody } from "@/lib/validation";
|
||||||
import {
|
import {
|
||||||
@@ -9,20 +14,17 @@ import {
|
|||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
return handleApi(async () => {
|
return handleApi(async () => {
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'api/teacher/route.ts:POST',message:'teacher POST start',data:{},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
const parsed = await parseJsonBody(request, teacherCreateSchema);
|
const parsed = await parseJsonBody(request, teacherCreateSchema);
|
||||||
const body = normalizeTeacherCreateBody(parsed);
|
const body = normalizeTeacherCreateBody(parsed);
|
||||||
// #region agent log
|
|
||||||
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'api/teacher/route.ts:POST',message:'teacher body validated',data:{topicLen:body.topic.length,hasExplorationId:!!body.explorationId},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{});
|
|
||||||
// #endregion
|
|
||||||
const { user } = await requireUser();
|
const { user } = await requireUser();
|
||||||
const result = await createTeacherLesson(user.id, body);
|
try {
|
||||||
// #region agent log
|
return await createTeacherLesson(user.id, body);
|
||||||
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'api/teacher/route.ts:POST',message:'teacher lesson created',data:{source:result.source,lessonId:result.id},timestamp:Date.now(),hypothesisId:'H2'})}).catch(()=>{});
|
} catch (e) {
|
||||||
// #endregion
|
if (e instanceof TeacherAiUnavailableError) {
|
||||||
return result;
|
throw new ServiceUnavailableError(e.message);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,29 +3,19 @@
|
|||||||
import { useState, useEffect, Suspense } from "react";
|
import { useState, useEffect, Suspense } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import type {
|
||||||
|
TeacherLessonContent,
|
||||||
|
TeacherLessonData,
|
||||||
|
TeacherDifficulty,
|
||||||
|
TeacherLength,
|
||||||
|
} from "@adventureos/shared";
|
||||||
import { AppShell } from "@/components/layout/app-shell";
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
|
import {
|
||||||
|
resolveResearchAssignmentDisplay,
|
||||||
|
type ResearchAssignmentDisplay,
|
||||||
|
} from "@/lib/teacher/teacher-render";
|
||||||
|
|
||||||
type LessonContent = {
|
type Lesson = TeacherLessonData;
|
||||||
title?: string;
|
|
||||||
introduction?: string;
|
|
||||||
objectives?: string[];
|
|
||||||
readingSteps?: string[];
|
|
||||||
reflectionPrompt?: string;
|
|
||||||
flashcards: { front: string; back: string }[];
|
|
||||||
quiz: { question: string; options: string[]; answer: number }[];
|
|
||||||
assignment: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Lesson = {
|
|
||||||
id: string;
|
|
||||||
topic: string;
|
|
||||||
content: LessonContent;
|
|
||||||
status: string;
|
|
||||||
completedNote?: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
source?: "ai" | "fallback";
|
|
||||||
fallbackReason?: "offline" | "model_unavailable" | "parse_failed" | "generation_failed" | "timeout";
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TeacherPage() {
|
export default function TeacherPage() {
|
||||||
return (
|
return (
|
||||||
@@ -39,12 +29,16 @@ function TeacherPageContent() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
|
const [difficulty, setDifficulty] = useState<TeacherDifficulty>("beginner");
|
||||||
|
const [length, setLength] = useState<TeacherLength>("standard");
|
||||||
const [explorationId, setExplorationId] = useState<string | null>(null);
|
const [explorationId, setExplorationId] = useState<string | null>(null);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [content, setContent] = useState<LessonContent | null>(null);
|
const [content, setContent] = useState<TeacherLessonContent | null>(null);
|
||||||
const [contentSource, setContentSource] = useState<"ai" | "fallback" | null>(null);
|
const [contentSource, setContentSource] = useState<"ai" | "fallback" | null>(null);
|
||||||
const [fallbackReason, setFallbackReason] = useState<string | null>(null);
|
const [fallbackReason, setFallbackReason] = useState<string | null>(null);
|
||||||
const [revealedQuiz, setRevealedQuiz] = useState<Record<number, number | null>>({});
|
const [revealedQuiz, setRevealedQuiz] = useState<Record<number, number | null>>({});
|
||||||
|
const [revealedShortAnswers, setRevealedShortAnswers] = useState<Record<number, boolean>>({});
|
||||||
|
const [showAnswerKey, setShowAnswerKey] = useState(false);
|
||||||
const [completionNote, setCompletionNote] = useState("");
|
const [completionNote, setCompletionNote] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,6 +57,17 @@ function TeacherPageContent() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const resetLessonView = () => {
|
||||||
|
setContent(null);
|
||||||
|
setContentSource(null);
|
||||||
|
setFallbackReason(null);
|
||||||
|
setSelectedId(null);
|
||||||
|
setRevealedQuiz({});
|
||||||
|
setRevealedShortAnswers({});
|
||||||
|
setShowAnswerKey(false);
|
||||||
|
setCompletionNote("");
|
||||||
|
};
|
||||||
|
|
||||||
const loadLesson = useMutation({
|
const loadLesson = useMutation({
|
||||||
mutationFn: async (id: string) => {
|
mutationFn: async (id: string) => {
|
||||||
const res = await fetch(`/api/teacher/${id}`);
|
const res = await fetch(`/api/teacher/${id}`);
|
||||||
@@ -76,6 +81,8 @@ function TeacherPageContent() {
|
|||||||
setFallbackReason(data.fallbackReason ?? null);
|
setFallbackReason(data.fallbackReason ?? null);
|
||||||
setTopic(data.topic);
|
setTopic(data.topic);
|
||||||
setRevealedQuiz({});
|
setRevealedQuiz({});
|
||||||
|
setRevealedShortAnswers({});
|
||||||
|
setShowAnswerKey(false);
|
||||||
setCompletionNote(data.completedNote ?? "");
|
setCompletionNote(data.completedNote ?? "");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -87,6 +94,9 @@ function TeacherPageContent() {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
topic,
|
topic,
|
||||||
|
difficulty,
|
||||||
|
length,
|
||||||
|
includeLibraryContext: true,
|
||||||
...(explorationId ? { explorationId } : {}),
|
...(explorationId ? { explorationId } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -102,6 +112,8 @@ function TeacherPageContent() {
|
|||||||
setFallbackReason(data.fallbackReason ?? null);
|
setFallbackReason(data.fallbackReason ?? null);
|
||||||
setSelectedId(data.id);
|
setSelectedId(data.id);
|
||||||
setRevealedQuiz({});
|
setRevealedQuiz({});
|
||||||
|
setRevealedShortAnswers({});
|
||||||
|
setShowAnswerKey(false);
|
||||||
qc.invalidateQueries({ queryKey: ["teacher"] });
|
qc.invalidateQueries({ queryKey: ["teacher"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -120,76 +132,181 @@ function TeacherPageContent() {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["teacher"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["teacher"] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const overview = content?.overview ?? content?.introduction;
|
||||||
|
const objectives = content?.learningObjectives ?? content?.objectives;
|
||||||
|
const researchDisplay = content ? resolveResearchAssignmentDisplay(content) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<div className="p-4 pb-20 md:pb-4">
|
<div className="p-4 pb-20 md:pb-4 max-w-3xl">
|
||||||
<h1 className="font-bold text-lg mb-2">The Teacher</h1>
|
<h1 className="font-bold text-lg mb-2">The Teacher</h1>
|
||||||
<p className="text-sm text-[var(--warm-grey)] mb-4">
|
<p className="text-sm text-[var(--warm-grey)] mb-4">
|
||||||
Ask the Guide to create flashcards, quizzes, and research assignments on any topic.
|
A calm mini-lesson with explanation, research steps, library suggestions, homework, and a quiz.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="retro-window p-4 mb-6 space-y-3">
|
||||||
<input
|
<input
|
||||||
className="retro-window-inset flex-1 p-2"
|
className="retro-window-inset w-full p-2"
|
||||||
placeholder="e.g. How Roman roads were built"
|
placeholder="e.g. DNS, Ottoman history, cryptography"
|
||||||
value={topic}
|
value={topic}
|
||||||
onChange={(e) => setTopic(e.target.value)}
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
|
disabled={generate.isPending}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span className="text-[var(--warm-grey)]">Difficulty</span>
|
||||||
|
<select
|
||||||
|
className="retro-window-inset p-1"
|
||||||
|
value={difficulty}
|
||||||
|
onChange={(e) => setDifficulty(e.target.value as TeacherDifficulty)}
|
||||||
|
disabled={generate.isPending}
|
||||||
|
>
|
||||||
|
<option value="beginner">Beginner</option>
|
||||||
|
<option value="intermediate">Intermediate</option>
|
||||||
|
<option value="advanced">Advanced</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<span className="text-[var(--warm-grey)]">Length</span>
|
||||||
|
<select
|
||||||
|
className="retro-window-inset p-1"
|
||||||
|
value={length}
|
||||||
|
onChange={(e) => setLength(e.target.value as TeacherLength)}
|
||||||
|
disabled={generate.isPending}
|
||||||
|
>
|
||||||
|
<option value="short">Short</option>
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="deep">Deep dive</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
className="retro-btn retro-btn-primary"
|
className="retro-btn retro-btn-primary"
|
||||||
onClick={() => generate.mutate()}
|
onClick={() => generate.mutate()}
|
||||||
disabled={!topic || generate.isPending}
|
disabled={!topic.trim() || generate.isPending}
|
||||||
>
|
>
|
||||||
{generate.isPending ? "Teaching..." : "Teach Me"}
|
{generate.isPending ? "Preparing lesson…" : "Generate Lesson"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="retro-btn"
|
||||||
|
type="button"
|
||||||
|
onClick={resetLessonView}
|
||||||
|
disabled={generate.isPending}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generate.isPending && (
|
||||||
|
<p className="text-sm text-[var(--warm-grey)] mb-4 italic">
|
||||||
|
The Guide is preparing your lesson on “{topic}”…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{generate.isError && (
|
{generate.isError && (
|
||||||
<p className="text-sm text-[var(--muted-rose)] mb-4">
|
<p className="text-sm text-[var(--muted-rose)] mb-4">
|
||||||
{generate.error instanceof Error
|
{generate.error instanceof Error
|
||||||
? generate.error.message
|
? generate.error.message
|
||||||
: "Could not generate lesson. Check AI Health."}
|
: "Could not generate lesson. Check AI Health in Settings."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{contentSource === "fallback" && (
|
{contentSource === "fallback" && (
|
||||||
<p className="text-sm text-[var(--warm-grey)] mb-4 italic">
|
<p className="text-sm text-[var(--warm-grey)] mb-4 italic">
|
||||||
{fallbackReason === "timeout"
|
{fallbackReason === "timeout"
|
||||||
? "AI timed out — your model may be too large for this machine. Try a smaller model (e.g. llama3.2:1b) in Settings or .env."
|
? "AI timed out — try a smaller model in Settings."
|
||||||
|
: fallbackReason === "parse_failed"
|
||||||
|
? "AI responded but the lesson could not be parsed — showing a basic template."
|
||||||
: fallbackReason === "generation_failed"
|
: fallbackReason === "generation_failed"
|
||||||
? "AI could not generate a lesson (check that your Ollama model is installed) — showing a basic offline template."
|
? "AI could not generate a full lesson — showing a basic offline template."
|
||||||
: "AI is offline or unavailable — showing a basic offline lesson template."}
|
: "AI is offline or unavailable — showing a basic offline lesson template."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{content && (
|
{content && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{content.title && (
|
{content.title && <h2 className="font-bold text-base">{content.title}</h2>}
|
||||||
<h2 className="font-bold text-base">{content.title}</h2>
|
|
||||||
)}
|
{overview && (
|
||||||
{content.introduction && (
|
<Section title="Overview">
|
||||||
<Section title="Introduction">
|
<p className="serif text-sm whitespace-pre-wrap">{overview}</p>
|
||||||
<p className="serif text-sm">{content.introduction}</p>
|
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
{content.objectives && content.objectives.length > 0 && (
|
|
||||||
|
{content.whyItMatters && (
|
||||||
|
<Section title="Why This Matters">
|
||||||
|
<p className="serif text-sm whitespace-pre-wrap">{content.whyItMatters}</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{objectives && objectives.length > 0 && (
|
||||||
<Section title="Learning Objectives">
|
<Section title="Learning Objectives">
|
||||||
<ul className="text-sm list-disc pl-5 space-y-1">
|
<ul className="text-sm list-disc pl-5 space-y-1">
|
||||||
{content.objectives.map((o, i) => (
|
{objectives.map((o, i) => (
|
||||||
<li key={i}>{o}</li>
|
<li key={i}>{o}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
{content.readingSteps && content.readingSteps.length > 0 && (
|
|
||||||
<Section title="Reading & Research">
|
{content.explanation && (
|
||||||
<ol className="text-sm list-decimal pl-5 space-y-1">
|
<Section title="Explanation">
|
||||||
{content.readingSteps.map((step, i) => (
|
<p className="serif text-sm whitespace-pre-wrap">{content.explanation}</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{researchDisplay && (
|
||||||
|
<Section title="Research Assignment">
|
||||||
|
<ResearchAssignmentBlock display={researchDisplay} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Books From Your Library">
|
||||||
|
{content.calibreSuggestions && content.calibreSuggestions.length > 0 ? (
|
||||||
|
<ul className="text-sm space-y-2">
|
||||||
|
{content.calibreSuggestions.map((book, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span className="font-bold">{book.title}</span>
|
||||||
|
{book.authors.length > 0 && (
|
||||||
|
<span className="text-[var(--warm-grey)]"> — {book.authors.join(", ")}</span>
|
||||||
|
)}
|
||||||
|
<p className="text-sm mt-0.5">{book.reason}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--warm-grey)] italic">
|
||||||
|
No matching library books were found for this topic.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{content.externalReading && content.externalReading.length > 0 && (
|
||||||
|
<Section title="Further Reading">
|
||||||
|
<ul className="text-sm list-disc pl-5 space-y-1">
|
||||||
|
{content.externalReading.map((item, i) => (
|
||||||
|
<li key={i}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content.homework && (
|
||||||
|
<Section title="Homework">
|
||||||
|
<p className="serif text-sm font-bold">{content.homework.task}</p>
|
||||||
|
{content.homework.instructions.length > 0 && (
|
||||||
|
<ol className="text-sm list-decimal pl-5 space-y-1 mt-2">
|
||||||
|
{content.homework.instructions.map((step, i) => (
|
||||||
<li key={i}>{step}</li>
|
<li key={i}>{step}</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{content.flashcards.length > 0 && (
|
||||||
<Section title="Flashcards">
|
<Section title="Flashcards">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
{content.flashcards.map((c, i) => (
|
{content.flashcards.map((c, i) => (
|
||||||
@@ -197,27 +314,74 @@ function TeacherPageContent() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content.quiz.length > 0 && (
|
||||||
<Section title="Quiz">
|
<Section title="Quiz">
|
||||||
{content.quiz.map((q, i) => (
|
{content.quiz.map((q, i) =>
|
||||||
|
q.type === "short_answer" ? (
|
||||||
|
<ShortAnswerQuestion
|
||||||
|
key={i}
|
||||||
|
question={q.question}
|
||||||
|
answer={q.answerText ?? String(q.answer)}
|
||||||
|
explanation={q.explanation}
|
||||||
|
revealed={revealedShortAnswers[i] ?? false}
|
||||||
|
onReveal={() =>
|
||||||
|
setRevealedShortAnswers({ ...revealedShortAnswers, [i]: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<QuizQuestion
|
<QuizQuestion
|
||||||
key={i}
|
key={i}
|
||||||
question={q.question}
|
question={q.question}
|
||||||
options={q.options}
|
options={q.options ?? []}
|
||||||
answer={q.answer}
|
answer={q.answer}
|
||||||
selected={revealedQuiz[i] ?? null}
|
selected={revealedQuiz[i] ?? null}
|
||||||
onSelect={(idx) => setRevealedQuiz({ ...revealedQuiz, [i]: idx })}
|
onSelect={(idx) => setRevealedQuiz({ ...revealedQuiz, [i]: idx })}
|
||||||
/>
|
/>
|
||||||
))}
|
)
|
||||||
</Section>
|
|
||||||
<Section title="Research Assignment">
|
|
||||||
<p className="serif">{content.assignment}</p>
|
|
||||||
{content.reflectionPrompt && (
|
|
||||||
<p className="text-sm italic mt-2 text-[var(--warm-grey)]">
|
|
||||||
Reflection: {content.reflectionPrompt}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content.answerKey && content.answerKey.length > 0 && (
|
||||||
|
<Section title="Answer Key">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="retro-btn text-sm mb-2"
|
||||||
|
onClick={() => setShowAnswerKey(!showAnswerKey)}
|
||||||
|
>
|
||||||
|
{showAnswerKey ? "Hide answers" : "Show answers"}
|
||||||
|
</button>
|
||||||
|
{showAnswerKey && (
|
||||||
|
<ol className="text-sm list-decimal pl-5 space-y-2">
|
||||||
|
{content.answerKey.map((entry, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span className="font-bold">{entry.answer}</span>
|
||||||
|
{entry.explanation && (
|
||||||
|
<p className="text-[var(--warm-grey)] mt-0.5">{entry.explanation}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content.reflectionPrompt && (
|
||||||
|
<Section title="Reflection">
|
||||||
|
<p className="serif text-sm italic">{content.reflectionPrompt}</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content.nextLessonSuggestion && (
|
||||||
|
<Section title="Next Lesson">
|
||||||
|
<p className="text-sm">{content.nextLessonSuggestion}</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedId && (
|
{selectedId && (
|
||||||
<div className="mt-4">
|
<Section title="Complete Assignment">
|
||||||
<textarea
|
<textarea
|
||||||
className="retro-window-inset w-full p-2 text-sm min-h-[60px]"
|
className="retro-window-inset w-full p-2 text-sm min-h-[60px]"
|
||||||
placeholder="What did you learn from this assignment?"
|
placeholder="What did you learn from this assignment?"
|
||||||
@@ -231,9 +395,8 @@ function TeacherPageContent() {
|
|||||||
>
|
>
|
||||||
Mark assignment complete
|
Mark assignment complete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -267,6 +430,32 @@ function TeacherPageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ResearchAssignmentBlock({ display }: { display: ResearchAssignmentDisplay }) {
|
||||||
|
if (display.kind === "string") {
|
||||||
|
return <p className="serif text-sm whitespace-pre-wrap">{display.text}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { steps, expectedOutcome, estimatedTimeMinutes } = display.data;
|
||||||
|
return (
|
||||||
|
<div className="text-sm space-y-2">
|
||||||
|
{estimatedTimeMinutes != null && (
|
||||||
|
<p className="text-[var(--warm-grey)]">Estimated time: ~{estimatedTimeMinutes} minutes</p>
|
||||||
|
)}
|
||||||
|
<ol className="list-decimal pl-5 space-y-1">
|
||||||
|
{steps.map((step, i) => (
|
||||||
|
<li key={i}>{step}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
{expectedOutcome && (
|
||||||
|
<p className="serif mt-2">
|
||||||
|
<span className="font-bold">Expected outcome: </span>
|
||||||
|
{expectedOutcome}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Flashcard({ front, back }: { front: string; back: string }) {
|
function Flashcard({ front, back }: { front: string; back: string }) {
|
||||||
const [flipped, setFlipped] = useState(false);
|
const [flipped, setFlipped] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -307,7 +496,7 @@ function QuizQuestion({
|
|||||||
if (showResult && !picked && isCorrect) cls = "text-[var(--bliss-green)]";
|
if (showResult && !picked && isCorrect) cls = "text-[var(--bliss-green)]";
|
||||||
return (
|
return (
|
||||||
<li key={j}>
|
<li key={j}>
|
||||||
<button className={`text-left ${cls}`} onClick={() => onSelect(j)}>
|
<button type="button" className={`text-left ${cls}`} onClick={() => onSelect(j)}>
|
||||||
{String.fromCharCode(65 + j)}. {o}
|
{String.fromCharCode(65 + j)}. {o}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -318,6 +507,39 @@ function QuizQuestion({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ShortAnswerQuestion({
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
explanation,
|
||||||
|
revealed,
|
||||||
|
onReveal,
|
||||||
|
}: {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
explanation?: string;
|
||||||
|
revealed: boolean;
|
||||||
|
onReveal: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="retro-window-inset p-3 mb-2">
|
||||||
|
<p className="font-bold text-sm mb-2">{question}</p>
|
||||||
|
<p className="text-xs text-[var(--warm-grey)] mb-2">Short answer</p>
|
||||||
|
{!revealed ? (
|
||||||
|
<button type="button" className="retro-btn text-sm" onClick={onReveal}>
|
||||||
|
Reveal answer
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--bliss-green)] font-bold">{answer}</p>
|
||||||
|
{explanation && (
|
||||||
|
<p className="text-sm text-[var(--warm-grey)] mt-1">{explanation}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="retro-window p-4">
|
<div className="retro-window p-4">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
normalizeExplorationPayload,
|
normalizeExplorationPayload,
|
||||||
normalizeTeacherPayload,
|
normalizeTeacherPayload,
|
||||||
safeParseAiJson,
|
safeParseAiJson,
|
||||||
|
isInvalidObjectString,
|
||||||
} from "./ai-normalize";
|
} from "./ai-normalize";
|
||||||
|
|
||||||
describe("safeParseAiJson", () => {
|
describe("safeParseAiJson", () => {
|
||||||
@@ -17,11 +18,62 @@ describe("normalizeTeacherPayload", () => {
|
|||||||
flashcards: [{ front: "Q", back: "A" }],
|
flashcards: [{ front: "Q", back: "A" }],
|
||||||
quiz: [{ question: "Pick one", options: ["a", "b"], answer: "1" }],
|
quiz: [{ question: "Pick one", options: ["a", "b"], answer: "1" }],
|
||||||
homework: "Read for 20 minutes",
|
homework: "Read for 20 minutes",
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
expect(normalized.assignment).toBe("Read for 20 minutes");
|
||||||
|
expect((normalized.quiz as { answer: number }[])[0].answer).toBe(1);
|
||||||
});
|
});
|
||||||
expect(normalized).toMatchObject({
|
|
||||||
assignment: "Read for 20 minutes",
|
it("flattens object assignment instead of [object Object]", () => {
|
||||||
quiz: [{ answer: 1 }],
|
const normalized = normalizeTeacherPayload({
|
||||||
|
flashcards: [],
|
||||||
|
quiz: [],
|
||||||
|
assignment: {
|
||||||
|
task: "Research Roman roads",
|
||||||
|
steps: ["Find a map", "Note three facts"],
|
||||||
|
},
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
expect(normalized.assignment).toContain("Research Roman roads");
|
||||||
|
expect(normalized.researchAssignment).toMatchObject({
|
||||||
|
steps: ["Find a map", "Note three facts"],
|
||||||
});
|
});
|
||||||
|
expect(isInvalidObjectString(normalized.assignment as string)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles structured researchAssignment from AI", () => {
|
||||||
|
const normalized = normalizeTeacherPayload({
|
||||||
|
flashcards: [{ front: "Q", back: "A" }],
|
||||||
|
quiz: [
|
||||||
|
{
|
||||||
|
question: "What is DNS?",
|
||||||
|
type: "short_answer",
|
||||||
|
answer: "Domain Name System",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
researchAssignment: {
|
||||||
|
steps: ["Look up DNS basics", "Draw a diagram"],
|
||||||
|
expectedOutcome: "A labeled diagram",
|
||||||
|
estimatedTimeMinutes: 25,
|
||||||
|
},
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
expect(normalized.researchAssignment).toEqual({
|
||||||
|
steps: ["Look up DNS basics", "Draw a diagram"],
|
||||||
|
expectedOutcome: "A labeled diagram",
|
||||||
|
estimatedTimeMinutes: 25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps letter quiz answers", () => {
|
||||||
|
const normalized = normalizeTeacherPayload({
|
||||||
|
flashcards: [],
|
||||||
|
quiz: [
|
||||||
|
{
|
||||||
|
question: "Pick B",
|
||||||
|
options: ["wrong", "right", "nope"],
|
||||||
|
answer: "B",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
expect((normalized.quiz as { answer: number }[])[0].answer).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { parseAiJson } from "./parse-json";
|
import { parseAiJson } from "./parse-json";
|
||||||
|
import {
|
||||||
|
extractText,
|
||||||
|
extractStringList,
|
||||||
|
isObjectObject,
|
||||||
|
normalizeQuizAnswer,
|
||||||
|
} from "../teacher/extract-text";
|
||||||
|
|
||||||
export function safeParseAiJson(raw: string): unknown | null {
|
export function safeParseAiJson(raw: string): unknown | null {
|
||||||
try {
|
try {
|
||||||
@@ -8,17 +14,155 @@ export function safeParseAiJson(raw: string): unknown | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeResearchAssignment(
|
||||||
|
raw: unknown,
|
||||||
|
readingSteps: string[],
|
||||||
|
assignmentText: string
|
||||||
|
): {
|
||||||
|
researchAssignment?: {
|
||||||
|
steps: string[];
|
||||||
|
expectedOutcome?: string;
|
||||||
|
estimatedTimeMinutes?: number;
|
||||||
|
};
|
||||||
|
readingSteps?: string[];
|
||||||
|
} {
|
||||||
|
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
const steps = extractStringList(o.steps ?? o.readingSteps);
|
||||||
|
const expectedOutcome = extractText(o.expectedOutcome ?? o.outcome);
|
||||||
|
const timeRaw = o.estimatedTimeMinutes ?? o.timeMinutes ?? o.duration;
|
||||||
|
const estimatedTimeMinutes =
|
||||||
|
timeRaw != null && Number.isFinite(Number(timeRaw)) ? Number(timeRaw) : undefined;
|
||||||
|
if (steps.length || expectedOutcome) {
|
||||||
|
return {
|
||||||
|
researchAssignment: {
|
||||||
|
steps: steps.length ? steps : readingSteps,
|
||||||
|
expectedOutcome: expectedOutcome || undefined,
|
||||||
|
estimatedTimeMinutes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readingSteps.length) {
|
||||||
|
return {
|
||||||
|
researchAssignment: {
|
||||||
|
steps: readingSteps,
|
||||||
|
expectedOutcome: assignmentText || undefined,
|
||||||
|
},
|
||||||
|
readingSteps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignmentText) {
|
||||||
|
return {
|
||||||
|
researchAssignment: { steps: [assignmentText] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHomework(raw: unknown): {
|
||||||
|
task: string;
|
||||||
|
instructions: string[];
|
||||||
|
} | undefined {
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const task = raw.trim();
|
||||||
|
return task ? { task, instructions: [] } : undefined;
|
||||||
|
}
|
||||||
|
if (typeof raw === "object" && !Array.isArray(raw)) {
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
const task = extractText(o.task ?? o.assignment ?? o.description);
|
||||||
|
const instructions = extractStringList(o.instructions ?? o.steps);
|
||||||
|
if (task || instructions.length) {
|
||||||
|
return { task: task || instructions[0] || "", instructions };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCalibreSuggestions(raw: unknown) {
|
||||||
|
if (!Array.isArray(raw)) return undefined;
|
||||||
|
const items = raw
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== "object") return null;
|
||||||
|
const o = item as Record<string, unknown>;
|
||||||
|
const title = extractText(o.title);
|
||||||
|
if (!title) return null;
|
||||||
|
const authors = Array.isArray(o.authors)
|
||||||
|
? o.authors.map((a) => extractText(a)).filter(Boolean)
|
||||||
|
: extractText(o.author)
|
||||||
|
? [extractText(o.author)]
|
||||||
|
: [];
|
||||||
|
const reason = extractText(o.reason ?? o.why);
|
||||||
|
return { title, authors, reason: reason || "Relevant to this topic" };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
return items.length ? items : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeTeacherPayload(data: unknown): unknown {
|
export function normalizeTeacherPayload(data: unknown): unknown {
|
||||||
if (!data || typeof data !== "object") return data;
|
if (!data || typeof data !== "object") return data;
|
||||||
const o = data as Record<string, unknown>;
|
const o = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
const overview = extractText(o.overview ?? o.introduction);
|
||||||
|
const whyItMatters = extractText(o.whyItMatters ?? o.why_this_matters);
|
||||||
|
const explanation = extractText(o.explanation ?? o.body);
|
||||||
|
const objectives = extractStringList(
|
||||||
|
o.learningObjectives ?? o.objectives ?? o.learning_objectives
|
||||||
|
);
|
||||||
|
const readingSteps = extractStringList(o.readingSteps ?? o.reading_steps);
|
||||||
|
const externalReading = extractStringList(o.externalReading ?? o.external_reading);
|
||||||
|
const reflectionPrompt = extractText(o.reflectionPrompt ?? o.reflection);
|
||||||
|
const nextLessonSuggestion = extractText(
|
||||||
|
o.nextLessonSuggestion ?? o.next_lesson ?? o.nextTopic
|
||||||
|
);
|
||||||
|
|
||||||
|
const homeworkRaw = o.homework;
|
||||||
|
let assignmentText = "";
|
||||||
|
let assignmentObjectSteps: string[] = [];
|
||||||
|
|
||||||
|
if (o.assignment && typeof o.assignment === "object" && !Array.isArray(o.assignment)) {
|
||||||
|
const assignmentObj = o.researchAssignment ?? o.research_assignment ?? o.assignment;
|
||||||
|
if (assignmentObj && typeof assignmentObj === "object" && !Array.isArray(assignmentObj)) {
|
||||||
|
const a = assignmentObj as Record<string, unknown>;
|
||||||
|
assignmentText = extractText(a.task ?? a.assignment ?? a.description);
|
||||||
|
assignmentObjectSteps = extractStringList(a.steps ?? a.instructions);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assignmentText = extractText(o.assignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assignmentText && homeworkRaw && typeof homeworkRaw === "object" && !Array.isArray(homeworkRaw)) {
|
||||||
|
assignmentText = extractText((homeworkRaw as Record<string, unknown>).task);
|
||||||
|
}
|
||||||
|
if (!assignmentText && typeof o.homework === "string") {
|
||||||
|
assignmentText = extractText(o.homework);
|
||||||
|
}
|
||||||
|
|
||||||
|
const researchFields = normalizeResearchAssignment(
|
||||||
|
o.researchAssignment ?? o.research_assignment ?? (assignmentObjectSteps.length ? {
|
||||||
|
steps: assignmentObjectSteps,
|
||||||
|
expectedOutcome: assignmentText,
|
||||||
|
} : undefined),
|
||||||
|
readingSteps.length ? readingSteps : assignmentObjectSteps,
|
||||||
|
assignmentText
|
||||||
|
);
|
||||||
|
|
||||||
|
const homework = normalizeHomework(homeworkRaw);
|
||||||
|
if (!assignmentText && homework?.task) {
|
||||||
|
assignmentText = homework.task;
|
||||||
|
}
|
||||||
|
|
||||||
const flashcards = Array.isArray(o.flashcards)
|
const flashcards = Array.isArray(o.flashcards)
|
||||||
? o.flashcards
|
? o.flashcards
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
if (!c || typeof c !== "object") return null;
|
if (!c || typeof c !== "object") return null;
|
||||||
const card = c as Record<string, unknown>;
|
const card = c as Record<string, unknown>;
|
||||||
const front = String(card.front ?? "").trim();
|
const front = extractText(card.front);
|
||||||
const back = String(card.back ?? "").trim();
|
const back = extractText(card.back);
|
||||||
if (!front && !back) return null;
|
if (!front && !back) return null;
|
||||||
return { front, back };
|
return { front, back };
|
||||||
})
|
})
|
||||||
@@ -30,36 +174,90 @@ export function normalizeTeacherPayload(data: unknown): unknown {
|
|||||||
.map((q) => {
|
.map((q) => {
|
||||||
if (!q || typeof q !== "object") return null;
|
if (!q || typeof q !== "object") return null;
|
||||||
const item = q as Record<string, unknown>;
|
const item = q as Record<string, unknown>;
|
||||||
const options = Array.isArray(item.options)
|
const question = extractText(item.question);
|
||||||
? item.options.map((opt) => String(opt))
|
if (!question) return null;
|
||||||
|
|
||||||
|
const typeRaw = extractText(item.type).toLowerCase();
|
||||||
|
const type =
|
||||||
|
typeRaw === "short_answer" || typeRaw === "short answer"
|
||||||
|
? ("short_answer" as const)
|
||||||
|
: ("multiple_choice" as const);
|
||||||
|
|
||||||
|
const options =
|
||||||
|
type === "multiple_choice"
|
||||||
|
? extractStringList(item.options ?? item.choices)
|
||||||
: [];
|
: [];
|
||||||
if (options.length === 0) return null;
|
|
||||||
let answer = Number(item.answer ?? 0);
|
if (type === "multiple_choice" && options.length === 0) return null;
|
||||||
if (!Number.isFinite(answer)) answer = 0;
|
|
||||||
if (answer < 0 || answer >= options.length) answer = 0;
|
const { answerIndex, answerText } =
|
||||||
|
type === "multiple_choice"
|
||||||
|
? normalizeQuizAnswer(item.answer ?? item.correct, options)
|
||||||
|
: {
|
||||||
|
answerIndex: 0,
|
||||||
|
answerText: extractText(item.answer ?? item.correct) || "See explanation",
|
||||||
|
};
|
||||||
|
|
||||||
|
const explanation = extractText(item.explanation ?? item.rationale);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
question: String(item.question ?? "Question"),
|
question,
|
||||||
options,
|
type,
|
||||||
answer,
|
options: type === "multiple_choice" ? options : undefined,
|
||||||
|
answer: answerIndex,
|
||||||
|
answerText,
|
||||||
|
explanation: explanation || undefined,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const answerKey = Array.isArray(o.answerKey ?? o.answer_key)
|
||||||
|
? (o.answerKey ?? o.answer_key as unknown[])
|
||||||
|
.map((entry, idx) => {
|
||||||
|
if (!entry || typeof entry !== "object") return null;
|
||||||
|
const e = entry as Record<string, unknown>;
|
||||||
|
const answer = extractText(e.answer);
|
||||||
|
if (!answer) return null;
|
||||||
|
const questionIndex = Number(e.questionIndex ?? e.question ?? idx);
|
||||||
return {
|
return {
|
||||||
title: o.title != null ? String(o.title) : undefined,
|
questionIndex: Number.isFinite(questionIndex) ? questionIndex : idx,
|
||||||
introduction: o.introduction != null ? String(o.introduction) : undefined,
|
answer,
|
||||||
objectives: Array.isArray(o.objectives)
|
explanation: extractText(e.explanation) || undefined,
|
||||||
? o.objectives.map(String).filter(Boolean)
|
};
|
||||||
: undefined,
|
})
|
||||||
readingSteps: Array.isArray(o.readingSteps)
|
.filter(Boolean)
|
||||||
? o.readingSteps.map(String).filter(Boolean)
|
: quiz.map((q, i) => ({
|
||||||
: undefined,
|
questionIndex: i,
|
||||||
reflectionPrompt:
|
answer: q.answerText ?? String(q.answer),
|
||||||
o.reflectionPrompt != null ? String(o.reflectionPrompt) : undefined,
|
explanation: q.explanation,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const calibreSuggestions = normalizeCalibreSuggestions(
|
||||||
|
o.calibreSuggestions ?? o.librarySuggestions ?? o.booksFromLibrary
|
||||||
|
);
|
||||||
|
|
||||||
|
const safeAssignment =
|
||||||
|
assignmentText && !isObjectObject(assignmentText) ? assignmentText : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: extractText(o.title) || undefined,
|
||||||
|
overview: overview || undefined,
|
||||||
|
introduction: overview || undefined,
|
||||||
|
whyItMatters: whyItMatters || undefined,
|
||||||
|
learningObjectives: objectives.length ? objectives : undefined,
|
||||||
|
objectives: objectives.length ? objectives : undefined,
|
||||||
|
explanation: explanation || undefined,
|
||||||
|
...researchFields,
|
||||||
|
calibreSuggestions,
|
||||||
|
externalReading: externalReading.length ? externalReading : undefined,
|
||||||
|
homework,
|
||||||
|
reflectionPrompt: reflectionPrompt || undefined,
|
||||||
flashcards,
|
flashcards,
|
||||||
quiz,
|
quiz,
|
||||||
assignment: String(o.assignment ?? o.homework ?? "").trim(),
|
answerKey,
|
||||||
|
nextLessonSuggestion: nextLessonSuggestion || undefined,
|
||||||
|
assignment: safeAssignment ?? homework?.task ?? researchFields.researchAssignment?.steps?.[0] ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,13 +276,13 @@ export function normalizeExplorationPayload(data: unknown): unknown {
|
|||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (!item || typeof item !== "object") return null;
|
if (!item || typeof item !== "object") return null;
|
||||||
const e = item as Record<string, unknown>;
|
const e = item as Record<string, unknown>;
|
||||||
const title = String(e.title ?? "").trim();
|
const title = extractText(e.title);
|
||||||
if (!title) return null;
|
if (!title) return null;
|
||||||
const minutes = Number(e.minutes ?? e.duration ?? 20);
|
const minutes = Number(e.minutes ?? e.duration ?? 20);
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
hook: String(e.hook ?? e.description ?? e.reason ?? title).trim(),
|
hook: extractText(e.hook ?? e.description ?? e.reason ?? title),
|
||||||
category: String(e.category ?? "general").trim(),
|
category: extractText(e.category) || "general",
|
||||||
minutes: Number.isFinite(minutes) && minutes > 0 ? minutes : 20,
|
minutes: Number.isFinite(minutes) && minutes > 0 ? minutes : 20,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -98,22 +296,6 @@ export function debugAiLog(
|
|||||||
data: Record<string, unknown>
|
data: Record<string, unknown>
|
||||||
) {
|
) {
|
||||||
console.error(`[adventureos-ai] ${location}: ${message}`, JSON.stringify(data));
|
console.error(`[adventureos-ai] ${location}: ${message}`, JSON.stringify(data));
|
||||||
// #region agent log
|
|
||||||
fetch("http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Debug-Session-Id": "04e12c",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
sessionId: "04e12c",
|
|
||||||
location,
|
|
||||||
message,
|
|
||||||
data,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
}),
|
|
||||||
}).catch(() => {});
|
|
||||||
// #endregion
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAiTimeoutError(error: unknown): boolean {
|
export function isAiTimeoutError(error: unknown): boolean {
|
||||||
@@ -122,3 +304,7 @@ export function isAiTimeoutError(error: unknown): boolean {
|
|||||||
if (error.message.toLowerCase().includes("timeout")) return true;
|
if (error.message.toLowerCase().includes("timeout")) return true;
|
||||||
return (error as Error & { code?: number }).code === 23;
|
return (error as Error & { code?: number }).code === 23;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isInvalidObjectString(value: string | undefined): boolean {
|
||||||
|
return !value || isObjectObject(value);
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,20 +32,45 @@ Return JSON: { "patterns": ["..."], "encouragement": "...", "focus_suggestion":
|
|||||||
name: "Homework Generation",
|
name: "Homework Generation",
|
||||||
description: "Teacher homework assignments",
|
description: "Teacher homework assignments",
|
||||||
category: "template",
|
category: "template",
|
||||||
body: `Create topic-specific learning materials about: {{topic}}
|
body: `Create a useful mini-lesson about: {{topic}}
|
||||||
Include a short introduction, learning objectives, reading/research steps, homework, reflection prompt, flashcards, and a quiz.
|
Difficulty: {{difficulty}}. Length: {{length}}.
|
||||||
Return JSON only:
|
Personal context: {{context}}
|
||||||
|
Library context (ONLY cite these books if relevant; never invent owned books):
|
||||||
|
{{library_context}}
|
||||||
|
|
||||||
|
Return JSON only with ALL sections filled (not one-line answers):
|
||||||
{
|
{
|
||||||
"title": "...",
|
"title": "clear lesson title",
|
||||||
"introduction": "...",
|
"overview": "2-4 sentences introducing the topic",
|
||||||
"objectives": ["..."],
|
"whyItMatters": "2-3 sentences on real-world relevance",
|
||||||
"readingSteps": ["..."],
|
"learningObjectives": ["3-5 specific objectives"],
|
||||||
"reflectionPrompt": "...",
|
"explanation": "beginner-friendly explanation (short=2 paragraphs, standard=3-4, deep=5+)",
|
||||||
|
"researchAssignment": {
|
||||||
|
"steps": ["3-5 concrete research steps"],
|
||||||
|
"expectedOutcome": "what the learner should produce",
|
||||||
|
"estimatedTimeMinutes": 20
|
||||||
|
},
|
||||||
|
"calibreSuggestions": [{ "title", "authors": ["..."], "reason": "why this book helps" }],
|
||||||
|
"externalReading": ["general topic directions without URLs"],
|
||||||
|
"homework": { "task": "...", "instructions": ["..."] },
|
||||||
|
"reflectionPrompt": "one thoughtful question",
|
||||||
"flashcards": [{ "front", "back" }],
|
"flashcards": [{ "front", "back" }],
|
||||||
"quiz": [{ "question", "options": ["a","b","c","d"], "answer": 0 }],
|
"quiz": [{
|
||||||
"assignment": "..."
|
"question": "...",
|
||||||
|
"type": "multiple_choice",
|
||||||
|
"options": ["a","b","c","d"],
|
||||||
|
"answer": 0,
|
||||||
|
"explanation": "why this is correct"
|
||||||
|
}],
|
||||||
|
"answerKey": [{ "questionIndex": 0, "answer": "...", "explanation": "..." }],
|
||||||
|
"nextLessonSuggestion": "optional follow-up topic"
|
||||||
}
|
}
|
||||||
Use answer as a 0-based index number.`,
|
Rules:
|
||||||
|
- Include 3-5 quiz questions (mix multiple_choice and short_answer if possible).
|
||||||
|
- For multiple_choice use answer as 0-based index.
|
||||||
|
- For short_answer omit options; put the answer text in answer as a string.
|
||||||
|
- If no library books match, set calibreSuggestions to [] and mention it in overview.
|
||||||
|
- Be specific to {{topic}}. Tone: calm, curious, practical.`,
|
||||||
},
|
},
|
||||||
reflection_prompt: {
|
reflection_prompt: {
|
||||||
name: "Reflection Prompt",
|
name: "Reflection Prompt",
|
||||||
@@ -114,7 +139,9 @@ Be concise, encouraging, and practical. Output valid JSON only.`,
|
|||||||
name: "Teacher Instructions",
|
name: "Teacher Instructions",
|
||||||
description: "Teacher content persona",
|
description: "Teacher content persona",
|
||||||
category: "system_prompt",
|
category: "system_prompt",
|
||||||
body: `Explain concepts simply. Use examples from everyday life. Encourage curiosity over perfection.`,
|
body: `You are a calm, wise tutor giving a structured mini-learning session — not a generic chat reply.
|
||||||
|
Explain simply with everyday examples. Give practical homework and a fair quiz with an answer key.
|
||||||
|
Encourage curiosity over perfection. Never overwhelm. Output valid JSON only.`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,7 +153,9 @@ export const PLACEHOLDER_DOCS = [
|
|||||||
{ key: "reading_progress", description: "Reading stats" },
|
{ key: "reading_progress", description: "Reading stats" },
|
||||||
{ key: "exercise_summary", description: "Exercise activity" },
|
{ key: "exercise_summary", description: "Exercise activity" },
|
||||||
{ key: "prayer_summary", description: "Prayer/litany progress" },
|
{ key: "prayer_summary", description: "Prayer/litany progress" },
|
||||||
{ key: "learning_topics", description: "Current learning topics" },
|
{ key: "library_context", description: "User Calibre library metadata for this topic" },
|
||||||
|
{ key: "difficulty", description: "Lesson difficulty: beginner, intermediate, advanced" },
|
||||||
|
{ key: "length", description: "Lesson length: short, standard, deep" },
|
||||||
{ key: "chronicle_context", description: "Long-term journey context" },
|
{ key: "chronicle_context", description: "Long-term journey context" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { and, eq, desc, isNull } from "drizzle-orm";
|
|||||||
import { db, aiChatSessions, aiChatMessages } from "../db";
|
import { db, aiChatSessions, aiChatMessages } from "../db";
|
||||||
import { buildMentorContext, formatContextForPrompt } from "./ai-context";
|
import { buildMentorContext, formatContextForPrompt } from "./ai-context";
|
||||||
import { generateChatReply, getAiAvailability } from "./ai";
|
import { generateChatReply, getAiAvailability } from "./ai";
|
||||||
|
import {
|
||||||
|
formatLibraryContextForPrompt,
|
||||||
|
getLibraryContextForTopic,
|
||||||
|
shouldIncludeLibraryContextInChat,
|
||||||
|
} from "./library-context";
|
||||||
import { listMemories, getProfileSummary } from "./ai-memory";
|
import { listMemories, getProfileSummary } from "./ai-memory";
|
||||||
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
|
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
|
||||||
|
|
||||||
@@ -66,6 +71,16 @@ export async function sendChatMessage(userId: string, sessionId: string, content
|
|||||||
logContext: true,
|
logContext: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let contextBlock = formatContextForPrompt(ctx);
|
||||||
|
if (shouldIncludeLibraryContextInChat(content)) {
|
||||||
|
try {
|
||||||
|
const libraryPayload = await getLibraryContextForTopic(userId, content);
|
||||||
|
contextBlock = `${contextBlock}\n\n${formatLibraryContextForPrompt(libraryPayload)}`;
|
||||||
|
} catch {
|
||||||
|
/* library optional */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let reply: string;
|
let reply: string;
|
||||||
let offline = false;
|
let offline = false;
|
||||||
let memoryIdsUsed = ctx.memoryIds;
|
let memoryIdsUsed = ctx.memoryIds;
|
||||||
@@ -81,7 +96,7 @@ export async function sendChatMessage(userId: string, sessionId: string, content
|
|||||||
|
|
||||||
const generated = await generateChatReply(userId, {
|
const generated = await generateChatReply(userId, {
|
||||||
userMessage: content,
|
userMessage: content,
|
||||||
contextBlock: formatContextForPrompt(ctx),
|
contextBlock,
|
||||||
history,
|
history,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ vi.mock("./ai-config", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./ai-templates", () => ({
|
vi.mock("./ai-templates", () => ({
|
||||||
getTemplateBody: vi.fn(async () => "topic: {{topic}}"),
|
getTemplateBody: vi.fn(async () => "topic: {{topic}} difficulty: {{difficulty}}"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../ai/provider-registry", () => ({
|
vi.mock("../ai/provider-registry", () => ({
|
||||||
@@ -16,12 +16,30 @@ vi.mock("../ai/provider-registry", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./user", () => ({
|
vi.mock("./user", () => ({
|
||||||
requireUser: vi.fn(async () => ({ user: { id: "user-1" } })),
|
requireUser: vi.fn(async () => ({ user: { id: "user-1", displayName: "Traveler" } })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { getAiBehaviorConfig, getAiProviderConfig } from "./ai-config";
|
import { getAiBehaviorConfig, getAiProviderConfig } from "./ai-config";
|
||||||
import { getProvider } from "../ai/provider-registry";
|
import { getProvider } from "../ai/provider-registry";
|
||||||
|
|
||||||
|
const onlineAvailability = {
|
||||||
|
enabled: true,
|
||||||
|
creativity: 0.7,
|
||||||
|
personality: "supportive_mentor",
|
||||||
|
verbosity: "balanced",
|
||||||
|
strictMode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const onlineProviderConfig = {
|
||||||
|
enabled: true,
|
||||||
|
type: "ollama" as const,
|
||||||
|
baseUrl: "http://localhost:11434",
|
||||||
|
model: "test",
|
||||||
|
maxTokens: 1000,
|
||||||
|
temperature: 0.7,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
};
|
||||||
|
|
||||||
describe("teacherSchema", () => {
|
describe("teacherSchema", () => {
|
||||||
it("coerces string quiz answers to numbers", () => {
|
it("coerces string quiz answers to numbers", () => {
|
||||||
const parsed = teacherSchema.parse({
|
const parsed = teacherSchema.parse({
|
||||||
@@ -32,18 +50,34 @@ describe("teacherSchema", () => {
|
|||||||
expect(parsed.quiz[0].answer).toBe(0);
|
expect(parsed.quiz[0].answer).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts optional structured fields", () => {
|
it("accepts extended structured fields", () => {
|
||||||
const parsed = teacherSchema.parse({
|
const parsed = teacherSchema.parse({
|
||||||
title: "Roman Roads",
|
title: "DNS Basics",
|
||||||
introduction: "A brief intro",
|
overview: "Intro to DNS",
|
||||||
objectives: ["Learn basics"],
|
whyItMatters: "Used everywhere",
|
||||||
readingSteps: ["Find a source"],
|
learningObjectives: ["Understand DNS"],
|
||||||
reflectionPrompt: "What surprised you?",
|
explanation: "DNS maps names to IPs",
|
||||||
|
researchAssignment: {
|
||||||
|
steps: ["Look up DNS"],
|
||||||
|
expectedOutcome: "Summary",
|
||||||
|
estimatedTimeMinutes: 20,
|
||||||
|
},
|
||||||
|
calibreSuggestions: [{ title: "Networking", authors: ["Author"], reason: "Relevant" }],
|
||||||
|
homework: { task: "Draw diagram", instructions: ["Use pen"] },
|
||||||
flashcards: [{ front: "Q", back: "A" }],
|
flashcards: [{ front: "Q", back: "A" }],
|
||||||
quiz: [{ question: "Q?", options: ["a", "b"], answer: 0 }],
|
quiz: [
|
||||||
|
{
|
||||||
|
question: "Q?",
|
||||||
|
type: "multiple_choice",
|
||||||
|
options: ["a", "b"],
|
||||||
|
answer: 0,
|
||||||
|
answerText: "a",
|
||||||
|
},
|
||||||
|
],
|
||||||
assignment: "Research",
|
assignment: "Research",
|
||||||
});
|
});
|
||||||
expect(parsed.title).toBe("Roman Roads");
|
expect(parsed.title).toBe("DNS Basics");
|
||||||
|
expect(parsed.researchAssignment?.steps).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,28 +95,16 @@ describe("generateTeacherContent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns AI content when provider succeeds", async () => {
|
it("returns AI content when provider succeeds", async () => {
|
||||||
vi.mocked(getAiBehaviorConfig).mockResolvedValue({
|
vi.mocked(getAiBehaviorConfig).mockResolvedValue(onlineAvailability);
|
||||||
enabled: true,
|
vi.mocked(getAiProviderConfig).mockResolvedValue(onlineProviderConfig);
|
||||||
creativity: 0.7,
|
|
||||||
personality: "supportive_mentor",
|
|
||||||
verbosity: "balanced",
|
|
||||||
strictMode: false,
|
|
||||||
});
|
|
||||||
vi.mocked(getAiProviderConfig).mockResolvedValue({
|
|
||||||
enabled: true,
|
|
||||||
type: "ollama",
|
|
||||||
baseUrl: "http://localhost:11434",
|
|
||||||
model: "test",
|
|
||||||
maxTokens: 1000,
|
|
||||||
temperature: 0.7,
|
|
||||||
timeoutMs: 30000,
|
|
||||||
});
|
|
||||||
vi.mocked(getProvider).mockReturnValue({
|
vi.mocked(getProvider).mockReturnValue({
|
||||||
type: "ollama",
|
type: "ollama",
|
||||||
healthCheck: vi.fn(async () => ({ status: "online" as const })),
|
healthCheck: vi.fn(async () => ({ status: "online" as const })),
|
||||||
listModels: vi.fn(async () => []),
|
listModels: vi.fn(async () => []),
|
||||||
generateText: vi.fn(async () => ({
|
generateText: vi.fn(async () => ({
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
|
overview: "Intro",
|
||||||
|
explanation: "Details here",
|
||||||
flashcards: [{ front: "What is Rust?", back: "A systems language" }],
|
flashcards: [{ front: "What is Rust?", back: "A systems language" }],
|
||||||
quiz: [
|
quiz: [
|
||||||
{
|
{
|
||||||
@@ -91,7 +113,7 @@ describe("generateTeacherContent", () => {
|
|||||||
answer: 0,
|
answer: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
assignment: "Read chapter 1",
|
researchAssignment: { steps: ["Read chapter 1"], expectedOutcome: "Notes" },
|
||||||
}),
|
}),
|
||||||
model: "test",
|
model: "test",
|
||||||
latencyMs: 10,
|
latencyMs: 10,
|
||||||
@@ -99,52 +121,30 @@ describe("generateTeacherContent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { generateTeacherContent } = await import("./ai");
|
const { generateTeacherContent } = await import("./ai");
|
||||||
const result = await generateTeacherContent("Rust ownership", "user-1");
|
const result = await generateTeacherContent("Rust ownership", "user-1", {
|
||||||
|
difficulty: "beginner",
|
||||||
|
length: "standard",
|
||||||
|
});
|
||||||
expect(result.source).toBe("ai");
|
expect(result.source).toBe("ai");
|
||||||
expect(result.content.flashcards[0].back).toContain("systems language");
|
expect(result.content.flashcards[0].back).toContain("systems language");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns fallback when AI is disabled", async () => {
|
it("throws when AI is unavailable", async () => {
|
||||||
vi.mocked(getAiBehaviorConfig).mockResolvedValue({
|
vi.mocked(getAiBehaviorConfig).mockResolvedValue({
|
||||||
|
...onlineAvailability,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
creativity: 0.7,
|
|
||||||
personality: "supportive_mentor",
|
|
||||||
verbosity: "balanced",
|
|
||||||
strictMode: false,
|
|
||||||
});
|
|
||||||
vi.mocked(getAiProviderConfig).mockResolvedValue({
|
|
||||||
enabled: true,
|
|
||||||
type: "ollama",
|
|
||||||
baseUrl: "http://localhost:11434",
|
|
||||||
model: "test",
|
|
||||||
maxTokens: 1000,
|
|
||||||
temperature: 0.7,
|
|
||||||
timeoutMs: 30000,
|
|
||||||
});
|
});
|
||||||
|
vi.mocked(getAiProviderConfig).mockResolvedValue(onlineProviderConfig);
|
||||||
|
|
||||||
const { generateTeacherContent } = await import("./ai");
|
const { generateTeacherContent } = await import("./ai");
|
||||||
const result = await generateTeacherContent("Stoicism", "user-1");
|
await expect(generateTeacherContent("Stoicism", "user-1")).rejects.toThrow(
|
||||||
expect(result.source).toBe("fallback");
|
"AI is unavailable"
|
||||||
expect(result.content.assignment).toContain("Stoicism");
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns fallback when AI JSON is invalid and strictMode is off", async () => {
|
it("returns fallback when AI JSON is invalid and strictMode is off", async () => {
|
||||||
vi.mocked(getAiBehaviorConfig).mockResolvedValue({
|
vi.mocked(getAiBehaviorConfig).mockResolvedValue(onlineAvailability);
|
||||||
enabled: true,
|
vi.mocked(getAiProviderConfig).mockResolvedValue(onlineProviderConfig);
|
||||||
creativity: 0.7,
|
|
||||||
personality: "supportive_mentor",
|
|
||||||
verbosity: "balanced",
|
|
||||||
strictMode: false,
|
|
||||||
});
|
|
||||||
vi.mocked(getAiProviderConfig).mockResolvedValue({
|
|
||||||
enabled: true,
|
|
||||||
type: "ollama",
|
|
||||||
baseUrl: "http://localhost:11434",
|
|
||||||
model: "test",
|
|
||||||
maxTokens: 1000,
|
|
||||||
temperature: 0.7,
|
|
||||||
timeoutMs: 30000,
|
|
||||||
});
|
|
||||||
vi.mocked(getProvider).mockReturnValue({
|
vi.mocked(getProvider).mockReturnValue({
|
||||||
type: "ollama",
|
type: "ollama",
|
||||||
healthCheck: vi.fn(async () => ({ status: "online" as const })),
|
healthCheck: vi.fn(async () => ({ status: "online" as const })),
|
||||||
@@ -159,5 +159,6 @@ describe("generateTeacherContent", () => {
|
|||||||
const { generateTeacherContent } = await import("./ai");
|
const { generateTeacherContent } = await import("./ai");
|
||||||
const result = await generateTeacherContent("Stoicism", "user-1");
|
const result = await generateTeacherContent("Stoicism", "user-1");
|
||||||
expect(result.source).toBe("fallback");
|
expect(result.source).toBe("fallback");
|
||||||
|
expect(result.fallbackReason).toBe("parse_failed");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
pickRandomQuests,
|
pickRandomQuests,
|
||||||
} from "@adventureos/shared";
|
} from "@adventureos/shared";
|
||||||
import { getProvider } from "../ai/provider-registry";
|
import { getProvider } from "../ai/provider-registry";
|
||||||
|
import type { TeacherDifficulty, TeacherLength } from "@adventureos/shared";
|
||||||
import {
|
import {
|
||||||
debugAiLog,
|
debugAiLog,
|
||||||
isAiTimeoutError,
|
isAiTimeoutError,
|
||||||
|
isInvalidObjectString,
|
||||||
normalizeExplorationPayload,
|
normalizeExplorationPayload,
|
||||||
normalizeTeacherPayload,
|
normalizeTeacherPayload,
|
||||||
safeParseAiJson,
|
safeParseAiJson,
|
||||||
@@ -55,21 +57,59 @@ const mentorSchema = z.object({
|
|||||||
letter: z.string(),
|
letter: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const teacherResearchAssignmentSchema = z.object({
|
||||||
|
steps: z.array(z.string()),
|
||||||
|
expectedOutcome: z.string().optional(),
|
||||||
|
estimatedTimeMinutes: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const teacherHomeworkSchema = z.object({
|
||||||
|
task: z.string(),
|
||||||
|
instructions: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const teacherCalibreSuggestionSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
authors: z.array(z.string()),
|
||||||
|
reason: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const teacherSchema = z.object({
|
export const teacherSchema = z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
introduction: z.string().optional(),
|
introduction: z.string().optional(),
|
||||||
|
overview: z.string().optional(),
|
||||||
|
whyItMatters: z.string().optional(),
|
||||||
objectives: z.array(z.string()).optional(),
|
objectives: z.array(z.string()).optional(),
|
||||||
|
learningObjectives: z.array(z.string()).optional(),
|
||||||
|
explanation: z.string().optional(),
|
||||||
|
researchAssignment: teacherResearchAssignmentSchema.optional(),
|
||||||
readingSteps: z.array(z.string()).optional(),
|
readingSteps: z.array(z.string()).optional(),
|
||||||
|
calibreSuggestions: z.array(teacherCalibreSuggestionSchema).optional(),
|
||||||
|
externalReading: z.array(z.string()).optional(),
|
||||||
|
homework: teacherHomeworkSchema.optional(),
|
||||||
reflectionPrompt: z.string().optional(),
|
reflectionPrompt: z.string().optional(),
|
||||||
flashcards: z.array(z.object({ front: z.string(), back: z.string() })),
|
flashcards: z.array(z.object({ front: z.string(), back: z.string() })),
|
||||||
quiz: z.array(
|
quiz: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
question: z.string(),
|
question: z.string(),
|
||||||
options: z.array(z.string()),
|
type: z.enum(["multiple_choice", "short_answer"]).optional(),
|
||||||
|
options: z.array(z.string()).optional(),
|
||||||
answer: z.coerce.number(),
|
answer: z.coerce.number(),
|
||||||
|
answerText: z.string().optional(),
|
||||||
|
explanation: z.string().optional(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
assignment: z.string(),
|
answerKey: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
questionIndex: z.coerce.number(),
|
||||||
|
answer: z.string(),
|
||||||
|
explanation: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
nextLessonSuggestion: z.string().optional(),
|
||||||
|
assignment: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TeacherContent = z.infer<typeof teacherSchema>;
|
export type TeacherContent = z.infer<typeof teacherSchema>;
|
||||||
@@ -174,10 +214,16 @@ async function aiGenerate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasMeaningfulTeacherContent(content: TeacherContent): boolean {
|
function hasMeaningfulTeacherContent(content: TeacherContent): boolean {
|
||||||
|
const hasAssignment =
|
||||||
|
(content.assignment && !isInvalidObjectString(content.assignment)) ||
|
||||||
|
(content.researchAssignment?.steps?.length ?? 0) > 0 ||
|
||||||
|
(content.homework?.task && !isInvalidObjectString(content.homework.task));
|
||||||
|
const hasExplanation = !!content.explanation?.trim() || !!content.overview?.trim();
|
||||||
return (
|
return (
|
||||||
content.assignment.trim().length > 0 ||
|
hasAssignment ||
|
||||||
content.flashcards.length > 0 ||
|
content.flashcards.length > 0 ||
|
||||||
content.quiz.length > 0
|
content.quiz.length > 0 ||
|
||||||
|
hasExplanation
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,23 +259,58 @@ function parseExplorationResponse(raw: string) {
|
|||||||
|
|
||||||
function teacherFallback(topic: string): TeacherContent {
|
function teacherFallback(topic: string): TeacherContent {
|
||||||
return {
|
return {
|
||||||
|
title: `Introduction to ${topic}`,
|
||||||
|
overview: `This is a basic offline lesson about ${topic}. Connect to the AI when available for a fuller session.`,
|
||||||
|
whyItMatters: `${topic} connects to everyday curiosity and practical understanding.`,
|
||||||
|
learningObjectives: [`Understand the basics of ${topic}`, "Know where to look next"],
|
||||||
|
explanation: `Start by defining ${topic} in your own words. Look for one reliable source and one example from daily life.`,
|
||||||
|
researchAssignment: {
|
||||||
|
steps: [
|
||||||
|
`Search for a beginner-friendly explanation of ${topic}`,
|
||||||
|
"Write three bullet points in your own words",
|
||||||
|
"Note one question you still have",
|
||||||
|
],
|
||||||
|
expectedOutcome: "A short summary you could explain to a friend",
|
||||||
|
estimatedTimeMinutes: 20,
|
||||||
|
},
|
||||||
|
homework: {
|
||||||
|
task: `Explore ${topic} for 20 minutes`,
|
||||||
|
instructions: ["Pick one source", "Write one surprising sentence"],
|
||||||
|
},
|
||||||
flashcards: [
|
flashcards: [
|
||||||
{
|
{
|
||||||
front: `What is ${topic}?`,
|
front: `What is ${topic}?`,
|
||||||
back: "Explore this topic through reading and observation.",
|
back: "A topic worth exploring through reading and observation.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
quiz: [
|
quiz: [
|
||||||
{
|
{
|
||||||
question: `Which approach helps you learn about ${topic}?`,
|
question: `Which approach helps you learn about ${topic}?`,
|
||||||
|
type: "multiple_choice",
|
||||||
options: ["Curious reading", "Giving up", "Ignoring it", "Rushing"],
|
options: ["Curious reading", "Giving up", "Ignoring it", "Rushing"],
|
||||||
answer: 0,
|
answer: 0,
|
||||||
|
answerText: "Curious reading",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
assignment: `Spend 20 minutes researching ${topic}. Write one sentence about what surprised you.`,
|
assignment: `Spend 20 minutes researching ${topic}. Write one sentence about what surprised you.`,
|
||||||
|
reflectionPrompt: `What is one thing about ${topic} you want to understand better?`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TeacherGenerationOptions = {
|
||||||
|
personalContext?: string;
|
||||||
|
libraryContext?: string;
|
||||||
|
difficulty?: TeacherDifficulty;
|
||||||
|
length?: TeacherLength;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TeacherAiUnavailableError extends Error {
|
||||||
|
constructor(message = "AI is unavailable. Check Settings or your local model.") {
|
||||||
|
super(message);
|
||||||
|
this.name = "TeacherAiUnavailableError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateQuests(context: Record<string, unknown>, userId?: string) {
|
export async function generateQuests(context: Record<string, unknown>, userId?: string) {
|
||||||
const uid = userId ?? (await getUserId());
|
const uid = userId ?? (await getUserId());
|
||||||
const raw = await aiGenerate(
|
const raw = await aiGenerate(
|
||||||
@@ -321,7 +402,7 @@ export async function generateMentorReview(context: Record<string, unknown>, use
|
|||||||
export async function generateTeacherContent(
|
export async function generateTeacherContent(
|
||||||
topic: string,
|
topic: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
personalContext?: string
|
options: TeacherGenerationOptions = {}
|
||||||
): Promise<TeacherGenerationResult> {
|
): Promise<TeacherGenerationResult> {
|
||||||
const trimmed = topic.trim();
|
const trimmed = topic.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -332,12 +413,28 @@ export async function generateTeacherContent(
|
|||||||
const behavior = await getAiBehaviorConfig(uid);
|
const behavior = await getAiBehaviorConfig(uid);
|
||||||
const availability = await getAiAvailability(uid);
|
const availability = await getAiAvailability(uid);
|
||||||
|
|
||||||
|
if (!availability.canUse || !availability.online) {
|
||||||
|
throw new TeacherAiUnavailableError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficulty = options.difficulty ?? "beginner";
|
||||||
|
const length = options.length ?? "standard";
|
||||||
|
const contextParts = [options.personalContext ?? ""];
|
||||||
|
if (options.libraryContext) contextParts.push(options.libraryContext);
|
||||||
|
const combinedContext = contextParts.filter(Boolean).join("\n\n");
|
||||||
|
|
||||||
const raw = await aiGenerate(
|
const raw = await aiGenerate(
|
||||||
uid,
|
uid,
|
||||||
"homework_generation",
|
"homework_generation",
|
||||||
"system_core",
|
"system_core",
|
||||||
"system_teacher",
|
"system_teacher",
|
||||||
{ topic: trimmed, context: personalContext ?? "" }
|
{
|
||||||
|
topic: trimmed,
|
||||||
|
context: combinedContext,
|
||||||
|
library_context: options.libraryContext ?? "Library not available.",
|
||||||
|
difficulty,
|
||||||
|
length,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (raw) {
|
if (raw) {
|
||||||
@@ -350,20 +447,29 @@ export async function generateTeacherContent(
|
|||||||
canUse: availability.canUse,
|
canUse: availability.canUse,
|
||||||
rawLen: raw.length,
|
rawLen: raw.length,
|
||||||
});
|
});
|
||||||
if (behavior.strictMode && availability.canUse && availability.online) {
|
if (behavior.strictMode) {
|
||||||
throw new Error("AI returned invalid lesson content. Try again.");
|
throw new Error("AI returned invalid lesson content. Try again.");
|
||||||
}
|
}
|
||||||
} else {
|
return {
|
||||||
|
content: teacherFallback(trimmed),
|
||||||
|
source: "fallback",
|
||||||
|
fallbackReason: "parse_failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
debugAiLog("ai.ts:generateTeacherContent", "no raw response", {
|
debugAiLog("ai.ts:generateTeacherContent", "no raw response", {
|
||||||
online: availability.online,
|
online: availability.online,
|
||||||
canUse: availability.canUse,
|
canUse: availability.canUse,
|
||||||
});
|
});
|
||||||
if (behavior.strictMode && availability.canUse && availability.online) {
|
if (behavior.strictMode) {
|
||||||
throw new Error("AI generation failed");
|
throw new Error("AI generation failed");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return { content: teacherFallback(trimmed), source: "fallback", fallbackReason: lastAiFallbackReason ?? "generation_failed" };
|
return {
|
||||||
|
content: teacherFallback(trimmed),
|
||||||
|
source: "fallback",
|
||||||
|
fallbackReason: lastAiFallbackReason ?? "generation_failed",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isOllamaAvailable(): Promise<boolean> {
|
export async function isOllamaAvailable(): Promise<boolean> {
|
||||||
|
|||||||
90
apps/web/src/lib/services/library-context.test.ts
Normal file
90
apps/web/src/lib/services/library-context.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./calibre", () => ({
|
||||||
|
getCalibreStatus: vi.fn(),
|
||||||
|
listCalibreBooks: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./calibre-reading", () => ({
|
||||||
|
getReadingProgress: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getCalibreStatus, listCalibreBooks } from "./calibre";
|
||||||
|
import { getReadingProgress } from "./calibre-reading";
|
||||||
|
import {
|
||||||
|
formatLibraryContextForPrompt,
|
||||||
|
getLibraryContextForTopic,
|
||||||
|
shouldIncludeLibraryContextInChat,
|
||||||
|
} from "./library-context";
|
||||||
|
|
||||||
|
describe("library-context", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty payload when Calibre unavailable", async () => {
|
||||||
|
vi.mocked(getCalibreStatus).mockResolvedValue({
|
||||||
|
configured: false,
|
||||||
|
online: false,
|
||||||
|
bookCount: 0,
|
||||||
|
lastCheckAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await getLibraryContextForTopic("user-1", "Ottoman history");
|
||||||
|
expect(payload.available).toBe(false);
|
||||||
|
expect(payload.relevantBooks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits and ranks relevant books for a topic", async () => {
|
||||||
|
vi.mocked(getCalibreStatus).mockResolvedValue({
|
||||||
|
configured: true,
|
||||||
|
online: true,
|
||||||
|
bookCount: 2,
|
||||||
|
lastCheckAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
vi.mocked(getReadingProgress).mockResolvedValue([]);
|
||||||
|
vi.mocked(listCalibreBooks).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
uuid: "a",
|
||||||
|
title: "The Ottoman Empire",
|
||||||
|
authors: ["Author A"],
|
||||||
|
tags: ["history", "ottoman"],
|
||||||
|
comment: "A long ".repeat(50),
|
||||||
|
series: null,
|
||||||
|
seriesIndex: null,
|
||||||
|
rating: null,
|
||||||
|
formats: ["EPUB"],
|
||||||
|
hasCover: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
uuid: "b",
|
||||||
|
title: "Cooking Basics",
|
||||||
|
authors: ["Chef"],
|
||||||
|
tags: ["food"],
|
||||||
|
comment: null,
|
||||||
|
series: null,
|
||||||
|
seriesIndex: null,
|
||||||
|
rating: null,
|
||||||
|
formats: ["EPUB"],
|
||||||
|
hasCover: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const payload = await getLibraryContextForTopic("user-1", "Ottoman history", { limit: 3 });
|
||||||
|
expect(payload.available).toBe(true);
|
||||||
|
expect(payload.relevantBooks.length).toBeGreaterThan(0);
|
||||||
|
expect(payload.relevantBooks[0]?.title).toBe("The Ottoman Empire");
|
||||||
|
expect(payload.relevantBooks[0]?.description?.length).toBeLessThanOrEqual(200);
|
||||||
|
const formatted = formatLibraryContextForPrompt(payload);
|
||||||
|
expect(formatted).toContain("Ottoman Empire");
|
||||||
|
expect(formatted).not.toMatch(/\/home\//);
|
||||||
|
expect(formatted).not.toContain("uuid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects chat messages about reading", () => {
|
||||||
|
expect(shouldIncludeLibraryContextInChat("What book should I read about history?")).toBe(true);
|
||||||
|
expect(shouldIncludeLibraryContextInChat("How is my day going?")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
202
apps/web/src/lib/services/library-context.ts
Normal file
202
apps/web/src/lib/services/library-context.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import type { CalibreBook } from "./calibre";
|
||||||
|
import { getCalibreStatus, listCalibreBooks } from "./calibre";
|
||||||
|
import { getReadingProgress } from "./calibre-reading";
|
||||||
|
|
||||||
|
const MAX_DESCRIPTION = 200;
|
||||||
|
const DEFAULT_RELEVANT_LIMIT = 6;
|
||||||
|
const DEFAULT_READING_LIMIT = 3;
|
||||||
|
|
||||||
|
export type LibraryBookContext = {
|
||||||
|
title: string;
|
||||||
|
authors: string[];
|
||||||
|
tags: string[];
|
||||||
|
series?: string | null;
|
||||||
|
description?: string;
|
||||||
|
status?: "unread" | "reading" | "finished";
|
||||||
|
progressPercent?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LibraryContextPayload = {
|
||||||
|
available: boolean;
|
||||||
|
relevantBooks: LibraryBookContext[];
|
||||||
|
currentlyReading: LibraryBookContext[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function trimDescription(comment: string | null | undefined): string | undefined {
|
||||||
|
if (!comment?.trim()) return undefined;
|
||||||
|
const trimmed = comment.trim().replace(/\s+/g, " ");
|
||||||
|
if (trimmed.length <= MAX_DESCRIPTION) return trimmed;
|
||||||
|
return `${trimmed.slice(0, MAX_DESCRIPTION - 1)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBookContext(
|
||||||
|
book: CalibreBook,
|
||||||
|
progress?: { status?: string | null; currentPage?: number | null; totalPages?: number | null }
|
||||||
|
): LibraryBookContext {
|
||||||
|
const total = Math.max(progress?.totalPages ?? 1, 1);
|
||||||
|
const current = progress?.currentPage ?? 0;
|
||||||
|
const status = (progress?.status as LibraryBookContext["status"]) ?? "unread";
|
||||||
|
return {
|
||||||
|
title: book.title,
|
||||||
|
authors: book.authors,
|
||||||
|
tags: book.tags,
|
||||||
|
series: book.series,
|
||||||
|
description: trimDescription(book.comment),
|
||||||
|
status,
|
||||||
|
progressPercent: progress ? Math.round((current / total) * 100) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreBookForTopic(book: CalibreBook, keywords: string[]): number {
|
||||||
|
if (keywords.length === 0) return 0;
|
||||||
|
const haystack = [
|
||||||
|
book.title,
|
||||||
|
book.authors.join(" "),
|
||||||
|
book.tags.join(" "),
|
||||||
|
book.series ?? "",
|
||||||
|
book.comment ?? "",
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
for (const kw of keywords) {
|
||||||
|
if (haystack.includes(kw)) score += kw.length > 3 ? 3 : 1;
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function topicKeywords(topic: string): string[] {
|
||||||
|
return topic
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[\s,;/]+/)
|
||||||
|
.map((w) => w.trim())
|
||||||
|
.filter((w) => w.length > 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentlyReadingBooks(
|
||||||
|
userId: string,
|
||||||
|
limit = DEFAULT_READING_LIMIT
|
||||||
|
): Promise<LibraryBookContext[]> {
|
||||||
|
try {
|
||||||
|
const status = await getCalibreStatus();
|
||||||
|
if (!status.configured || !status.online) return [];
|
||||||
|
|
||||||
|
const progress = await getReadingProgress(userId);
|
||||||
|
const reading = progress.filter((p) => p.status === "reading" && p.calibreBookId);
|
||||||
|
if (reading.length === 0) return [];
|
||||||
|
|
||||||
|
const books = await listCalibreBooks({ limit: 500 });
|
||||||
|
const bookMap = new Map(books.map((b) => [b.id, b]));
|
||||||
|
|
||||||
|
return reading
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((p) => {
|
||||||
|
const book = bookMap.get(p.calibreBookId!);
|
||||||
|
if (!book) return null;
|
||||||
|
return toBookContext(book, p);
|
||||||
|
})
|
||||||
|
.filter((b): b is LibraryBookContext => b != null);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLibraryContextForTopic(
|
||||||
|
userId: string,
|
||||||
|
topic: string,
|
||||||
|
options?: { limit?: number }
|
||||||
|
): Promise<LibraryContextPayload> {
|
||||||
|
const limit = options?.limit ?? DEFAULT_RELEVANT_LIMIT;
|
||||||
|
const empty: LibraryContextPayload = {
|
||||||
|
available: false,
|
||||||
|
relevantBooks: [],
|
||||||
|
currentlyReading: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await getCalibreStatus();
|
||||||
|
if (!status.configured || !status.online) return empty;
|
||||||
|
|
||||||
|
const currentlyReading = await getCurrentlyReadingBooks(userId);
|
||||||
|
const keywords = topicKeywords(topic);
|
||||||
|
const progress = await getReadingProgress(userId);
|
||||||
|
const progressMap = new Map(progress.map((p) => [p.calibreBookId, p]));
|
||||||
|
|
||||||
|
let candidates: CalibreBook[] = [];
|
||||||
|
if (keywords.length > 0) {
|
||||||
|
const primary = keywords[0]!;
|
||||||
|
candidates = await listCalibreBooks({ search: primary, limit: 50 });
|
||||||
|
if (candidates.length < limit && keywords.length > 1) {
|
||||||
|
const extra = await listCalibreBooks({ search: keywords.slice(0, 3).join(" "), limit: 50 });
|
||||||
|
const seen = new Set(candidates.map((b) => b.id));
|
||||||
|
for (const book of extra) {
|
||||||
|
if (!seen.has(book.id)) candidates.push(book);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
candidates = await listCalibreBooks({ limit: 50 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranked = candidates
|
||||||
|
.map((book) => ({ book, score: scoreBookForTopic(book, keywords) }))
|
||||||
|
.filter((entry) => entry.score > 0 || keywords.length === 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(({ book }) => toBookContext(book, progressMap.get(book.id)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
relevantBooks: ranked,
|
||||||
|
currentlyReading,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLibraryContextForPrompt(payload: LibraryContextPayload): string {
|
||||||
|
if (!payload.available) {
|
||||||
|
return "Library: not available (no matching books from user's Calibre library).";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = ["User's Calibre library (metadata only — cite ONLY these titles if relevant):"];
|
||||||
|
|
||||||
|
if (payload.currentlyReading.length > 0) {
|
||||||
|
lines.push("Currently reading:");
|
||||||
|
for (const book of payload.currentlyReading) {
|
||||||
|
lines.push(
|
||||||
|
`- "${book.title}" by ${book.authors.join(", ") || "unknown"}${book.tags.length ? ` [${book.tags.slice(0, 4).join(", ")}]` : ""}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.relevantBooks.length > 0) {
|
||||||
|
lines.push("Books possibly relevant to this topic:");
|
||||||
|
for (const book of payload.relevantBooks) {
|
||||||
|
const desc = book.description ? ` — ${book.description}` : "";
|
||||||
|
lines.push(
|
||||||
|
`- "${book.title}" by ${book.authors.join(", ") || "unknown"}${book.tags.length ? ` [${book.tags.slice(0, 4).join(", ")}]` : ""}${desc}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push("No matching library books found for this topic.");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("Do NOT invent books the user owns. If none match, say so and give general reading guidance.");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldIncludeLibraryContextInChat(message: string): boolean {
|
||||||
|
const lower = message.toLowerCase();
|
||||||
|
const patterns = [
|
||||||
|
/\bbook(s)?\b/,
|
||||||
|
/\bread(ing)?\b/,
|
||||||
|
/\blibrary\b/,
|
||||||
|
/\bshelf\b/,
|
||||||
|
/\brecommend\b.*\bread/,
|
||||||
|
/\bwhat should i read\b/,
|
||||||
|
/\bown(s)?\b.*\bbook/,
|
||||||
|
];
|
||||||
|
return patterns.some((p) => p.test(lower));
|
||||||
|
}
|
||||||
@@ -1,21 +1,36 @@
|
|||||||
import { generateTeacherContent } from "./ai";
|
import {
|
||||||
|
generateTeacherContent,
|
||||||
|
TeacherAiUnavailableError,
|
||||||
|
} from "./ai";
|
||||||
import { buildMentorContext, formatContextForPrompt } from "./ai-context";
|
import { buildMentorContext, formatContextForPrompt } from "./ai-context";
|
||||||
|
import {
|
||||||
|
formatLibraryContextForPrompt,
|
||||||
|
getLibraryContextForTopic,
|
||||||
|
} from "./library-context";
|
||||||
import {
|
import {
|
||||||
listTeacherContent,
|
listTeacherContent,
|
||||||
findTeacherContentById,
|
findTeacherContentById,
|
||||||
insertTeacherContent,
|
insertTeacherContent,
|
||||||
updateTeacherContent,
|
updateTeacherContent,
|
||||||
} from "@/lib/repositories/teacher.repository";
|
} from "@/lib/repositories/teacher.repository";
|
||||||
|
import { wrapTeacherContent, unwrapTeacherContent } from "@/lib/teacher/teacher-content";
|
||||||
import type { TeacherCreateBody } from "@/lib/validation/schemas";
|
import type { TeacherCreateBody } from "@/lib/validation/schemas";
|
||||||
|
|
||||||
|
function mapTeacherRow(row: Awaited<ReturnType<typeof listTeacherContent>>[number]) {
|
||||||
|
const stored = row.content as Record<string, unknown>;
|
||||||
|
const { content, source, fallbackReason } = unwrapTeacherContent(stored);
|
||||||
|
return { ...row, content, source, fallbackReason };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTeacherHistory(userId: string) {
|
export async function getTeacherHistory(userId: string) {
|
||||||
return listTeacherContent(userId);
|
const rows = await listTeacherContent(userId);
|
||||||
|
return rows.map(mapTeacherRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTeacherLesson(userId: string, id: string) {
|
export async function getTeacherLesson(userId: string, id: string) {
|
||||||
const row = await findTeacherContentById(id);
|
const row = await findTeacherContentById(id);
|
||||||
if (!row || row.userId !== userId) return null;
|
if (!row || row.userId !== userId) return null;
|
||||||
return row;
|
return mapTeacherRow(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTeacherLesson(
|
export async function createTeacherLesson(
|
||||||
@@ -27,18 +42,42 @@ export async function createTeacherLesson(
|
|||||||
throw new Error("Topic is required");
|
throw new Error("Topic is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = await buildMentorContext(userId, { feature: "teacher", topic, userMessage: topic });
|
const ctx = await buildMentorContext(userId, {
|
||||||
const { content, source, fallbackReason } = await generateTeacherContent(topic, userId, formatContextForPrompt(ctx));
|
feature: "teacher",
|
||||||
|
topic,
|
||||||
|
userMessage: topic,
|
||||||
|
});
|
||||||
|
|
||||||
|
let libraryContext = "";
|
||||||
|
if (input.includeLibraryContext !== false) {
|
||||||
|
try {
|
||||||
|
const payload = await getLibraryContextForTopic(userId, topic);
|
||||||
|
libraryContext = formatLibraryContextForPrompt(payload);
|
||||||
|
} catch {
|
||||||
|
libraryContext = "Library: not available.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { content, source, fallbackReason } = await generateTeacherContent(topic, userId, {
|
||||||
|
personalContext: formatContextForPrompt(ctx),
|
||||||
|
libraryContext,
|
||||||
|
difficulty: input.difficulty,
|
||||||
|
length: input.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const storedContent = wrapTeacherContent(content, { source, fallbackReason });
|
||||||
const row = await insertTeacherContent({
|
const row = await insertTeacherContent({
|
||||||
userId,
|
userId,
|
||||||
topic,
|
topic,
|
||||||
explorationId: input.explorationId,
|
explorationId: input.explorationId,
|
||||||
content,
|
content: storedContent as Record<string, unknown>,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
return { ...row, source, fallbackReason };
|
return { ...mapTeacherRow({ ...row, content: storedContent as Record<string, unknown> }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { TeacherAiUnavailableError };
|
||||||
|
|
||||||
export async function completeTeacherLesson(
|
export async function completeTeacherLesson(
|
||||||
userId: string,
|
userId: string,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -53,5 +92,5 @@ export async function completeTeacherLesson(
|
|||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
});
|
});
|
||||||
if (!updated) throw new Error("Lesson not found");
|
if (!updated) throw new Error("Lesson not found");
|
||||||
return updated;
|
return mapTeacherRow(updated);
|
||||||
}
|
}
|
||||||
|
|||||||
46
apps/web/src/lib/teacher/extract-text.test.ts
Normal file
46
apps/web/src/lib/teacher/extract-text.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
extractText,
|
||||||
|
extractStringList,
|
||||||
|
isObjectObject,
|
||||||
|
normalizeQuizAnswer,
|
||||||
|
} from "./extract-text";
|
||||||
|
|
||||||
|
describe("extractText", () => {
|
||||||
|
it("returns empty for object without known fields instead of [object Object]", () => {
|
||||||
|
expect(extractText({ foo: "bar" })).toBe("");
|
||||||
|
expect(extractText({ task: "Research DNS" })).toBe("Research DNS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts nested assignment objects", () => {
|
||||||
|
expect(
|
||||||
|
extractText({
|
||||||
|
task: "Write a summary",
|
||||||
|
steps: ["Open docs", "Take notes"],
|
||||||
|
})
|
||||||
|
).toContain("Write a summary");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractStringList", () => {
|
||||||
|
it("maps object steps to strings", () => {
|
||||||
|
expect(
|
||||||
|
extractStringList([{ step: "Read chapter 1" }, "Search Wikipedia"])
|
||||||
|
).toEqual(["Read chapter 1", "Search Wikipedia"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeQuizAnswer", () => {
|
||||||
|
it("maps letter B to index 1", () => {
|
||||||
|
const result = normalizeQuizAnswer("B", ["A opt", "B opt", "C opt"]);
|
||||||
|
expect(result.answerIndex).toBe(1);
|
||||||
|
expect(result.answerText).toBe("B opt");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isObjectObject", () => {
|
||||||
|
it("detects invalid coerced strings", () => {
|
||||||
|
expect(isObjectObject("[object Object]")).toBe(true);
|
||||||
|
expect(isObjectObject("real text")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
83
apps/web/src/lib/teacher/extract-text.ts
Normal file
83
apps/web/src/lib/teacher/extract-text.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const OBJECT_OBJECT = "[object Object]";
|
||||||
|
|
||||||
|
export function isObjectObject(value: string | undefined | null): boolean {
|
||||||
|
return value?.trim() === OBJECT_OBJECT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract human-readable text from AI fields that may be strings or nested objects. */
|
||||||
|
export function extractText(value: unknown): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return isObjectObject(trimmed) ? "" : trimmed;
|
||||||
|
}
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => extractText(item)).filter(Boolean).join("; ");
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const o = value as Record<string, unknown>;
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const key of ["task", "text", "description", "content", "summary", "goal", "step"]) {
|
||||||
|
const part = extractText(o[key]);
|
||||||
|
if (part) parts.push(part);
|
||||||
|
}
|
||||||
|
if (Array.isArray(o.steps)) {
|
||||||
|
const steps = o.steps.map((s) => extractText(s)).filter(Boolean);
|
||||||
|
if (steps.length) parts.push(steps.join("; "));
|
||||||
|
}
|
||||||
|
if (Array.isArray(o.instructions)) {
|
||||||
|
const instructions = o.instructions.map((s) => extractText(s)).filter(Boolean);
|
||||||
|
if (instructions.length) parts.push(instructions.join("; "));
|
||||||
|
}
|
||||||
|
return parts.join(" — ").trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractStringList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.map((item) => extractText(item)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeQuizAnswer(
|
||||||
|
rawAnswer: unknown,
|
||||||
|
options: string[]
|
||||||
|
): { answerIndex: number; answerText: string } {
|
||||||
|
if (options.length === 0) {
|
||||||
|
const text = extractText(rawAnswer);
|
||||||
|
return { answerIndex: 0, answerText: text || "See explanation" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawAnswer === "number" && Number.isFinite(rawAnswer)) {
|
||||||
|
const idx = Math.min(Math.max(0, rawAnswer), options.length - 1);
|
||||||
|
return { answerIndex: idx, answerText: options[idx] ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const asString = extractText(rawAnswer).trim();
|
||||||
|
if (!asString) return { answerIndex: 0, answerText: options[0] ?? "" };
|
||||||
|
|
||||||
|
const asNum = Number(asString);
|
||||||
|
if (Number.isFinite(asNum) && asNum >= 0 && asNum < options.length) {
|
||||||
|
return { answerIndex: asNum, answerText: options[asNum] ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const letterMatch = /^[A-Da-d]$/.exec(asString);
|
||||||
|
if (letterMatch) {
|
||||||
|
const idx = letterMatch[0].toUpperCase().charCodeAt(0) - 65;
|
||||||
|
if (idx >= 0 && idx < options.length) {
|
||||||
|
return { answerIndex: idx, answerText: options[idx] ?? "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionIdx = options.findIndex(
|
||||||
|
(opt) => opt.toLowerCase() === asString.toLowerCase()
|
||||||
|
);
|
||||||
|
if (optionIdx >= 0) {
|
||||||
|
return { answerIndex: optionIdx, answerText: options[optionIdx] ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { answerIndex: 0, answerText: asString };
|
||||||
|
}
|
||||||
19
apps/web/src/lib/teacher/teacher-content.test.ts
Normal file
19
apps/web/src/lib/teacher/teacher-content.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { wrapTeacherContent, unwrapTeacherContent } from "./teacher-content";
|
||||||
|
|
||||||
|
describe("teacher-content envelope", () => {
|
||||||
|
it("round-trips source metadata", () => {
|
||||||
|
const wrapped = wrapTeacherContent(
|
||||||
|
{
|
||||||
|
flashcards: [],
|
||||||
|
quiz: [],
|
||||||
|
assignment: "Read",
|
||||||
|
},
|
||||||
|
{ source: "fallback", fallbackReason: "parse_failed" }
|
||||||
|
);
|
||||||
|
const unwrapped = unwrapTeacherContent(wrapped as Record<string, unknown>);
|
||||||
|
expect(unwrapped.source).toBe("fallback");
|
||||||
|
expect(unwrapped.fallbackReason).toBe("parse_failed");
|
||||||
|
expect(unwrapped.content.assignment).toBe("Read");
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/web/src/lib/teacher/teacher-content.ts
Normal file
34
apps/web/src/lib/teacher/teacher-content.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { AiFallbackReason, AiContentSource } from "@/lib/services/ai";
|
||||||
|
import type { TeacherLessonContent } from "@adventureos/shared";
|
||||||
|
|
||||||
|
const META_KEY = "_meta";
|
||||||
|
|
||||||
|
type StoredTeacherMeta = {
|
||||||
|
source?: AiContentSource;
|
||||||
|
fallbackReason?: AiFallbackReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoredTeacherContent = TeacherLessonContent & {
|
||||||
|
[META_KEY]?: StoredTeacherMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function wrapTeacherContent(
|
||||||
|
content: TeacherLessonContent,
|
||||||
|
meta: StoredTeacherMeta
|
||||||
|
): StoredTeacherContent {
|
||||||
|
return { ...content, [META_KEY]: meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapTeacherContent(stored: Record<string, unknown>): {
|
||||||
|
content: TeacherLessonContent;
|
||||||
|
source?: AiContentSource;
|
||||||
|
fallbackReason?: AiFallbackReason;
|
||||||
|
} {
|
||||||
|
const meta = stored[META_KEY] as StoredTeacherMeta | undefined;
|
||||||
|
const { [META_KEY]: _, ...rest } = stored;
|
||||||
|
return {
|
||||||
|
content: rest as TeacherLessonContent,
|
||||||
|
source: meta?.source,
|
||||||
|
fallbackReason: meta?.fallbackReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
39
apps/web/src/lib/teacher/teacher-render.test.ts
Normal file
39
apps/web/src/lib/teacher/teacher-render.test.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveResearchAssignmentDisplay,
|
||||||
|
hasMeaningfulResearchAssignment,
|
||||||
|
} from "./teacher-render";
|
||||||
|
|
||||||
|
describe("resolveResearchAssignmentDisplay", () => {
|
||||||
|
it("prefers structured researchAssignment", () => {
|
||||||
|
const display = resolveResearchAssignmentDisplay({
|
||||||
|
researchAssignment: {
|
||||||
|
steps: ["Step one", "Step two"],
|
||||||
|
expectedOutcome: "A summary",
|
||||||
|
estimatedTimeMinutes: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(display?.kind).toBe("structured");
|
||||||
|
if (display?.kind === "structured") {
|
||||||
|
expect(display.data.steps).toHaveLength(2);
|
||||||
|
expect(display.data.expectedOutcome).toBe("A summary");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to string assignment", () => {
|
||||||
|
const display = resolveResearchAssignmentDisplay({
|
||||||
|
assignment: "Write one paragraph about DNS",
|
||||||
|
});
|
||||||
|
expect(display).toEqual({
|
||||||
|
kind: "string",
|
||||||
|
text: "Write one paragraph about DNS",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns [object Object]", () => {
|
||||||
|
const display = resolveResearchAssignmentDisplay({
|
||||||
|
assignment: "[object Object]",
|
||||||
|
});
|
||||||
|
expect(hasMeaningfulResearchAssignment(display)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
42
apps/web/src/lib/teacher/teacher-render.ts
Normal file
42
apps/web/src/lib/teacher/teacher-render.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type {
|
||||||
|
TeacherLessonContent,
|
||||||
|
TeacherResearchAssignment,
|
||||||
|
} from "@adventureos/shared";
|
||||||
|
|
||||||
|
export type ResearchAssignmentDisplay =
|
||||||
|
| { kind: "string"; text: string }
|
||||||
|
| { kind: "structured"; data: TeacherResearchAssignment };
|
||||||
|
|
||||||
|
export function resolveResearchAssignmentDisplay(
|
||||||
|
content: Pick<
|
||||||
|
TeacherLessonContent,
|
||||||
|
"assignment" | "researchAssignment" | "readingSteps"
|
||||||
|
>
|
||||||
|
): ResearchAssignmentDisplay | null {
|
||||||
|
if (content.researchAssignment?.steps?.length) {
|
||||||
|
return { kind: "structured", data: content.researchAssignment };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.assignment?.trim()) {
|
||||||
|
return { kind: "string", text: content.assignment.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.readingSteps?.length) {
|
||||||
|
return {
|
||||||
|
kind: "structured",
|
||||||
|
data: { steps: content.readingSteps },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMeaningfulResearchAssignment(
|
||||||
|
display: ResearchAssignmentDisplay | null
|
||||||
|
): boolean {
|
||||||
|
if (!display) return false;
|
||||||
|
if (display.kind === "string") {
|
||||||
|
return display.text.length > 0 && display.text !== "[object Object]";
|
||||||
|
}
|
||||||
|
return display.data.steps.length > 0;
|
||||||
|
}
|
||||||
@@ -13,14 +13,19 @@ describe("teacherCreateSchema", () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts null explorationId", () => {
|
it("accepts optional difficulty and length", () => {
|
||||||
const result = teacherCreateSchema.safeParse({
|
const result = teacherCreateSchema.safeParse({
|
||||||
topic: "Rust ownership",
|
topic: "DNS",
|
||||||
explorationId: null,
|
difficulty: "intermediate",
|
||||||
|
length: "deep",
|
||||||
|
includeLibraryContext: false,
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(normalizeTeacherCreateBody(result.data).explorationId).toBeUndefined();
|
const body = normalizeTeacherCreateBody(result.data);
|
||||||
|
expect(body.difficulty).toBe("intermediate");
|
||||||
|
expect(body.length).toBe("deep");
|
||||||
|
expect(body.includeLibraryContext).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ import { z } from "zod";
|
|||||||
export const teacherCreateSchema = z.object({
|
export const teacherCreateSchema = z.object({
|
||||||
topic: z.string().min(1, "Topic is required"),
|
topic: z.string().min(1, "Topic is required"),
|
||||||
explorationId: z.union([z.string().uuid(), z.null()]).optional(),
|
explorationId: z.union([z.string().uuid(), z.null()]).optional(),
|
||||||
|
difficulty: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||||
|
length: z.enum(["short", "standard", "deep"]).optional(),
|
||||||
|
includeLibraryContext: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TeacherCreateBody = {
|
export type TeacherCreateBody = {
|
||||||
topic: string;
|
topic: string;
|
||||||
explorationId?: string;
|
explorationId?: string;
|
||||||
|
difficulty?: "beginner" | "intermediate" | "advanced";
|
||||||
|
length?: "short" | "standard" | "deep";
|
||||||
|
includeLibraryContext?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeTeacherCreateBody(
|
export function normalizeTeacherCreateBody(
|
||||||
@@ -16,6 +22,9 @@ export function normalizeTeacherCreateBody(
|
|||||||
return {
|
return {
|
||||||
topic: body.topic,
|
topic: body.topic,
|
||||||
explorationId: body.explorationId ?? undefined,
|
explorationId: body.explorationId ?? undefined,
|
||||||
|
difficulty: body.difficulty ?? "beginner",
|
||||||
|
length: body.length ?? "standard",
|
||||||
|
includeLibraryContext: body.includeLibraryContext ?? true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,16 +206,63 @@ export interface StatsOverview {
|
|||||||
xpByCategory: { category: string; amount: number }[];
|
xpByCategory: { category: string; amount: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TeacherDifficulty = "beginner" | "intermediate" | "advanced";
|
||||||
|
export type TeacherLength = "short" | "standard" | "deep";
|
||||||
|
|
||||||
|
export interface TeacherResearchAssignment {
|
||||||
|
steps: string[];
|
||||||
|
expectedOutcome?: string;
|
||||||
|
estimatedTimeMinutes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeacherHomework {
|
||||||
|
task: string;
|
||||||
|
instructions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeacherCalibreSuggestion {
|
||||||
|
title: string;
|
||||||
|
authors: string[];
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeacherQuizQuestion {
|
export interface TeacherQuizQuestion {
|
||||||
question: string;
|
question: string;
|
||||||
options: string[];
|
type?: "multiple_choice" | "short_answer";
|
||||||
|
options?: string[];
|
||||||
|
/** 0-based index for multiple choice (legacy + normalized MC) */
|
||||||
answer: number;
|
answer: number;
|
||||||
|
answerText?: string;
|
||||||
|
explanation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeacherAnswerKeyEntry {
|
||||||
|
questionIndex: number;
|
||||||
|
answer: string;
|
||||||
|
explanation?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeacherLessonContent {
|
export interface TeacherLessonContent {
|
||||||
|
title?: string;
|
||||||
|
/** Alias kept for legacy stored lessons */
|
||||||
|
introduction?: string;
|
||||||
|
overview?: string;
|
||||||
|
whyItMatters?: string;
|
||||||
|
/** Alias kept for legacy stored lessons */
|
||||||
|
objectives?: string[];
|
||||||
|
learningObjectives?: string[];
|
||||||
|
explanation?: string;
|
||||||
|
researchAssignment?: TeacherResearchAssignment;
|
||||||
|
readingSteps?: string[];
|
||||||
|
calibreSuggestions?: TeacherCalibreSuggestion[];
|
||||||
|
externalReading?: string[];
|
||||||
|
homework?: TeacherHomework;
|
||||||
flashcards: { front: string; back: string }[];
|
flashcards: { front: string; back: string }[];
|
||||||
quiz: TeacherQuizQuestion[];
|
quiz: TeacherQuizQuestion[];
|
||||||
assignment: string;
|
assignment?: string;
|
||||||
|
reflectionPrompt?: string;
|
||||||
|
answerKey?: TeacherAnswerKeyEntry[];
|
||||||
|
nextLessonSuggestion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeacherLessonData {
|
export interface TeacherLessonData {
|
||||||
@@ -226,4 +273,11 @@ export interface TeacherLessonData {
|
|||||||
explorationId?: string | null;
|
explorationId?: string | null;
|
||||||
completedNote?: string | null;
|
completedNote?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
source?: "ai" | "fallback";
|
||||||
|
fallbackReason?:
|
||||||
|
| "offline"
|
||||||
|
| "model_unavailable"
|
||||||
|
| "parse_failed"
|
||||||
|
| "generation_failed"
|
||||||
|
| "timeout";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user