feat(ai): my_progress tool so the assistant sees the learner's progress

Spec 9.2 has the assistant retrieve from the learner's notes and progress;
progress was missing. Reads progressRepo(db).get for the signed-in user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 04:37:25 +01:00
co-authored by Claude Opus 5.5
parent 6fb0019cd9
commit a47e6e0971
5 changed files with 41 additions and 3 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ export function buildSystemPrompt(o: {
parts.push(
`You are the course assistant for "${title}" on STCourses, a self-hosted course platform. You help one learner with this course: you answer questions, explain ideas, and quiz them when they ask.`,
[
"Ground your answers in the course material. Use the retrieved sources below and your tools (search_course, read_lesson, read_file, list_notes) to look things up rather than guessing.",
"Ground your answers in the course material. Use the retrieved sources below and your tools (search_course, read_lesson, read_file, list_notes, my_progress) to look things up rather than guessing.",
"When you rely on a source, cite its file path and lesson in square brackets, for example [lessons/04-exercise.md · Adding numbers]. If the course does not cover something, say so plainly.",
"Reference solutions are for authors only and are not available to you. Do not try to reconstruct them.",
"Keep answers short and plain. Use Markdown for code.",
@@ -14,6 +14,7 @@ export interface ToolSet {
close?(): Promise<void>;
}
type ProgressLike = { state: "not-started" | "in-progress" | "done"; positionSeconds: number | null };
type NoteLike = { id: string; lessonId: string; body: string; anchorKind: string; anchorValue: string | null };
const MAX_READ = 60_000;
const str = (v: unknown) => (typeof v === "string" ? v : "");
@@ -24,6 +25,8 @@ export function courseTools(o: {
course: LoadedCourse;
index: CourseIndex;
listNotes: (lessonId?: string) => Promise<NoteLike[]>;
/** The learner's progress in this course, keyed by lesson id (shape of `progressRepo(db).get`). */
getProgress?: () => Promise<Record<string, ProgressLike>>;
}): ToolSet {
const sources: Chunk[] = [];
const { course } = o;
@@ -54,6 +57,13 @@ export function courseTools(o: {
description: "List the learner's own notes in this course, optionally for one lesson.",
inputSchema: { type: "object", properties: { lessonId: { type: "string" } }, additionalProperties: false },
},
...(o.getProgress
? [{
name: "my_progress",
description: "The learner's progress in this course: which lessons are done, in progress (with the video position) or not started.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
}]
: []),
];
const lessonList = () =>
(course.manifest?.modules ?? []).map((m) => `${m.title}: ${m.lessons.map((l) => `${l.id} (${l.title})`).join(", ")}`).join("\n");
@@ -100,6 +110,19 @@ export function courseTools(o: {
if (!notes.length) return "The learner has no notes here yet.";
return noteChunks(course, notes).map((c) => `- ${[c.lessonTitle ?? c.lessonId, c.heading].filter(Boolean).join(", ")}: ${c.text}`).join("\n");
}
case "my_progress": {
if (!o.getProgress) return `Unknown tool "${name}".`;
const p = await o.getProgress();
const lessons = (course.manifest?.modules ?? []).flatMap((m) => m.lessons);
const done = lessons.filter((l) => p[l.id]?.state === "done").length;
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
const line = (l: { id: string; title: string }) => {
const e = p[l.id];
const state = e?.state === "done" ? "done" : e?.state === "in-progress" ? `in progress${e.positionSeconds ? `, at ${clock(e.positionSeconds)}` : ""}` : "not started";
return `- ${l.title} (${l.id}): ${state}`;
};
return [`${done} of ${lessons.length} lessons done.`, ...(course.manifest?.modules ?? []).flatMap((m) => [`${m.title}:`, ...m.lessons.map(line)])].join("\n");
}
default:
return `Unknown tool "${name}".`;
}
+3 -2
View File
@@ -3,7 +3,7 @@ import { extname, join, relative, sep } from "node:path";
import type { AiProvider, AuthUser, ChatMessage, Feature, JobContext, RequestContext, ServerContext } from "@stc/contracts";
import { canView } from "@stc/core";
import type { LoadedCourse } from "@stc/course-format";
import { aclRepo, notesRepo } from "@stc/db";
import { aclRepo, notesRepo, progressRepo } from "@stc/db";
import { buildSystemPrompt, citationsFor, readMentorText } from "./assistant/prompt";
import { combineTools, courseTools, mcpTools, type ToolSet } from "./assistant/tools";
import { runAssistant } from "./assistant/loop";
@@ -138,6 +138,7 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
const settings = aiSettingsRepo(ctx.db, createSecretBox(ctx.config.secret));
const acl = aclRepo(ctx.db);
const notes = notesRepo(ctx.db);
const progress = progressRepo(ctx.db);
const sink = opts.draftSink ?? createFsDraftSink(ctx.config.dataDir);
const makeProvider = opts.providerFactory ?? ((cfg: ProviderConfig, key: string | null) => createProvider(cfg, key, opts.fetch));
@@ -236,7 +237,7 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
send({ type: "meta", conversationId: convId, mentorOn, mentorAvailable: !!mentorText, source: resolved.source, citations: citationsFor(sources) });
let tools: ToolSet | null = null;
try {
const course_ = courseTools({ repo: ctx.courses, course, index, listNotes: (lid) => notes.list(user.id, course.id, lid) });
const course_ = courseTools({ repo: ctx.courses, course, index, listNotes: (lid) => notes.list(user.id, course.id, lid), getProgress: () => progress.get(user.id, course.id) });
const servers = mcpServersFor(course.id, (await settings.getServer()).mcpServers);
const mcp = servers.length ? await mcpTools(servers, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs }) : null;
for (const w of mcp?.warnings ?? []) send({ type: "notice", message: w });
@@ -89,3 +89,16 @@ test("unknown tools and provider errors are reported, and the step limit holds",
for await (const e of runAssistant({ provider: broken, messages: [{ role: "user", content: "x" }], tools: combineTools([t]) })) ev2.push(e);
expect(ev2).toEqual([{ type: "error", message: "boom" }]);
});
test("my_progress reports the learner's progress per lesson", async () => {
const t = courseTools({
repo, course, index, listNotes: async () => [],
getProgress: async () => ({ "m1-intro": { state: "done", positionSeconds: null, updatedAt: "2026-10-30" }, "m1-video": { state: "in-progress", positionSeconds: 75, updatedAt: "2026-10-30" } }),
});
expect(t.specs.map((s) => s.name)).toContain("my_progress");
const out = await t.run("my_progress", {});
expect(out).toContain("Introduction (m1-intro): done");
expect(out).toContain("Watch the clip (m1-video): in progress, at 1:15");
expect(out).toContain("Adding numbers (m2-text): not started");
expect(out).toMatch(/1 of \d+ lessons done/);
});
+1
View File
@@ -96,6 +96,7 @@ forEachDb("chat cites course files, applies mentor rules, and stores the convers
const system = fake.seen[0]!.messages[0]!;
expect(system.role).toBe("system");
expect(system.content).toContain("Give hints, never full solutions.");
expect(fake.seen[0]!.tools!.map((t) => t.name)).toEqual(expect.arrayContaining(["search_course", "read_lesson", "list_notes", "my_progress"]));
const convs = await (await call("GET", "/api/ai/courses/sample/conversations", "ann")).json();
expect(convs).toHaveLength(1);