ai fixes
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-06-26 14:44:54 +01:00
parent 25d62ce723
commit 2c4c9b0595
21 changed files with 1553 additions and 259 deletions

View 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),
};
});
}

View File

@@ -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;
}
}); });
} }

View File

@@ -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,120 +132,271 @@ 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}
/> />
<button <div className="flex flex-wrap gap-3 text-sm">
className="retro-btn retro-btn-primary" <label className="flex items-center gap-2">
onClick={() => generate.mutate()} <span className="text-[var(--warm-grey)]">Difficulty</span>
disabled={!topic || generate.isPending} <select
> className="retro-window-inset p-1"
{generate.isPending ? "Teaching..." : "Teach Me"} value={difficulty}
</button> 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> </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 === "generation_failed" : fallbackReason === "parse_failed"
? "AI could not generate a lesson (check that your Ollama model is installed) — showing a basic offline template." ? "AI responded but the lesson could not be parsed — showing a basic template."
: "AI is offline or unavailable — showing a basic offline lesson 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> </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>
<li key={i}>{step}</li>
))}
</ol>
</Section> </Section>
)} )}
<Section title="Flashcards">
<div className="grid gap-2"> {researchDisplay && (
{content.flashcards.map((c, i) => ( <Section title="Research Assignment">
<Flashcard key={i} front={c.front} back={c.back} /> <ResearchAssignmentBlock display={researchDisplay} />
))} </Section>
</div> )}
</Section>
<Section title="Quiz"> <Section title="Books From Your Library">
{content.quiz.map((q, i) => ( {content.calibreSuggestions && content.calibreSuggestions.length > 0 ? (
<QuizQuestion <ul className="text-sm space-y-2">
key={i} {content.calibreSuggestions.map((book, i) => (
question={q.question} <li key={i}>
options={q.options} <span className="font-bold">{book.title}</span>
answer={q.answer} {book.authors.length > 0 && (
selected={revealedQuiz[i] ?? null} <span className="text-[var(--warm-grey)]"> {book.authors.join(", ")}</span>
onSelect={(idx) => setRevealedQuiz({ ...revealedQuiz, [i]: idx })} )}
/> <p className="text-sm mt-0.5">{book.reason}</p>
))} </li>
</Section> ))}
<Section title="Research Assignment"> </ul>
<p className="serif">{content.assignment}</p> ) : (
{content.reflectionPrompt && ( <p className="text-sm text-[var(--warm-grey)] italic">
<p className="text-sm italic mt-2 text-[var(--warm-grey)]"> No matching library books were found for this topic.
Reflection: {content.reflectionPrompt}
</p> </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> </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> </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">

View File

@@ -3,6 +3,7 @@ import {
normalizeExplorationPayload, normalizeExplorationPayload,
normalizeTeacherPayload, normalizeTeacherPayload,
safeParseAiJson, safeParseAiJson,
isInvalidObjectString,
} from "./ai-normalize"; } from "./ai-normalize";
describe("safeParseAiJson", () => { describe("safeParseAiJson", () => {
@@ -17,12 +18,63 @@ 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);
});
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({ expect(isInvalidObjectString(normalized.assignment as string)).toBe(false);
assignment: "Read for 20 minutes", });
quiz: [{ answer: 1 }],
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", () => { describe("normalizeExplorationPayload", () => {

View File

@@ -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;
: [];
if (options.length === 0) return null; const typeRaw = extractText(item.type).toLowerCase();
let answer = Number(item.answer ?? 0); const type =
if (!Number.isFinite(answer)) answer = 0; typeRaw === "short_answer" || typeRaw === "short answer"
if (answer < 0 || answer >= options.length) answer = 0; ? ("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 { 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 {
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 { return {
title: o.title != null ? String(o.title) : undefined, title: extractText(o.title) || undefined,
introduction: o.introduction != null ? String(o.introduction) : undefined, overview: overview || undefined,
objectives: Array.isArray(o.objectives) introduction: overview || undefined,
? o.objectives.map(String).filter(Boolean) whyItMatters: whyItMatters || undefined,
: undefined, learningObjectives: objectives.length ? objectives : undefined,
readingSteps: Array.isArray(o.readingSteps) objectives: objectives.length ? objectives : undefined,
? o.readingSteps.map(String).filter(Boolean) explanation: explanation || undefined,
: undefined, ...researchFields,
reflectionPrompt: calibreSuggestions,
o.reflectionPrompt != null ? String(o.reflectionPrompt) : undefined, 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);
}

View File

@@ -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" },
]; ];

View File

@@ -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,
}); });

View File

@@ -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");
}); });
}); });

View File

@@ -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 {
debugAiLog("ai.ts:generateTeacherContent", "no raw response", { content: teacherFallback(trimmed),
online: availability.online, source: "fallback",
canUse: availability.canUse, fallbackReason: "parse_failed",
}); };
if (behavior.strictMode && availability.canUse && availability.online) {
throw new Error("AI generation 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> { export async function isOllamaAvailable(): Promise<boolean> {

View 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);
});
});

View 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));
}

View File

@@ -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);
} }

View 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);
});
});

View 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 };
}

View 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");
});
});

View 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,
};
}

View 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);
});
});

View 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;
}

View File

@@ -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);
} }
}); });
}); });

View File

@@ -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,
}; };
} }

View File

@@ -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";
} }