From 230aaec7c5dc924ade6c9a7ca8f343a7d562a7d9 Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Sun, 27 Sep 2026 19:45:23 +0200 Subject: [PATCH 1/2] test: cover tutor question quality regressions --- .../question-quality-regressions.test.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/server/services/tutor/question-quality-regressions.test.ts diff --git a/tests/server/services/tutor/question-quality-regressions.test.ts b/tests/server/services/tutor/question-quality-regressions.test.ts new file mode 100644 index 00000000..42ffa092 --- /dev/null +++ b/tests/server/services/tutor/question-quality-regressions.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildTutorContext, buildUserPrompt, TutorService } from "../../../../server/services/tutor/tutor-service"; +import type { LLMProvider } from "../../../../server/services/tutor/llm-provider"; +import type { TutorPlanningExtension } from "../../../../server/services/tutor/tutor-planning"; + +const revision = "a".repeat(40); + +function providerReturning(question: string): LLMProvider { + return { + listModels: vi.fn().mockResolvedValue(["pilot-model"]), + generateLearningQuestion: vi.fn().mockResolvedValue({ + model: "pilot-model", + result: { + answerRating: 5, + question, + }, + }), + }; +} + +function transitionPlanning(): TutorPlanningExtension { + return { + planInitial: vi.fn().mockResolvedValue(null), + planFollowup: vi.fn().mockResolvedValue({ + kind: "transition", + contentRevision: revision, + learningPhase: "DEEPEN", + activeTopicId: "pwm-output", + masteredTopicIds: ["pwm-output"], + strategyId: "built-in-default", + strategySource: "built-in", + }), + }; +} + +describe("Tutor question quality regressions", () => { + it("keeps the distinct question after applying transition metadata", async () => { + const repeatedQuestion = "Welchen Wert verwendet der Sketch an der betrachteten Integer-Variablen?"; + const result = await new TutorService( + providerReturning(repeatedQuestion), + transitionPlanning(), + ).generateDialogResponse( + `const int ledPin = 9; +void setup() { pinMode(ledPin, OUTPUT); } +void loop() { analogWrite(ledPin, 128); }`, + [], + repeatedQuestion, + "Der Wert ist 128.", + "key", + undefined, + 30, + ); + + expect(result.result).toMatchObject({ + answerRating: 5, + learningPhase: "DEEPEN", + strategyId: "built-in-default", + }); + expect(result.result.question).not.toBe(repeatedQuestion); + expect(result.result.question).toBe("Welches andere im Sketch sichtbare Konzept möchtest du als Nächstes mit diesem verstandenen Teilkonzept verknüpfen?"); + }); + + it("guides the provider to identify concrete variables and local code context", () => { + const code = `const int ledPin = 9; +void setup() { pinMode(ledPin, OUTPUT); } +void loop() { + for (int brightness = 0; brightness <= 255; brightness += 5) { + analogWrite(ledPin, brightness); + } +}`; + const prompt = buildUserPrompt(code, buildTutorContext(code)); + + expect(prompt).toContain("konkreten Bezeichner"); + expect(prompt).toContain("lokale Code-Stelle"); + expect(prompt).toContain("ledPin"); + expect(prompt).toContain("brightness"); + }); +}); From deebcdf2d2c8f349ee3e61fe9c7eb554a1715dd8 Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Sun, 27 Sep 2026 19:45:35 +0200 Subject: [PATCH 2/2] fix: preserve distinct tutor questions --- server/services/tutor/tutor-service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/services/tutor/tutor-service.ts b/server/services/tutor/tutor-service.ts index 339e63da..8490e5b5 100644 --- a/server/services/tutor/tutor-service.ts +++ b/server/services/tutor/tutor-service.ts @@ -45,6 +45,7 @@ const UNSAFE_MERMAID_PATTERNS = [ ]; const TUTOR_DIFFICULTY_GUIDANCE = "Kalibriere die Frage kognitiv: 1–10 = elementare Wiedererkennung oder direkter Fakt, 11–30 = einfache Anwendung, 31–50 = Verständnis und Zusammenhang, 51–70 = Transfer oder Analyse, 71–90 = anspruchsvolle Herleitung mehrerer Konzepte, 91–100 = sehr anspruchsvolle Synthese. Die Frage muss zum aktuellen Wert passen; Difficulty ist kein Prüfungsniveau."; +const TUTOR_CONCRETE_REFERENCE_GUIDANCE = "Wenn eine Frage auf ein konkretes Sketch-Element zielt und mehrere Bezeichner oder Stellen infrage kommen, nenne den konkreten Bezeichner und genügend lokale Code-Stelle (zum Beispiel Deklaration, Schleife oder Aufruf), damit die Frage ohne Raten verständlich ist. Vermeide bei möglicher Mehrdeutigkeit unklare Formulierungen wie ‚die Integer-Variable‘, ‚dieser Wert‘ oder ‚dort‘. Frage nur nach durch den aktuellen Sketch belegten Fakten."; type TutorDialogArguments = [ code: string, @@ -83,6 +84,7 @@ export const TUTOR_SYSTEM_PROMPT = [ "Erfinde keine Hardware, Pins, Variablen, Werte oder Programmstrukturen.", "Gib keine vollständige Lösung, keinen vollständigen Ersatzcode und keine Codeänderung aus.", "Die Frage soll die eigene Analyse des Studierenden fördern und nicht die Denkarbeit ersetzen.", + TUTOR_CONCRETE_REFERENCE_GUIDANCE, "Wenn eine Nutzerantwort vorliegt, gib bei normalen inhaltlichen Antworten kurzes Feedback, responseStyle normal, eine answerRating von 1 bis 5 und danach genau eine Folgefrage.", "Für offensichtlich unsinnige, absurde oder vollständig themenfremde Antworten verwende ausschließlich den begrenzten philosophischen Fallback: responseStyle philosophical, keine answerRating, kurzer nicht-spöttischer Reflexionshinweis und genau eine Frage zurück zum aktuellen Sketch.", "Normale fachlich falsche Antworten bleiben responseStyle normal und werden bewertet.", @@ -131,6 +133,7 @@ function buildUserPrompt( "Erzeuge eine einzige Lernfrage zum folgenden aktuellen Arduino-Sketch.", `Relative didaktische Schwierigkeit für diese Frage: ${difficulty}/100 (1 = sehr leicht, 100 = sehr schwer; kein Prüfungsniveau).`, TUTOR_DIFFICULTY_GUIDANCE, + TUTOR_CONCRETE_REFERENCE_GUIDANCE, buildTutorStrategyGuidance(strategy), ...(objectivesGuidance ? [objectivesGuidance] : []), "Wenn ein Sachverhalt nicht statisch belegt ist, formuliere höchstens eine offene Reflexionsfrage statt einer Tatsachenbehauptung.", @@ -166,6 +169,7 @@ function buildDialogPrompt( "Führe den sokratischen Lerndialog zum folgenden aktuellen Arduino-Sketch fort.", `Erzeuge die Folgefrage mit relativer didaktischer Schwierigkeit ${difficulty}/100 (1 = sehr leicht, 100 = sehr schwer; kein Prüfungsniveau).`, TUTOR_DIFFICULTY_GUIDANCE, + TUTOR_CONCRETE_REFERENCE_GUIDANCE, buildTutorStrategyGuidance(strategy), ...(objectivesGuidance ? [objectivesGuidance] : []), "Bewerte die Antwort mit answerRating 1 bis 5 gemäß Verständnisrubrik, höchstens kurz, und stelle danach genau eine neue, weiterführende Frage.", @@ -630,7 +634,7 @@ export class TutorService { : validatedResult; if (validatedResult.responseStyle === "normal" && this.planningExtension) { const nextPlan = await this.planningExtension.planFollowup({ code, history: parsedHistory, currentQuestion: question, rating: validatedResult.answerRating!, difficulty, courseContent: transaction.courseContent }); - if (nextPlan) distinctResult = applyPlanningOutcome(validatedResult, nextPlan); + if (nextPlan) distinctResult = applyPlanningOutcome(distinctResult, nextPlan); } if (!distinctResult.strategyId) distinctResult = applyStrategyMetadata(distinctResult, strategy); transaction.commit();