diff --git a/server/services/course-content/embedded-tutor-annotation.ts b/server/services/course-content/embedded-tutor-annotation.ts index a59eba680..11a81b137 100644 --- a/server/services/course-content/embedded-tutor-annotation.ts +++ b/server/services/course-content/embedded-tutor-annotation.ts @@ -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; + 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" }); } diff --git a/server/services/course-content/inline-focus-validator.ts b/server/services/course-content/inline-focus-validator.ts new file mode 100644 index 000000000..962b52b38 --- /dev/null +++ b/server/services/course-content/inline-focus-validator.ts @@ -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; +} diff --git a/server/services/course-content/tutor-quality-directory-validator.ts b/server/services/course-content/tutor-quality-directory-validator.ts index 15e67659d..b906041f0 100644 --- a/server/services/course-content/tutor-quality-directory-validator.ts +++ b/server/services/course-content/tutor-quality-directory-validator.ts @@ -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 { @@ -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")]; diff --git a/server/services/course-content/tutor-quality-validator.ts b/server/services/course-content/tutor-quality-validator.ts index 8d65a5c5d..bc77fa8b2 100644 --- a/server/services/course-content/tutor-quality-validator.ts +++ b/server/services/course-content/tutor-quality-validator.ts @@ -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"; diff --git a/server/services/tutor/curriculum-tutor-adapter.ts b/server/services/tutor/curriculum-tutor-adapter.ts index 009280aa7..5760daf54 100644 --- a/server/services/tutor/curriculum-tutor-adapter.ts +++ b/server/services/tutor/curriculum-tutor-adapter.ts @@ -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 { @@ -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; @@ -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"; } @@ -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 = [ diff --git a/server/services/tutor/curriculum/inline-focus-topic.ts b/server/services/tutor/curriculum/inline-focus-topic.ts new file mode 100644 index 000000000..27a18f736 --- /dev/null +++ b/server/services/tutor/curriculum/inline-focus-topic.ts @@ -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); +} diff --git a/ssot/ssot_function_definition_CourseContent.md b/ssot/ssot_function_definition_CourseContent.md index 3f9476e0e..d242f0721 100644 --- a/ssot/ssot_function_definition_CourseContent.md +++ b/ssot/ssot_function_definition_CourseContent.md @@ -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 @@ -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-` 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 diff --git a/tests/server/services/course-content/inline-focus-validator.test.ts b/tests/server/services/course-content/inline-focus-validator.test.ts new file mode 100644 index 000000000..219296c32 --- /dev/null +++ b/tests/server/services/course-content/inline-focus-validator.test.ts @@ -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([]); + }); +}); diff --git a/tests/server/services/tutor/inline-focus.test.ts b/tests/server/services/tutor/inline-focus.test.ts new file mode 100644 index 000000000..2ecbd0d04 --- /dev/null +++ b/tests/server/services/tutor/inline-focus.test.ts @@ -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 = {}) { + 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); + }); +});