Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion server/services/course-content/embedded-tutor-annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,44 @@ const learningObjectiveSchema = z.preprocess(
}),
);

const SAFE_TEXT = (max: number) => z.string().trim().min(1)
.refine((value) => [...value].length <= max, `Text is longer than ${max} characters`)
.refine((value) => !CONTROL_CHARACTER.test(value), "Text contains a control character")
.refine((value) => !/https?:\/\//i.test(value), "URLs are not allowed in Tutor annotations");

const focusQuestionSchema = z.object({
kind: z.enum(["recall", "concept", "application", "prediction", "transfer"]),
text: SAFE_TEXT(500),
}).strict();

/** A teacher-authored focus area: a few questions the Tutor prefers to ask for this Example. */
const focusAreaSchema = z.object({
id: z.string().regex(SAFE_TUTOR_ID),
title: SAFE_TEXT(160),
objective: SAFE_TEXT(500),
questions: z.array(focusQuestionSchema).min(1).max(6),
}).strict();

export type ExampleFocusArea = z.infer<typeof focusAreaSchema>;

export const embeddedTutorAnnotationSchema = z.object({
schemaVersion: z.literal(1),
schemaVersion: z.union([z.literal(1), z.literal(2)]),
focus: z.array(focusAreaSchema).min(1).max(8).optional(),
exclusive: z.boolean().optional(),
topics: z.array(z.string().regex(SAFE_TUTOR_ID)).min(1).max(32).optional(),
primaryTopic: z.string().regex(SAFE_TUTOR_ID).optional(),
strategy: z.string().regex(SAFE_TUTOR_ID).optional(),
learningObjectives: z.array(learningObjectiveSchema).min(1).max(10).optional(),
}).strict().superRefine((value, context) => {
if (value.schemaVersion === 1 && (value.focus !== undefined || value.exclusive !== undefined)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["focus"], message: "focus and exclusive require schemaVersion 2" });
}
if (value.exclusive === true && value.focus === undefined) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["exclusive"], message: "exclusive requires focus" });
}
if (value.focus !== undefined && new Set(value.focus.map(({ id }) => id)).size !== value.focus.length) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["focus"], message: "focus ids must be unique" });
}
if (value.primaryTopic !== undefined && value.topics === undefined) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ["primaryTopic"], message: "primaryTopic requires topics" });
}
Expand Down
37 changes: 37 additions & 0 deletions server/services/course-content/inline-focus-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { stripComments } from "@shared/parser-patterns";
import type { ExampleTutorAnnotation } from "./embedded-tutor-annotation";
import type { TutorContentQualityIssue } from "./tutor-quality-validator";

const CODE_TERM = /`([^`]+)`/g;

/**
* Teacher-authored focus questions put every code term in backticks. Each such term must occur in
* the Example's code (comments excluded); otherwise the Tutor would ask about something the sketch
* does not contain, such as `long` for an `unsigned long` sketch.
*/
export function validateInlineFocus(
examples: readonly { readonly id: string; readonly code: string; readonly annotation?: ExampleTutorAnnotation }[],
): TutorContentQualityIssue[] {
const issues: TutorContentQualityIssue[] = [];
for (const example of examples) {
const focus = example.annotation?.focus;
if (focus === undefined) continue;
const code = stripComments(example.code);
for (const area of focus) {
for (const [index, question] of area.questions.entries()) {
for (const match of question.text.matchAll(CODE_TERM)) {
const term = (match[1] ?? "").trim();
if (term.length > 0 && !code.includes(term)) {
issues.push({
code: "inline-focus-term-not-in-sketch",
message: `Inline focus ${area.id} question ${index + 1} names \`${term}\`, which does not occur in the sketch`,
exampleId: example.id,
conceptId: area.id,
});
}
}
}
}
}
return issues;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import { parse as parseYaml } from "yaml";
import { CourseContentLoader, type LoadedCourseContentSnapshot } from "./course-content-loader";
import { validateInlineFocus } from "./inline-focus-validator";
import { tutorQualityCasesSchema } from "./tutor-quality-schema";
import { BUILT_IN_TUTOR_STRATEGY, type EffectiveTutorStrategy } from "../tutor/strategy/effective-tutor-strategy";
import {
Expand Down Expand Up @@ -40,6 +41,10 @@ export async function validateTutorCourseContentDirectory(directory: string): Pr
return [
...validateTutorContentQuality(loaded.tutor.topics, resolution.cases),
...validateExampleTopicActivations(loaded.tutor.topics, catalogExamples(loaded)),
...validateInlineFocus(loaded.examples.flatMap((example) => {
const main = example.files.find(({ name }) => name === example.main);
return main ? [{ id: example.id, code: main.content, annotation: example.tutorAnnotation }] : [];
})),
];
} catch {
return [directoryIssue("invalid-quality-cases", "Tutor quality case manifest could not be loaded")];
Expand Down
1 change: 1 addition & 0 deletions server/services/course-content/tutor-quality-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type TutorContentQualityIssueCode =
| "learn-content-exhausted"
| "deepen-content-exhausted"
| "unmasterable-example-activation"
| "inline-focus-term-not-in-sketch"
| "invalid-course-content-bundle"
| "invalid-quality-cases"
| "quality-case-example-not-found";
Expand Down
22 changes: 17 additions & 5 deletions server/services/tutor/curriculum-tutor-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type Observation,
type TopicClassification,
} from "./curriculum/learning-planner";
import { buildInlineFocusTopic } from "./curriculum/inline-focus-topic";
import { DefaultSketchFactExtractor, type SketchFactExtractor } from "./curriculum/sketch-facts";
import { DefaultTopicMatcher, type TopicMatch, type TopicMatcher } from "./curriculum/topic-matcher";
import type {
Expand Down Expand Up @@ -199,12 +200,15 @@ export class CurriculumTutorAdapter implements TutorPlanningExtension {
* revision) and changes nothing.
*/
private resolveMatch(code: string, history: readonly TutorDialogTurn[], difficulty: TutorDifficulty, snapshot: TutorPlanningContentContext, requestedExampleId?: string): MatchResolution | null {
if (snapshot.tutor?.status !== "valid" || snapshot.tutor.topics.length === 0) return null;
const facts = this.factExtractor.extract(code);
const matches = this.topicMatcher.match(snapshot.tutor.topics, facts);
const exampleContextId = snapshot.exampleId ?? requestedExampleId;
const annotation = exampleContextId === undefined ? undefined : snapshot.exampleTutorAnnotation;
const orderedMatches = orderTopicMatches(matches, annotation);
if (snapshot.tutor?.status !== "valid" || (snapshot.tutor.topics.length === 0 && annotation?.focus === undefined)) return null;
const facts = this.factExtractor.extract(code);
const inline = inlineFocusMatch(exampleContextId, annotation);
const matches = annotation?.exclusive === true && inline
? []
: this.topicMatcher.match(snapshot.tutor.topics, facts);
const orderedMatches = [...(inline ? [inline] : []), ...orderTopicMatches(matches, annotation)];
const state = snapshot.progressionState ?? createTutorProgressionState(snapshot.revision);
const resetsRevision = state.revision !== snapshot.revision;
const view = resetsRevision ? createTutorProgressionState(snapshot.revision) : state;
Expand Down Expand Up @@ -260,7 +264,9 @@ export class CurriculumTutorAdapter implements TutorPlanningExtension {
if (!snapshot || !code || !state?.activeTopicId || !state.phase || state.phase === "LEARN") return "LEARN";
if (state.revision !== snapshot.revision || snapshot.tutor?.status !== "valid") return "LEARN";
const facts = this.factExtractor.extract(code);
return this.topicMatcher.match(snapshot.tutor.topics, facts).some(({ topic }) => topic.id === state.activeTopicId)
const annotation = snapshot.exampleId === undefined ? undefined : snapshot.exampleTutorAnnotation;
const inline = inlineFocusMatch(snapshot.exampleId, annotation);
return [...(inline ? [inline] : []), ...this.topicMatcher.match(snapshot.tutor.topics, facts)].some(({ topic }) => topic.id === state.activeTopicId)
? state.phase
: "LEARN";
}
Expand Down Expand Up @@ -435,6 +441,12 @@ type AdapterContext = {
readonly blocked?: TutorPlanningBlocked;
};

/** The teacher-authored focus of an Example always comes first and needs no sketch-fact activation. */
function inlineFocusMatch(exampleId: string | undefined, annotation: ExampleTutorAnnotation | undefined): TopicMatch | undefined {
if (exampleId === undefined || annotation?.focus === undefined) return undefined;
return { topic: buildInlineFocusTopic(exampleId, annotation.focus), score: Number.MAX_SAFE_INTEGER };
}

function orderTopicMatches(matches: readonly TopicMatch[], annotation?: ExampleTutorAnnotation): readonly TopicMatch[] {
const byId = new Map(matches.map((match) => [match.topic.id, match]));
const boundIds = [
Expand Down
61 changes: 61 additions & 0 deletions server/services/tutor/curriculum/inline-focus-topic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { ExampleFocusArea } from "../../course-content/embedded-tutor-annotation";
import { validateCurriculumTopic, type CurriculumTopic } from "./curriculum-schema";

export const INLINE_FOCUS_TOPIC_PREFIX = "inline-";

/**
* Builds the Topic for the focus areas a teacher wrote into an Example's annotation. Every focus area
* is one Concept with its questions; mastery and progression use fixed defaults. The questions carry
* no fact requirements: the teacher authored them for exactly this sketch.
*/
export function buildInlineFocusTopic(exampleId: string, focus: readonly ExampleFocusArea[]): CurriculumTopic {
const topic: CurriculumTopic = {
schemaVersion: 1,
id: `${INLINE_FOCUS_TOPIC_PREFIX}${toSafeId(exampleId)}`,
title: "Fokus dieses Beispiels",
locale: "de-DE",
activation: { any: [{ fact: "serial-call", values: ["print", "write"] }] },
concepts: focus.map((area) => ({
id: area.id,
title: area.title,
objective: area.objective,
prerequisites: [],
difficulty: { entry: [1, 100], transfer: [1, 100] },
misconceptions: [],
indicators: [{ id: `understands-${area.id}`.slice(0, 64), description: area.objective }],
mastery: {
minimumSuccessfulProbes: 1,
successRatingAtLeast: 3,
requiredIndicators: [`understands-${area.id}`.slice(0, 64)],
minimumDistinctQuestionKinds: 1,
recentWeakAnswersAllowed: 0,
},
})),
questions: focus.flatMap((area) => area.questions.map((question, index) => ({
id: `${area.id}-q${index + 1}`.slice(0, 64),
concept: area.id,
indicator: `understands-${area.id}`.slice(0, 64),
kind: question.kind,
difficulty: [1, 100] as [number, number],
requires: [],
text: question.text,
}))),
scaffolds: [],
progression: {
entryConcepts: [focus[0]?.id ?? ""],
preferredOrder: focus.map(({ id }) => id),
onRating: {
"1-2": "remediate",
"3": "clarify-same-indicator",
"4": "probe-missing-indicator",
"5": "evaluate-mastery-and-advance",
},
},
};
return validateCurriculumTopic(topic);
}

function toSafeId(value: string): string {
const cleaned = value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
return (cleaned || "example").slice(0, 64 - INLINE_FOCUS_TOPIC_PREFIX.length);
}
31 changes: 29 additions & 2 deletions ssot/ssot_function_definition_CourseContent.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,8 @@ sketch, and only whitespace may follow its closing marker. A duplicate block,
a marker in the middle of executable source, or an unterminated block is
invalid. An annotation is optional.

The annotation payload is strict YAML core data with schema version 1 and no
additional fields. The complete UTF-8 annotation block is limited to 16 KiB
The annotation payload is strict YAML core data with schema version 1 or 2 and
no additional fields. The complete UTF-8 annotation block is limited to 16 KiB
before YAML parsing:

~~~yaml
Expand All @@ -232,6 +232,33 @@ structures are not interpreted. YAML duplicate keys, custom tags, unknown
fields, unsupported schema versions, invalid IDs, and invalid bounds make the
annotation invalid.

Schema version 2 additionally allows inline teacher focus (version 1 MUST NOT
contain these fields):

~~~yaml
schemaVersion: 2
focus:
- id: indizierung
title: Indizierung
objective: Elemente 眉ber den Index ansprechen.
questions:
- kind: concept
text: Welcher Index geh枚rt zum dritten Element von `werte`?
exclusive: true
~~~

`focus` is an ordered list of 1 to 8 focus areas with unique safe IDs. Each
has a bounded `title` (160) and `objective` (500) and 1 to 6 `questions`, each
with a `kind` (`recall`, `concept`, `application`, `prediction`, `transfer`)
and a bounded `text` (500; no URLs, no control characters). The server builds
one Topic `inline-<exampleId>` from it: one Concept per focus area, mastery by
one successful probe with rating at least 3, the standard rating progression,
and no fact requirements. This Topic is selected before every repository
Topic and needs no sketch-fact activation. `exclusive: true` requires `focus`
and excludes all repository Topics for this Example. Code terms in question
texts MUST be written in backticks; the Course Content quality validator
rejects a backticked term that does not occur in the sketch outside comments.

The server extracts and validates the block at the Course Content loading
boundary. The annotation travels separately in the immutable server snapshot.
The Example API, browser/editor, compiler, and simulator receive only the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { validateInlineFocus } from "../../../../server/services/course-content/inline-focus-validator";
import type { ExampleTutorAnnotation } from "../../../../server/services/course-content/embedded-tutor-annotation";

const annotation = (text: string): ExampleTutorAnnotation => ({
schemaVersion: 2,
focus: [{ id: "zeit", title: "Zeit", objective: "Zeitwert einordnen.", questions: [{ kind: "concept", text }] }],
});

describe("validateInlineFocus", () => {
const code = "unsigned long t = millis();\n// long ist hier nur ein Kommentar\n";

it("accepts code terms that occur in the sketch", () => {
expect(validateInlineFocus([{ id: "e", code, annotation: annotation("Warum `unsigned long` f眉r `millis()`?") }])).toEqual([]);
});

it("flags a code term that only occurs in a comment or not at all", () => {
const issues = validateInlineFocus([{ id: "e", code: "unsigned long t = 1;\n// struct\n", annotation: annotation("Was macht `struct` hier?") }]);
expect(issues).toMatchObject([{ code: "inline-focus-term-not-in-sketch", exampleId: "e", conceptId: "zeit" }]);
});

it("ignores examples without focus", () => {
expect(validateInlineFocus([{ id: "e", code, annotation: { schemaVersion: 2 } }])).toEqual([]);
});
});
60 changes: 60 additions & 0 deletions tests/server/services/tutor/inline-focus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { createTutorProgressionState } from "../../../../server/services/tutor/curriculum/progression-state";
import { plannerWithCourseContent } from "./support/course-content-planner";
import { embeddedTutorAnnotationSchema, type ExampleTutorAnnotation } from "../../../../server/services/course-content/embedded-tutor-annotation";

const revision = "2".repeat(40);
const arraySketch = "byte werte[3] = {1, 2, 3};\nvoid setup() {}\nvoid loop() {}\n";

const annotation: ExampleTutorAnnotation = {
schemaVersion: 2,
focus: [
{ id: "indizierung", title: "Indizierung", objective: "Elemente 眉ber den Index ansprechen.", questions: [
{ kind: "concept", text: "Welcher Index geh枚rt zum dritten Element von `werte`?" },
{ kind: "prediction", text: "Was passiert bei `werte[3]`?" },
] },
{ id: "datentyp", title: "Datentyp", objective: "Den Elementtyp begr眉nden.", questions: [
{ kind: "concept", text: "Warum gen眉gt `byte` f眉r die Elemente?" },
] },
],
};

function planner(overrides: Partial<ExampleTutorAnnotation> = {}) {
return plannerWithCourseContent(async () => ({
revision,
progressionState: createTutorProgressionState(revision),
tutor: { status: "valid" as const, manifest: { schemaVersion: 1 as const, topics: [], strategies: [] }, topics: [], strategies: [] },
exampleId: "it07-05-array",
exampleTutorAnnotation: { ...annotation, ...overrides },
}));
}

describe("inline focus annotation", () => {
it("plans the teacher's first focus question without any matching Topic", async () => {
const plan = await planner().planInitial({ code: arraySketch, history: [], difficulty: 30, exampleId: "it07-05-array" });
expect(plan).toMatchObject({ topicId: "inline-it07-05-array", question: "Welcher Index geh枚rt zum dritten Element von `werte`?" });
});

it("moves to the next focus area after the first one is mastered", async () => {
const adapter = planner();
const first = await adapter.planInitial({ code: arraySketch, history: [], difficulty: 30, exampleId: "it07-05-array" });
if (!first || "kind" in first) throw new Error("no plan");
const next = await adapter.planFollowup({
code: arraySketch, history: [], currentQuestion: first.question, rating: 5, difficulty: 30, exampleId: "it07-05-array",
});
expect(next).toMatchObject({ topicId: "inline-it07-05-array", question: "Warum gen眉gt `byte` f眉r die Elemente?" });
});

it("rejects focus in a version 1 annotation and exclusive without focus", () => {
expect(embeddedTutorAnnotationSchema.safeParse({ schemaVersion: 1, focus: annotation.focus }).success).toBe(false);
expect(embeddedTutorAnnotationSchema.safeParse({ schemaVersion: 2, exclusive: true }).success).toBe(false);
expect(embeddedTutorAnnotationSchema.safeParse({ schemaVersion: 2, focus: annotation.focus, exclusive: true }).success).toBe(true);
});

it("rejects duplicate focus ids, empty questions and URLs", () => {
const area = annotation.focus![0]!;
expect(embeddedTutorAnnotationSchema.safeParse({ schemaVersion: 2, focus: [area, area] }).success).toBe(false);
expect(embeddedTutorAnnotationSchema.safeParse({ schemaVersion: 2, focus: [{ ...area, questions: [] }] }).success).toBe(false);
expect(embeddedTutorAnnotationSchema.safeParse({ schemaVersion: 2, focus: [{ ...area, questions: [{ kind: "concept", text: "Siehe https://x.example" }] }] }).success).toBe(false);
});
});
Loading