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 { 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 { parseJsonBody } from "@/lib/validation";
|
||||
import {
|
||||
@@ -9,20 +14,17 @@ import {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
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 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 result = await createTeacherLesson(user.id, body);
|
||||
// #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 lesson created',data:{source:result.source,lessonId:result.id},timestamp:Date.now(),hypothesisId:'H2'})}).catch(()=>{});
|
||||
// #endregion
|
||||
return result;
|
||||
try {
|
||||
return await createTeacherLesson(user.id, body);
|
||||
} catch (e) {
|
||||
if (e instanceof TeacherAiUnavailableError) {
|
||||
throw new ServiceUnavailableError(e.message);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,29 +3,19 @@
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import type {
|
||||
TeacherLessonContent,
|
||||
TeacherLessonData,
|
||||
TeacherDifficulty,
|
||||
TeacherLength,
|
||||
} from "@adventureos/shared";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import {
|
||||
resolveResearchAssignmentDisplay,
|
||||
type ResearchAssignmentDisplay,
|
||||
} from "@/lib/teacher/teacher-render";
|
||||
|
||||
type LessonContent = {
|
||||
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";
|
||||
};
|
||||
type Lesson = TeacherLessonData;
|
||||
|
||||
export default function TeacherPage() {
|
||||
return (
|
||||
@@ -39,12 +29,16 @@ function TeacherPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
const [topic, setTopic] = useState("");
|
||||
const [difficulty, setDifficulty] = useState<TeacherDifficulty>("beginner");
|
||||
const [length, setLength] = useState<TeacherLength>("standard");
|
||||
const [explorationId, setExplorationId] = 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 [fallbackReason, setFallbackReason] = useState<string | null>(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("");
|
||||
|
||||
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({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await fetch(`/api/teacher/${id}`);
|
||||
@@ -76,6 +81,8 @@ function TeacherPageContent() {
|
||||
setFallbackReason(data.fallbackReason ?? null);
|
||||
setTopic(data.topic);
|
||||
setRevealedQuiz({});
|
||||
setRevealedShortAnswers({});
|
||||
setShowAnswerKey(false);
|
||||
setCompletionNote(data.completedNote ?? "");
|
||||
},
|
||||
});
|
||||
@@ -87,6 +94,9 @@ function TeacherPageContent() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
topic,
|
||||
difficulty,
|
||||
length,
|
||||
includeLibraryContext: true,
|
||||
...(explorationId ? { explorationId } : {}),
|
||||
}),
|
||||
});
|
||||
@@ -102,6 +112,8 @@ function TeacherPageContent() {
|
||||
setFallbackReason(data.fallbackReason ?? null);
|
||||
setSelectedId(data.id);
|
||||
setRevealedQuiz({});
|
||||
setRevealedShortAnswers({});
|
||||
setShowAnswerKey(false);
|
||||
qc.invalidateQueries({ queryKey: ["teacher"] });
|
||||
},
|
||||
});
|
||||
@@ -120,120 +132,271 @@ function TeacherPageContent() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["teacher"] }),
|
||||
});
|
||||
|
||||
const overview = content?.overview ?? content?.introduction;
|
||||
const objectives = content?.learningObjectives ?? content?.objectives;
|
||||
const researchDisplay = content ? resolveResearchAssignmentDisplay(content) : null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
<div className="retro-window p-4 mb-6 space-y-3">
|
||||
<input
|
||||
className="retro-window-inset flex-1 p-2"
|
||||
placeholder="e.g. How Roman roads were built"
|
||||
className="retro-window-inset w-full p-2"
|
||||
placeholder="e.g. DNS, Ottoman history, cryptography"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
disabled={generate.isPending}
|
||||
/>
|
||||
<button
|
||||
className="retro-btn retro-btn-primary"
|
||||
onClick={() => generate.mutate()}
|
||||
disabled={!topic || generate.isPending}
|
||||
>
|
||||
{generate.isPending ? "Teaching..." : "Teach Me"}
|
||||
</button>
|
||||
<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
|
||||
className="retro-btn retro-btn-primary"
|
||||
onClick={() => generate.mutate()}
|
||||
disabled={!topic.trim() || generate.isPending}
|
||||
>
|
||||
{generate.isPending ? "Preparing lesson…" : "Generate Lesson"}
|
||||
</button>
|
||||
<button
|
||||
className="retro-btn"
|
||||
type="button"
|
||||
onClick={resetLessonView}
|
||||
disabled={generate.isPending}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</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 && (
|
||||
<p className="text-sm text-[var(--muted-rose)] mb-4">
|
||||
{generate.error instanceof Error
|
||||
? generate.error.message
|
||||
: "Could not generate lesson. Check AI Health."}
|
||||
: "Could not generate lesson. Check AI Health in Settings."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{contentSource === "fallback" && (
|
||||
<p className="text-sm text-[var(--warm-grey)] mb-4 italic">
|
||||
{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."
|
||||
: fallbackReason === "generation_failed"
|
||||
? "AI could not generate a lesson (check that your Ollama model is installed) — showing a basic offline template."
|
||||
: "AI is offline or unavailable — showing a basic offline lesson template."}
|
||||
? "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"
|
||||
? "AI could not generate a full lesson — showing a basic offline template."
|
||||
: "AI is offline or unavailable — showing a basic offline lesson template."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{content && (
|
||||
<div className="space-y-4">
|
||||
{content.title && (
|
||||
<h2 className="font-bold text-base">{content.title}</h2>
|
||||
)}
|
||||
{content.introduction && (
|
||||
<Section title="Introduction">
|
||||
<p className="serif text-sm">{content.introduction}</p>
|
||||
{content.title && <h2 className="font-bold text-base">{content.title}</h2>}
|
||||
|
||||
{overview && (
|
||||
<Section title="Overview">
|
||||
<p className="serif text-sm whitespace-pre-wrap">{overview}</p>
|
||||
</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">
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
{content.readingSteps && content.readingSteps.length > 0 && (
|
||||
<Section title="Reading & Research">
|
||||
<ol className="text-sm list-decimal pl-5 space-y-1">
|
||||
{content.readingSteps.map((step, i) => (
|
||||
<li key={i}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{content.explanation && (
|
||||
<Section title="Explanation">
|
||||
<p className="serif text-sm whitespace-pre-wrap">{content.explanation}</p>
|
||||
</Section>
|
||||
)}
|
||||
<Section title="Flashcards">
|
||||
<div className="grid gap-2">
|
||||
{content.flashcards.map((c, i) => (
|
||||
<Flashcard key={i} front={c.front} back={c.back} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Quiz">
|
||||
{content.quiz.map((q, i) => (
|
||||
<QuizQuestion
|
||||
key={i}
|
||||
question={q.question}
|
||||
options={q.options}
|
||||
answer={q.answer}
|
||||
selected={revealedQuiz[i] ?? null}
|
||||
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}
|
||||
|
||||
{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>
|
||||
)}
|
||||
{selectedId && (
|
||||
<div className="mt-4">
|
||||
<textarea
|
||||
className="retro-window-inset w-full p-2 text-sm min-h-[60px]"
|
||||
placeholder="What did you learn from this assignment?"
|
||||
value={completionNote}
|
||||
onChange={(e) => setCompletionNote(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="retro-btn retro-btn-primary mt-2 text-sm"
|
||||
onClick={() => complete.mutate()}
|
||||
disabled={complete.isPending}
|
||||
>
|
||||
Mark assignment complete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{content.flashcards.length > 0 && (
|
||||
<Section title="Flashcards">
|
||||
<div className="grid gap-2">
|
||||
{content.flashcards.map((c, i) => (
|
||||
<Flashcard key={i} front={c.front} back={c.back} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{content.quiz.length > 0 && (
|
||||
<Section title="Quiz">
|
||||
{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
|
||||
key={i}
|
||||
question={q.question}
|
||||
options={q.options ?? []}
|
||||
answer={q.answer}
|
||||
selected={revealedQuiz[i] ?? null}
|
||||
onSelect={(idx) => setRevealedQuiz({ ...revealedQuiz, [i]: idx })}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</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 && (
|
||||
<Section title="Complete Assignment">
|
||||
<textarea
|
||||
className="retro-window-inset w-full p-2 text-sm min-h-[60px]"
|
||||
placeholder="What did you learn from this assignment?"
|
||||
value={completionNote}
|
||||
onChange={(e) => setCompletionNote(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="retro-btn retro-btn-primary mt-2 text-sm"
|
||||
onClick={() => complete.mutate()}
|
||||
disabled={complete.isPending}
|
||||
>
|
||||
Mark assignment complete
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
</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 }) {
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
return (
|
||||
@@ -307,7 +496,7 @@ function QuizQuestion({
|
||||
if (showResult && !picked && isCorrect) cls = "text-[var(--bliss-green)]";
|
||||
return (
|
||||
<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}
|
||||
</button>
|
||||
</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 }) {
|
||||
return (
|
||||
<div className="retro-window p-4">
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
normalizeExplorationPayload,
|
||||
normalizeTeacherPayload,
|
||||
safeParseAiJson,
|
||||
isInvalidObjectString,
|
||||
} from "./ai-normalize";
|
||||
|
||||
describe("safeParseAiJson", () => {
|
||||
@@ -17,12 +18,63 @@ describe("normalizeTeacherPayload", () => {
|
||||
flashcards: [{ front: "Q", back: "A" }],
|
||||
quiz: [{ question: "Pick one", options: ["a", "b"], answer: "1" }],
|
||||
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);
|
||||
});
|
||||
|
||||
it("flattens object assignment instead of [object Object]", () => {
|
||||
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(normalized).toMatchObject({
|
||||
assignment: "Read for 20 minutes",
|
||||
quiz: [{ answer: 1 }],
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeExplorationPayload", () => {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { parseAiJson } from "./parse-json";
|
||||
import {
|
||||
extractText,
|
||||
extractStringList,
|
||||
isObjectObject,
|
||||
normalizeQuizAnswer,
|
||||
} from "../teacher/extract-text";
|
||||
|
||||
export function safeParseAiJson(raw: string): unknown | null {
|
||||
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 {
|
||||
if (!data || typeof data !== "object") return data;
|
||||
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)
|
||||
? o.flashcards
|
||||
.map((c) => {
|
||||
if (!c || typeof c !== "object") return null;
|
||||
const card = c as Record<string, unknown>;
|
||||
const front = String(card.front ?? "").trim();
|
||||
const back = String(card.back ?? "").trim();
|
||||
const front = extractText(card.front);
|
||||
const back = extractText(card.back);
|
||||
if (!front && !back) return null;
|
||||
return { front, back };
|
||||
})
|
||||
@@ -30,36 +174,90 @@ export function normalizeTeacherPayload(data: unknown): unknown {
|
||||
.map((q) => {
|
||||
if (!q || typeof q !== "object") return null;
|
||||
const item = q as Record<string, unknown>;
|
||||
const options = Array.isArray(item.options)
|
||||
? item.options.map((opt) => String(opt))
|
||||
: [];
|
||||
if (options.length === 0) return null;
|
||||
let answer = Number(item.answer ?? 0);
|
||||
if (!Number.isFinite(answer)) answer = 0;
|
||||
if (answer < 0 || answer >= options.length) answer = 0;
|
||||
const question = extractText(item.question);
|
||||
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 (type === "multiple_choice" && options.length === 0) return null;
|
||||
|
||||
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 {
|
||||
question: String(item.question ?? "Question"),
|
||||
options,
|
||||
answer,
|
||||
question,
|
||||
type,
|
||||
options: type === "multiple_choice" ? options : undefined,
|
||||
answer: answerIndex,
|
||||
answerText,
|
||||
explanation: explanation || undefined,
|
||||
};
|
||||
})
|
||||
.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 {
|
||||
questionIndex: Number.isFinite(questionIndex) ? questionIndex : idx,
|
||||
answer,
|
||||
explanation: extractText(e.explanation) || undefined,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
: quiz.map((q, i) => ({
|
||||
questionIndex: i,
|
||||
answer: q.answerText ?? String(q.answer),
|
||||
explanation: q.explanation,
|
||||
}));
|
||||
|
||||
const calibreSuggestions = normalizeCalibreSuggestions(
|
||||
o.calibreSuggestions ?? o.librarySuggestions ?? o.booksFromLibrary
|
||||
);
|
||||
|
||||
const safeAssignment =
|
||||
assignmentText && !isObjectObject(assignmentText) ? assignmentText : undefined;
|
||||
|
||||
return {
|
||||
title: o.title != null ? String(o.title) : undefined,
|
||||
introduction: o.introduction != null ? String(o.introduction) : undefined,
|
||||
objectives: Array.isArray(o.objectives)
|
||||
? o.objectives.map(String).filter(Boolean)
|
||||
: undefined,
|
||||
readingSteps: Array.isArray(o.readingSteps)
|
||||
? o.readingSteps.map(String).filter(Boolean)
|
||||
: undefined,
|
||||
reflectionPrompt:
|
||||
o.reflectionPrompt != null ? String(o.reflectionPrompt) : undefined,
|
||||
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,
|
||||
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) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const e = item as Record<string, unknown>;
|
||||
const title = String(e.title ?? "").trim();
|
||||
const title = extractText(e.title);
|
||||
if (!title) return null;
|
||||
const minutes = Number(e.minutes ?? e.duration ?? 20);
|
||||
return {
|
||||
title,
|
||||
hook: String(e.hook ?? e.description ?? e.reason ?? title).trim(),
|
||||
category: String(e.category ?? "general").trim(),
|
||||
hook: extractText(e.hook ?? e.description ?? e.reason ?? title),
|
||||
category: extractText(e.category) || "general",
|
||||
minutes: Number.isFinite(minutes) && minutes > 0 ? minutes : 20,
|
||||
};
|
||||
})
|
||||
@@ -98,22 +296,6 @@ export function debugAiLog(
|
||||
data: Record<string, unknown>
|
||||
) {
|
||||
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 {
|
||||
@@ -122,3 +304,7 @@ export function isAiTimeoutError(error: unknown): boolean {
|
||||
if (error.message.toLowerCase().includes("timeout")) return true;
|
||||
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",
|
||||
description: "Teacher homework assignments",
|
||||
category: "template",
|
||||
body: `Create topic-specific learning materials about: {{topic}}
|
||||
Include a short introduction, learning objectives, reading/research steps, homework, reflection prompt, flashcards, and a quiz.
|
||||
Return JSON only:
|
||||
body: `Create a useful mini-lesson about: {{topic}}
|
||||
Difficulty: {{difficulty}}. Length: {{length}}.
|
||||
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": "...",
|
||||
"introduction": "...",
|
||||
"objectives": ["..."],
|
||||
"readingSteps": ["..."],
|
||||
"reflectionPrompt": "...",
|
||||
"title": "clear lesson title",
|
||||
"overview": "2-4 sentences introducing the topic",
|
||||
"whyItMatters": "2-3 sentences on real-world relevance",
|
||||
"learningObjectives": ["3-5 specific objectives"],
|
||||
"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" }],
|
||||
"quiz": [{ "question", "options": ["a","b","c","d"], "answer": 0 }],
|
||||
"assignment": "..."
|
||||
"quiz": [{
|
||||
"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: {
|
||||
name: "Reflection Prompt",
|
||||
@@ -114,7 +139,9 @@ Be concise, encouraging, and practical. Output valid JSON only.`,
|
||||
name: "Teacher Instructions",
|
||||
description: "Teacher content persona",
|
||||
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: "exercise_summary", description: "Exercise activity" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import { and, eq, desc, isNull } from "drizzle-orm";
|
||||
import { db, aiChatSessions, aiChatMessages } from "../db";
|
||||
import { buildMentorContext, formatContextForPrompt } from "./ai-context";
|
||||
import { generateChatReply, getAiAvailability } from "./ai";
|
||||
import {
|
||||
formatLibraryContextForPrompt,
|
||||
getLibraryContextForTopic,
|
||||
shouldIncludeLibraryContextInChat,
|
||||
} from "./library-context";
|
||||
import { listMemories, getProfileSummary } from "./ai-memory";
|
||||
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
|
||||
|
||||
@@ -66,6 +71,16 @@ export async function sendChatMessage(userId: string, sessionId: string, content
|
||||
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 offline = false;
|
||||
let memoryIdsUsed = ctx.memoryIds;
|
||||
@@ -81,7 +96,7 @@ export async function sendChatMessage(userId: string, sessionId: string, content
|
||||
|
||||
const generated = await generateChatReply(userId, {
|
||||
userMessage: content,
|
||||
contextBlock: formatContextForPrompt(ctx),
|
||||
contextBlock,
|
||||
history,
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ vi.mock("./ai-config", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./ai-templates", () => ({
|
||||
getTemplateBody: vi.fn(async () => "topic: {{topic}}"),
|
||||
getTemplateBody: vi.fn(async () => "topic: {{topic}} difficulty: {{difficulty}}"),
|
||||
}));
|
||||
|
||||
vi.mock("../ai/provider-registry", () => ({
|
||||
@@ -16,12 +16,30 @@ vi.mock("../ai/provider-registry", () => ({
|
||||
}));
|
||||
|
||||
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 { 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", () => {
|
||||
it("coerces string quiz answers to numbers", () => {
|
||||
const parsed = teacherSchema.parse({
|
||||
@@ -32,18 +50,34 @@ describe("teacherSchema", () => {
|
||||
expect(parsed.quiz[0].answer).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts optional structured fields", () => {
|
||||
it("accepts extended structured fields", () => {
|
||||
const parsed = teacherSchema.parse({
|
||||
title: "Roman Roads",
|
||||
introduction: "A brief intro",
|
||||
objectives: ["Learn basics"],
|
||||
readingSteps: ["Find a source"],
|
||||
reflectionPrompt: "What surprised you?",
|
||||
title: "DNS Basics",
|
||||
overview: "Intro to DNS",
|
||||
whyItMatters: "Used everywhere",
|
||||
learningObjectives: ["Understand DNS"],
|
||||
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" }],
|
||||
quiz: [{ question: "Q?", options: ["a", "b"], answer: 0 }],
|
||||
quiz: [
|
||||
{
|
||||
question: "Q?",
|
||||
type: "multiple_choice",
|
||||
options: ["a", "b"],
|
||||
answer: 0,
|
||||
answerText: "a",
|
||||
},
|
||||
],
|
||||
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 () => {
|
||||
vi.mocked(getAiBehaviorConfig).mockResolvedValue({
|
||||
enabled: true,
|
||||
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(getAiBehaviorConfig).mockResolvedValue(onlineAvailability);
|
||||
vi.mocked(getAiProviderConfig).mockResolvedValue(onlineProviderConfig);
|
||||
vi.mocked(getProvider).mockReturnValue({
|
||||
type: "ollama",
|
||||
healthCheck: vi.fn(async () => ({ status: "online" as const })),
|
||||
listModels: vi.fn(async () => []),
|
||||
generateText: vi.fn(async () => ({
|
||||
text: JSON.stringify({
|
||||
overview: "Intro",
|
||||
explanation: "Details here",
|
||||
flashcards: [{ front: "What is Rust?", back: "A systems language" }],
|
||||
quiz: [
|
||||
{
|
||||
@@ -91,7 +113,7 @@ describe("generateTeacherContent", () => {
|
||||
answer: 0,
|
||||
},
|
||||
],
|
||||
assignment: "Read chapter 1",
|
||||
researchAssignment: { steps: ["Read chapter 1"], expectedOutcome: "Notes" },
|
||||
}),
|
||||
model: "test",
|
||||
latencyMs: 10,
|
||||
@@ -99,52 +121,30 @@ describe("generateTeacherContent", () => {
|
||||
});
|
||||
|
||||
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.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({
|
||||
...onlineAvailability,
|
||||
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 result = await generateTeacherContent("Stoicism", "user-1");
|
||||
expect(result.source).toBe("fallback");
|
||||
expect(result.content.assignment).toContain("Stoicism");
|
||||
await expect(generateTeacherContent("Stoicism", "user-1")).rejects.toThrow(
|
||||
"AI is unavailable"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns fallback when AI JSON is invalid and strictMode is off", async () => {
|
||||
vi.mocked(getAiBehaviorConfig).mockResolvedValue({
|
||||
enabled: true,
|
||||
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(getAiBehaviorConfig).mockResolvedValue(onlineAvailability);
|
||||
vi.mocked(getAiProviderConfig).mockResolvedValue(onlineProviderConfig);
|
||||
vi.mocked(getProvider).mockReturnValue({
|
||||
type: "ollama",
|
||||
healthCheck: vi.fn(async () => ({ status: "online" as const })),
|
||||
@@ -159,5 +159,6 @@ describe("generateTeacherContent", () => {
|
||||
const { generateTeacherContent } = await import("./ai");
|
||||
const result = await generateTeacherContent("Stoicism", "user-1");
|
||||
expect(result.source).toBe("fallback");
|
||||
expect(result.fallbackReason).toBe("parse_failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
pickRandomQuests,
|
||||
} from "@adventureos/shared";
|
||||
import { getProvider } from "../ai/provider-registry";
|
||||
import type { TeacherDifficulty, TeacherLength } from "@adventureos/shared";
|
||||
import {
|
||||
debugAiLog,
|
||||
isAiTimeoutError,
|
||||
isInvalidObjectString,
|
||||
normalizeExplorationPayload,
|
||||
normalizeTeacherPayload,
|
||||
safeParseAiJson,
|
||||
@@ -55,21 +57,59 @@ const mentorSchema = z.object({
|
||||
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({
|
||||
title: z.string().optional(),
|
||||
introduction: z.string().optional(),
|
||||
overview: z.string().optional(),
|
||||
whyItMatters: 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(),
|
||||
calibreSuggestions: z.array(teacherCalibreSuggestionSchema).optional(),
|
||||
externalReading: z.array(z.string()).optional(),
|
||||
homework: teacherHomeworkSchema.optional(),
|
||||
reflectionPrompt: z.string().optional(),
|
||||
flashcards: z.array(z.object({ front: z.string(), back: z.string() })),
|
||||
quiz: z.array(
|
||||
z.object({
|
||||
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(),
|
||||
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>;
|
||||
@@ -174,10 +214,16 @@ async function aiGenerate(
|
||||
}
|
||||
|
||||
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 (
|
||||
content.assignment.trim().length > 0 ||
|
||||
hasAssignment ||
|
||||
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 {
|
||||
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: [
|
||||
{
|
||||
front: `What is ${topic}?`,
|
||||
back: "Explore this topic through reading and observation.",
|
||||
back: "A topic worth exploring through reading and observation.",
|
||||
},
|
||||
],
|
||||
quiz: [
|
||||
{
|
||||
question: `Which approach helps you learn about ${topic}?`,
|
||||
type: "multiple_choice",
|
||||
options: ["Curious reading", "Giving up", "Ignoring it", "Rushing"],
|
||||
answer: 0,
|
||||
answerText: "Curious reading",
|
||||
},
|
||||
],
|
||||
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) {
|
||||
const uid = userId ?? (await getUserId());
|
||||
const raw = await aiGenerate(
|
||||
@@ -321,7 +402,7 @@ export async function generateMentorReview(context: Record<string, unknown>, use
|
||||
export async function generateTeacherContent(
|
||||
topic: string,
|
||||
userId?: string,
|
||||
personalContext?: string
|
||||
options: TeacherGenerationOptions = {}
|
||||
): Promise<TeacherGenerationResult> {
|
||||
const trimmed = topic.trim();
|
||||
if (!trimmed) {
|
||||
@@ -332,12 +413,28 @@ export async function generateTeacherContent(
|
||||
const behavior = await getAiBehaviorConfig(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(
|
||||
uid,
|
||||
"homework_generation",
|
||||
"system_core",
|
||||
"system_teacher",
|
||||
{ topic: trimmed, context: personalContext ?? "" }
|
||||
{
|
||||
topic: trimmed,
|
||||
context: combinedContext,
|
||||
library_context: options.libraryContext ?? "Library not available.",
|
||||
difficulty,
|
||||
length,
|
||||
}
|
||||
);
|
||||
|
||||
if (raw) {
|
||||
@@ -350,20 +447,29 @@ export async function generateTeacherContent(
|
||||
canUse: availability.canUse,
|
||||
rawLen: raw.length,
|
||||
});
|
||||
if (behavior.strictMode && availability.canUse && availability.online) {
|
||||
if (behavior.strictMode) {
|
||||
throw new Error("AI returned invalid lesson content. Try again.");
|
||||
}
|
||||
} else {
|
||||
debugAiLog("ai.ts:generateTeacherContent", "no raw response", {
|
||||
online: availability.online,
|
||||
canUse: availability.canUse,
|
||||
});
|
||||
if (behavior.strictMode && availability.canUse && availability.online) {
|
||||
throw new Error("AI generation failed");
|
||||
}
|
||||
return {
|
||||
content: teacherFallback(trimmed),
|
||||
source: "fallback",
|
||||
fallbackReason: "parse_failed",
|
||||
};
|
||||
}
|
||||
|
||||
return { content: teacherFallback(trimmed), source: "fallback", fallbackReason: lastAiFallbackReason ?? "generation_failed" };
|
||||
debugAiLog("ai.ts:generateTeacherContent", "no raw response", {
|
||||
online: availability.online,
|
||||
canUse: availability.canUse,
|
||||
});
|
||||
if (behavior.strictMode) {
|
||||
throw new Error("AI generation failed");
|
||||
}
|
||||
|
||||
return {
|
||||
content: teacherFallback(trimmed),
|
||||
source: "fallback",
|
||||
fallbackReason: lastAiFallbackReason ?? "generation_failed",
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
formatLibraryContextForPrompt,
|
||||
getLibraryContextForTopic,
|
||||
} from "./library-context";
|
||||
import {
|
||||
listTeacherContent,
|
||||
findTeacherContentById,
|
||||
insertTeacherContent,
|
||||
updateTeacherContent,
|
||||
} from "@/lib/repositories/teacher.repository";
|
||||
import { wrapTeacherContent, unwrapTeacherContent } from "@/lib/teacher/teacher-content";
|
||||
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) {
|
||||
return listTeacherContent(userId);
|
||||
const rows = await listTeacherContent(userId);
|
||||
return rows.map(mapTeacherRow);
|
||||
}
|
||||
|
||||
export async function getTeacherLesson(userId: string, id: string) {
|
||||
const row = await findTeacherContentById(id);
|
||||
if (!row || row.userId !== userId) return null;
|
||||
return row;
|
||||
return mapTeacherRow(row);
|
||||
}
|
||||
|
||||
export async function createTeacherLesson(
|
||||
@@ -27,18 +42,42 @@ export async function createTeacherLesson(
|
||||
throw new Error("Topic is required");
|
||||
}
|
||||
|
||||
const ctx = await buildMentorContext(userId, { feature: "teacher", topic, userMessage: topic });
|
||||
const { content, source, fallbackReason } = await generateTeacherContent(topic, userId, formatContextForPrompt(ctx));
|
||||
const ctx = await buildMentorContext(userId, {
|
||||
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({
|
||||
userId,
|
||||
topic,
|
||||
explorationId: input.explorationId,
|
||||
content,
|
||||
content: storedContent as Record<string, unknown>,
|
||||
status: "active",
|
||||
});
|
||||
return { ...row, source, fallbackReason };
|
||||
return { ...mapTeacherRow({ ...row, content: storedContent as Record<string, unknown> }) };
|
||||
}
|
||||
|
||||
export { TeacherAiUnavailableError };
|
||||
|
||||
export async function completeTeacherLesson(
|
||||
userId: string,
|
||||
id: string,
|
||||
@@ -53,5 +92,5 @@ export async function completeTeacherLesson(
|
||||
completedAt: new Date(),
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
it("accepts null explorationId", () => {
|
||||
it("accepts optional difficulty and length", () => {
|
||||
const result = teacherCreateSchema.safeParse({
|
||||
topic: "Rust ownership",
|
||||
explorationId: null,
|
||||
topic: "DNS",
|
||||
difficulty: "intermediate",
|
||||
length: "deep",
|
||||
includeLibraryContext: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
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({
|
||||
topic: z.string().min(1, "Topic is required"),
|
||||
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 = {
|
||||
topic: string;
|
||||
explorationId?: string;
|
||||
difficulty?: "beginner" | "intermediate" | "advanced";
|
||||
length?: "short" | "standard" | "deep";
|
||||
includeLibraryContext?: boolean;
|
||||
};
|
||||
|
||||
export function normalizeTeacherCreateBody(
|
||||
@@ -16,6 +22,9 @@ export function normalizeTeacherCreateBody(
|
||||
return {
|
||||
topic: body.topic,
|
||||
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 }[];
|
||||
}
|
||||
|
||||
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 {
|
||||
question: string;
|
||||
options: string[];
|
||||
type?: "multiple_choice" | "short_answer";
|
||||
options?: string[];
|
||||
/** 0-based index for multiple choice (legacy + normalized MC) */
|
||||
answer: number;
|
||||
answerText?: string;
|
||||
explanation?: string;
|
||||
}
|
||||
|
||||
export interface TeacherAnswerKeyEntry {
|
||||
questionIndex: number;
|
||||
answer: string;
|
||||
explanation?: string;
|
||||
}
|
||||
|
||||
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 }[];
|
||||
quiz: TeacherQuizQuestion[];
|
||||
assignment: string;
|
||||
assignment?: string;
|
||||
reflectionPrompt?: string;
|
||||
answerKey?: TeacherAnswerKeyEntry[];
|
||||
nextLessonSuggestion?: string;
|
||||
}
|
||||
|
||||
export interface TeacherLessonData {
|
||||
@@ -226,4 +273,11 @@ export interface TeacherLessonData {
|
||||
explorationId?: string | null;
|
||||
completedNote?: string | null;
|
||||
createdAt: string;
|
||||
source?: "ai" | "fallback";
|
||||
fallbackReason?:
|
||||
| "offline"
|
||||
| "model_unavailable"
|
||||
| "parse_failed"
|
||||
| "generation_failed"
|
||||
| "timeout";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user