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:
co-authored by
Claude Opus 5.5
parent
6fb0019cd9
commit
a47e6e0971
@@ -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,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/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user