From a47e6e09717b99b1f74f021fc75e84a8042d0802 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 04:37:25 +0100 Subject: [PATCH] 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 --- packages/feature-ai/src/assistant/prompt.ts | 2 +- packages/feature-ai/src/assistant/tools.ts | 23 +++++++++++++++++++++ packages/feature-ai/src/feature.ts | 5 +++-- packages/feature-ai/test/assistant.test.ts | 13 ++++++++++++ packages/feature-ai/test/feature.test.ts | 1 + 5 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/feature-ai/src/assistant/prompt.ts b/packages/feature-ai/src/assistant/prompt.ts index c70e0e7..c1b2496 100644 --- a/packages/feature-ai/src/assistant/prompt.ts +++ b/packages/feature-ai/src/assistant/prompt.ts @@ -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.", diff --git a/packages/feature-ai/src/assistant/tools.ts b/packages/feature-ai/src/assistant/tools.ts index 142cc61..8ab1118 100644 --- a/packages/feature-ai/src/assistant/tools.ts +++ b/packages/feature-ai/src/assistant/tools.ts @@ -14,6 +14,7 @@ export interface ToolSet { close?(): Promise; } +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; + /** The learner's progress in this course, keyed by lesson id (shape of `progressRepo(db).get`). */ + getProgress?: () => Promise>; }): 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}".`; } diff --git a/packages/feature-ai/src/feature.ts b/packages/feature-ai/src/feature.ts index f74aa04..625fa23 100644 --- a/packages/feature-ai/src/feature.ts +++ b/packages/feature-ai/src/feature.ts @@ -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 }); diff --git a/packages/feature-ai/test/assistant.test.ts b/packages/feature-ai/test/assistant.test.ts index 7b6b8f0..8b533d4 100644 --- a/packages/feature-ai/test/assistant.test.ts +++ b/packages/feature-ai/test/assistant.test.ts @@ -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/); +}); diff --git a/packages/feature-ai/test/feature.test.ts b/packages/feature-ai/test/feature.test.ts index 6a8c700..8b657c6 100644 --- a/packages/feature-ai/test/feature.test.ts +++ b/packages/feature-ai/test/feature.test.ts @@ -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);