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
6 changes: 5 additions & 1 deletion server/services/tutor/tutor-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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();
Expand Down
78 changes: 78 additions & 0 deletions tests/server/services/tutor/question-quality-regressions.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading