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

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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> {

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

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

View File

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

View File

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