Files
coding-mentor-agent/tests/learning-progress-decision.test.ts

275 lines
15 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { prepareTurnModelContext } from "../src/server/context-management.js";
import { getLatestCatalogRun } from "../src/server/course-catalog.js";
import { getNextDiagnosticQuestion } from "../src/server/diagnostics.js";
import { deriveLearningProgressDecision } from "../src/server/learning-progress-decision.js";
import { createSession, getProgressSummary } from "../src/server/services.js";
import { createId, nowIso } from "../src/security/ids.js";
import { getRecentLearningContext, getStudentProfile } from "../src/tools/progress-tools.js";
import { createTestRuntime } from "./utils/runtime.js";
import { diagnosticTutor, upsertMasteryFixture } from "./utils/content-fixtures.js";
describe("learning progress decision", () => {
it("splits diagnostic, handoff, and practice state while failing closed before a fresh completion", async () => {
const runtime = await createTestRuntime({ tutor: diagnosticTutor() });
const session = createSession(runtime, { resume: false });
const notStarted = deriveLearningProgressDecision(runtime, { sessionId: session.session_id });
expect(notStarted).toMatchObject({
diagnostic_state: "not_started",
handoff_state: "not_ready",
practice_state: "locked_by_diagnostic",
current_goal: null,
current_unit: { id: "diagnostic" },
recommendation_focus: [],
});
await getNextDiagnosticQuestion(runtime, session.session_id);
const active = deriveLearningProgressDecision(runtime, { sessionId: session.session_id });
expect(active.diagnostic_state).toBe("active");
expect(active.practice_state).toBe("locked_by_diagnostic");
expect(active.diagnostic_focus.length).toBeGreaterThan(0);
expect(active.recommendation_focus).toEqual([]);
runtime.db.query("UPDATE diagnostic_sessions SET status = 'paused', stop_reason = 'needs_more_evidence' WHERE session_id = ?").run([session.session_id]);
const inconclusive = deriveLearningProgressDecision(runtime, { sessionId: session.session_id });
expect(inconclusive.diagnostic_state).toBe("inconclusive");
expect(inconclusive.practice_state).toBe("locked_by_diagnostic");
expect(inconclusive.recommendation_focus).toEqual([]);
});
it("distinguishes technical unavailable and catalog-stale diagnostics from fresh completion", async () => {
const unavailableRuntime = await createTestRuntime();
const unavailableSession = createSession(unavailableRuntime, { resume: false });
await getNextDiagnosticQuestion(unavailableRuntime, unavailableSession.session_id);
const unavailable = deriveLearningProgressDecision(unavailableRuntime, { sessionId: unavailableSession.session_id });
expect(unavailable).toMatchObject({
diagnostic_state: "technical_unavailable",
practice_state: "locked_by_diagnostic",
recommendation_focus: [],
});
const staleRuntime = await createTestRuntime({ tutor: diagnosticTutor() });
const staleSession = createSession(staleRuntime, { resume: false });
completeDiagnostic(staleRuntime, staleSession.session_id, { catalogFresh: false });
const stale = deriveLearningProgressDecision(staleRuntime, { sessionId: staleSession.session_id });
expect(stale).toMatchObject({
diagnostic_state: "catalog_stale",
practice_state: "locked_by_stale_catalog",
diagnostic_feedback: null,
recommendation_focus: [],
});
const freshRuntime = await createTestRuntime({ tutor: diagnosticTutor() });
const freshSession = createSession(freshRuntime, { resume: false });
completeDiagnostic(freshRuntime, freshSession.session_id, { catalogFresh: true, placementConceptId: "string" });
const fresh = deriveLearningProgressDecision(freshRuntime, { sessionId: freshSession.session_id });
expect(fresh).toMatchObject({
diagnostic_state: "completed",
handoff_state: "feedback_ready",
practice_state: "guidance_first",
});
expect(fresh.learning_start?.concept_id).toBe("string");
expect(fresh.diagnostic_feedback?.learning_start).toBeTruthy();
});
it("keeps generation interruptions locked until retry recovers to active diagnostic state", async () => {
let generationCalls = 0;
const runtime = await createTestRuntime({
tutor: {
generate: async () => {
generationCalls += 1;
if (generationCalls === 1) return "invalid diagnostic json";
return JSON.stringify({
prompt_md: "恢复后的测评题会输出什么?",
choices: [{ id: "a", text: "正确选项" }, { id: "b", text: "干扰项一" }, { id: "c", text: "干扰项二" }],
answer_choice_id: "a",
difficulty: 1,
});
},
},
});
const session = createSession(runtime, { resume: false });
await getNextDiagnosticQuestion(runtime, session.session_id);
const interrupted = deriveLearningProgressDecision(runtime, { sessionId: session.session_id });
expect(interrupted).toMatchObject({
diagnostic_state: "technical_unavailable",
practice_state: "locked_by_diagnostic",
recommendation_focus: [],
});
expect(interrupted.reasons).toContain("diagnostic_status:active");
expect(interrupted.reasons).toContain("diagnostic_stop:diagnostic_generation_unavailable");
await getNextDiagnosticQuestion(runtime, session.session_id);
const recovered = deriveLearningProgressDecision(runtime, { sessionId: session.session_id });
expect(recovered).toMatchObject({
diagnostic_state: "active",
practice_state: "locked_by_diagnostic",
recommendation_focus: [],
});
expect(recovered.reasons).not.toContain("technical_unavailable");
expect(recovered.reasons).not.toContain("diagnostic_stop:diagnostic_generation_unavailable");
});
it("exposes hard-cap low-confidence closure as completed guidance-first progress", async () => {
const runtime = await createTestRuntime({ tutor: diagnosticTutor() });
const session = createSession(runtime, { resume: false });
completeDiagnostic(runtime, session.session_id, {
catalogFresh: true,
placementConceptId: "string",
stopReason: "hard_cap_reached_low_confidence",
});
const decision = deriveLearningProgressDecision(runtime, { sessionId: session.session_id });
expect(decision).toMatchObject({
diagnostic_state: "completed",
handoff_state: "feedback_ready",
practice_state: "guidance_first",
});
expect(decision.learning_start?.concept_id).toBe("string");
expect(decision.reasons).toContain("diagnostic_stop:hard_cap_reached_low_confidence");
});
it("does not let display-only profile fields become authoritative progress", async () => {
const runtime = await createTestRuntime({ tutor: diagnosticTutor() });
const session = createSession(runtime, { resume: false });
runtime.db.query("UPDATE local_profile SET profile_json = ?, updated_at = ? WHERE id = 'local'").run([
JSON.stringify({
profile_summary: "Legacy display profile",
current_level: "高级",
current_goal: "Display-only goal",
diagnostic_placement_label: "Display-only placement",
}),
nowIso(),
]);
const decision = deriveLearningProgressDecision(runtime, { sessionId: session.session_id });
expect(decision.current_level).toBeNull();
expect(decision.current_goal).toBeNull();
expect(decision.learning_start).toBeNull();
expect(decision.provenance.current_level).toBeNull();
expect(decision.provenance.current_goal).toBeNull();
expect(decision.provenance.learning_start).toBeNull();
});
it("selects current unit by diagnostic gate, blocker, learning start, recommendation focus, and catalog fallback", async () => {
const diagnosticRuntime = await createTestRuntime({ tutor: diagnosticTutor() });
const diagnosticSession = createSession(diagnosticRuntime, { resume: false });
expect(deriveLearningProgressDecision(diagnosticRuntime, { sessionId: diagnosticSession.session_id }).current_unit.id).toBe("diagnostic");
const blockerRuntime = await createTestRuntime({ tutor: diagnosticTutor() });
const blockerSession = createSession(blockerRuntime, { resume: false });
completeDiagnostic(blockerRuntime, blockerSession.session_id, { catalogFresh: true, placementConceptId: "string" });
upsertMasteryFixture(blockerRuntime, "intro-python", { mastery: 5, readiness: 0, confidence: 0.9, evidenceCount: 4, reviewPriority: 10 });
const blocker = deriveLearningProgressDecision(blockerRuntime, { sessionId: blockerSession.session_id });
expect(blocker.current_unit.concept_ids).toContain("intro-python");
expect(blocker.provenance.current_unit?.source).toBe("mastery_projection");
const startRuntime = await createTestRuntime({ tutor: diagnosticTutor() });
const startSession = createSession(startRuntime, { resume: false });
completeDiagnostic(startRuntime, startSession.session_id, { catalogFresh: true, placementConceptId: "string" });
upsertMasteryFixture(startRuntime, "intro-python", { mastery: 95, readiness: 95, confidence: 0.9, evidenceCount: 4, reviewPriority: 0 });
const start = deriveLearningProgressDecision(startRuntime, { sessionId: startSession.session_id });
expect(start.current_unit.concept_ids).toContain("string");
expect(start.provenance.current_unit?.source).toBe("active_diagnostic");
const recommendationRuntime = await createTestRuntime({ tutor: diagnosticTutor() });
const recommendationSession = createSession(recommendationRuntime, { resume: false });
completeDiagnostic(recommendationRuntime, recommendationSession.session_id, { catalogFresh: true });
upsertMasteryFixture(recommendationRuntime, "set", { mastery: 20, readiness: 15, confidence: 0.9, evidenceCount: 3, reviewPriority: 10 });
const recommendation = deriveLearningProgressDecision(recommendationRuntime, { sessionId: recommendationSession.session_id });
expect(recommendation.current_unit.concept_ids).toContain(recommendation.recommendation_focus[0]!.target_id);
expect(recommendation.provenance.current_unit?.source).toBe("recommendation_ranker");
const fallbackRuntime = await createTestRuntime({ tutor: diagnosticTutor() });
const fallbackSession = createSession(fallbackRuntime, { resume: false });
completeDiagnostic(fallbackRuntime, fallbackSession.session_id, { catalogFresh: true });
for (const conceptId of activeConceptIds(fallbackRuntime)) {
upsertMasteryFixture(fallbackRuntime, conceptId, { mastery: 95, readiness: 95, confidence: 0.9, evidenceCount: 3, reviewPriority: 0 });
}
const fallback = deriveLearningProgressDecision(fallbackRuntime, { sessionId: fallbackSession.session_id });
expect(fallback.current_unit.id).toBe(activeUnitIds(fallbackRuntime).at(-1));
expect(fallback.provenance.current_unit?.source).toBe("active_catalog");
});
it("uses the same decision for progress API, tools, and model context", async () => {
const runtime = await createTestRuntime({ tutor: diagnosticTutor() });
const session = createSession(runtime, { resume: false });
completeDiagnostic(runtime, session.session_id, { catalogFresh: true, placementConceptId: "string" });
upsertMasteryFixture(runtime, "function", { mastery: 25, readiness: 20, confidence: 0.8, evidenceCount: 2, reviewPriority: 8 });
const decision = deriveLearningProgressDecision(runtime, { sessionId: session.session_id });
const progress = getProgressSummary(runtime, { sessionId: session.session_id });
const profile = await getStudentProfile(runtime, { sessionId: session.session_id });
const recent = await getRecentLearningContext(runtime, { event_limit: 3 }, { sessionId: session.session_id });
const context = prepareTurnModelContext(runtime, session.session_id, createId("turn"), { message: "我的学习进度怎么样?", code: "" });
expect(progress.progress_decision).toMatchObject({
diagnostic_state: decision.diagnostic_state,
handoff_state: decision.handoff_state,
practice_state: decision.practice_state,
current_unit: decision.current_unit,
});
expect(profile.data.progress_decision).toMatchObject(progress.progress_decision);
expect(recent.data.progress_decision).toMatchObject(progress.progress_decision);
expect(context.context.bundle?.server_attested_state.learning_progress_decision).toMatchObject({
diagnostic_state: progress.progress_decision.diagnostic_state,
handoff_state: progress.progress_decision.handoff_state,
practice_state: progress.progress_decision.practice_state,
current_unit: progress.progress_decision.current_unit,
});
expect(context.context.bundle?.server_attested_state.recommendation_focus).toEqual(decision.recommendation_focus);
});
});
function completeDiagnostic(
runtime: Awaited<ReturnType<typeof createTestRuntime>>,
sessionId: string,
options: { catalogFresh: boolean; placementConceptId?: string; stopReason?: string } = { catalogFresh: true },
): string {
const now = nowIso();
const id = createId("diag");
const catalogRun = options.catalogFresh ? getLatestCatalogRun(runtime) : undefined;
runtime.db.query(
"INSERT INTO diagnostic_sessions(id, session_id, status, target_concepts_json, stop_reason, catalog_version, catalog_run_id, started_at, ended_at) VALUES (?, ?, 'completed', ?, ?, ?, ?, ?, ?)",
).run([
id,
sessionId,
JSON.stringify(["intro-python", "loop", "string", "function"]),
options.stopReason ?? "test_complete",
catalogRun?.kb_version ?? null,
catalogRun?.id ?? null,
now,
now,
]);
runtime.db.query("UPDATE local_profile SET profile_json = ?, updated_at = ? WHERE id = 'local'").run([
JSON.stringify({
profile_summary: "Python learner with completed diagnostic.",
current_level: "Display-only level",
current_goal: "Display-only goal",
diagnostic_placement_concept_id: options.placementConceptId,
diagnostic_placement_label: options.placementConceptId ? conceptName(runtime, options.placementConceptId) : undefined,
}),
now,
]);
return id;
}
function conceptName(runtime: Awaited<ReturnType<typeof createTestRuntime>>, conceptId: string): string {
return runtime.db.query<{ name: string }>("SELECT name FROM concepts WHERE id = ?").get([conceptId])?.name ?? conceptId;
}
function activeConceptIds(runtime: Awaited<ReturnType<typeof createTestRuntime>>): string[] {
return runtime.db.query<{ id: string }>("SELECT id FROM concepts WHERE catalog_status = 'active' ORDER BY order_index ASC").all().map((row) => row.id);
}
function activeUnitIds(runtime: Awaited<ReturnType<typeof createTestRuntime>>): string[] {
return runtime.db.query<{ id: string }>("SELECT id FROM course_units WHERE catalog_status = 'active' ORDER BY order_index ASC").all().map((row) => row.id);
}