diff --git a/apps/mentora/.gitignore b/apps/mentora/.gitignore index da4616b..d99cacd 100644 --- a/apps/mentora/.gitignore +++ b/apps/mentora/.gitignore @@ -27,3 +27,7 @@ src/lib/paraglide *storybook.log storybook-static + +# Playwright +/test-results/ +/playwright-report/ diff --git a/apps/mentora/messages/en.json b/apps/mentora/messages/en.json index 6dbc50e..6366824 100644 --- a/apps/mentora/messages/en.json +++ b/apps/mentora/messages/en.json @@ -515,6 +515,20 @@ "mentor_submissions_status_graded": "Graded", "mentor_submissions_type_dialogue": "Dialogue", "mentor_submissions_type_questionnaire": "Questionnaire", + "mentor_submissions_summary": "Summary", + "mentor_submissions_ai_assessment": "AI Assessment", + "mentor_submissions_overall": "Overall", + "mentor_submissions_conversation": "Conversation", + "mentor_submissions_no_conversation": "No conversation available", + "mentor_submissions_no_assessment": "No AI assessment available", + "mentor_submissions_no_summary": "No summary available", + "mentor_submissions_dimension_argumentQuality": "Argument Quality", + "mentor_submissions_dimension_criticalThinking": "Critical Thinking", + "mentor_submissions_dimension_principleExtraction": "Principle Extraction", + "mentor_submissions_dimension_openness": "Openness", + "mentor_submissions_dimension_coherence": "Coherence", + "mentor_submissions_student_label": "Student", + "mentor_submissions_ai_label": "AI", "announcements": "Announcements", "announcements_title": "Announcements", "announcements_today": "Today", diff --git a/apps/mentora/messages/zh-tw.json b/apps/mentora/messages/zh-tw.json index fcdc2a6..23719e1 100644 --- a/apps/mentora/messages/zh-tw.json +++ b/apps/mentora/messages/zh-tw.json @@ -515,6 +515,20 @@ "mentor_submissions_status_graded": "已評分", "mentor_submissions_type_dialogue": "對話", "mentor_submissions_type_questionnaire": "問卷", + "mentor_submissions_summary": "對話總結", + "mentor_submissions_ai_assessment": "AI 評估", + "mentor_submissions_overall": "總分", + "mentor_submissions_conversation": "對話紀錄", + "mentor_submissions_no_conversation": "尚無對話紀錄", + "mentor_submissions_no_assessment": "尚無 AI 評估", + "mentor_submissions_no_summary": "尚無總結", + "mentor_submissions_dimension_argumentQuality": "論點品質", + "mentor_submissions_dimension_criticalThinking": "批判思考", + "mentor_submissions_dimension_principleExtraction": "原則提取", + "mentor_submissions_dimension_openness": "開放性", + "mentor_submissions_dimension_coherence": "連貫性", + "mentor_submissions_student_label": "學生", + "mentor_submissions_ai_label": "AI", "announcements": "公告", "announcements_title": "公告", "announcements_today": "今天", diff --git a/apps/mentora/package.json b/apps/mentora/package.json index 27fa0e4..8e1b321 100644 --- a/apps/mentora/package.json +++ b/apps/mentora/package.json @@ -13,6 +13,7 @@ "lint": "eslint .", "test": "vitest", "test:integration": "vitest run --config vitest.integration.config.ts", + "test:e2e": "npx playwright test --config playwright.config.ts", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, diff --git a/apps/mentora/playwright.config.ts b/apps/mentora/playwright.config.ts new file mode 100644 index 0000000..7b8a55e --- /dev/null +++ b/apps/mentora/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 60_000, + retries: 0, + use: { + baseURL: "http://localhost:5173", + video: "on", + viewport: { width: 1280, height: 720 }, + actionTimeout: 10_000, + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], + reporter: [["html", { open: "never" }]], + outputDir: "./test-results", +}); diff --git a/apps/mentora/src/lib/components/course/mentor/CourseSubmissions.svelte b/apps/mentora/src/lib/components/course/mentor/CourseSubmissions.svelte index b2aa549..9584545 100644 --- a/apps/mentora/src/lib/components/course/mentor/CourseSubmissions.svelte +++ b/apps/mentora/src/lib/components/course/mentor/CourseSubmissions.svelte @@ -9,6 +9,8 @@ Questionnaire, QuestionnaireResponse, } from "$lib/api"; + import type { AssessmentResult, Turn } from "mentora-firebase"; + import type { DialogueStateDisplay } from "mentora-firebase"; import { formatMentoraDateTime } from "$lib/features/datetime/format"; import { onMount } from "svelte"; import { SvelteMap } from "svelte/reactivity"; @@ -69,6 +71,10 @@ let gradingSaving = $state(false); let gradingSuccess = $state(null); let gradingError = $state(null); + let gradingLoading = $state(false); + let gradingAssessment = $state(null); + let gradingDialogueState = $state(null); + let gradingConversationTurns = $state([]); // Response viewer modal state let viewResponseOpen = $state(false); @@ -230,7 +236,36 @@ gradingNotes = sub?.notes ?? ""; gradingSuccess = null; gradingError = null; + gradingAssessment = sub?.assessment ?? null; + gradingDialogueState = null; + gradingConversationTurns = []; + gradingLoading = true; gradingOpen = true; + + // Fetch conversation data in background + void loadGradingDetails(row.userId); + } + + async function loadGradingDetails(userId: string) { + try { + const convRes = await api.conversations.getForAssignment( + selectedItemId, + userId, + ); + if (convRes.success) { + gradingConversationTurns = convRes.data.turns; + const stateRes = await api.conversations.getDialogueState( + convRes.data.id, + ); + if (stateRes.success) { + gradingDialogueState = stateRes.data; + } + } + } catch { + // Non-critical: grading still works without conversation details + } finally { + gradingLoading = false; + } } async function saveGrade() { @@ -296,6 +331,18 @@ } } + function dimensionLabel(key: string): string { + const labels: Record string> = { + argumentQuality: m.mentor_submissions_dimension_argumentQuality, + criticalThinking: m.mentor_submissions_dimension_criticalThinking, + principleExtraction: + m.mentor_submissions_dimension_principleExtraction, + openness: m.mentor_submissions_dimension_openness, + coherence: m.mentor_submissions_dimension_coherence, + }; + return labels[key]?.() ?? key; + } + function statusLabel(state: string): string { switch (state) { case "in_progress": @@ -490,7 +537,11 @@ {/if} - +
@@ -499,36 +550,158 @@
{gradingStudentName}
-
- - -
+ {#if gradingLoading} +
+ + {m.loading()} +
+ {:else} + +
+

+ {m.mentor_submissions_summary()} +

+ {#if gradingDialogueState?.summary} +

+ {gradingDialogueState.summary} +

+ {:else} +

+ {m.mentor_submissions_no_summary()} +

+ {/if} +
-
- - + +
+

+ {m.mentor_submissions_ai_assessment()} +

+ {#if gradingAssessment} +
+ {#each Object.entries(gradingAssessment.dimensions) as [key, dim] (key)} +
+
+ {dimensionLabel(key)} +
+
+ {#each [0, 1, 2, 3, 4] as dot (dot)} +
+ {/each} + {dim.score}/5 +
+

+ {dim.feedback} +

+
+ {/each} +
+
+ {m.mentor_submissions_overall()}: + {gradingAssessment.overallScore}/5 + {#if gradingAssessment.overallFeedback} + — {gradingAssessment.overallFeedback} + {/if} +
+ {:else} +

+ {m.mentor_submissions_no_assessment()} +

+ {/if} +
+ + +
+

+ {m.mentor_submissions_conversation()} +

+ {#if gradingConversationTurns.length > 0} +
+ {#each gradingConversationTurns as turn, i (turn.id)} + {@const isStudent = i % 2 === 0} +
+
+
+ {isStudent + ? m.mentor_submissions_student_label() + : m.mentor_submissions_ai_label()} +
+ {turn.text} +
+
+ {/each} +
+ {:else} +

+ {m.mentor_submissions_no_conversation()} +

+ {/if} +
+ {/if} + + +
+
+
+ + +
+ +
+ + +
+
{#if gradingError} diff --git a/apps/mentora/tests/e2e/grading-modal.spec.ts b/apps/mentora/tests/e2e/grading-modal.spec.ts new file mode 100644 index 0000000..f5e50c9 --- /dev/null +++ b/apps/mentora/tests/e2e/grading-modal.spec.ts @@ -0,0 +1,192 @@ +import { expect, test, type Page } from "playwright/test"; +import { createFreshToken, seedGradingData, type SeedResult } from "./seed"; + +let seed: SeedResult; + +/** + * Inject Firebase Auth state into the browser's IndexedDB so the + * app recognizes the user as signed in on page load. + */ +async function injectAuthState( + page: Page, + uid: string, + email: string, + token: string, + refreshToken: string, +) { + await page.goto("/"); + await page.waitForLoadState("domcontentloaded"); + + await page.evaluate( + async ({ uid, email, token, refreshToken }) => { + const API_KEY = "AIzaSyCMXQsEdCKChh-D_tfxWz6RBXzlO8q04ew"; + const APP_NAME = "[DEFAULT]"; + const DB_NAME = "firebaseLocalStorageDb"; + const STORE_NAME = "firebaseLocalStorage"; + const KEY = `firebase:authUser:${API_KEY}:${APP_NAME}`; + + const userObj = { + uid, + email, + emailVerified: false, + displayName: email.split("@")[0], + isAnonymous: false, + providerData: [ + { + providerId: "password", + uid: email, + displayName: null, + email, + phoneNumber: null, + photoURL: null, + }, + ], + stsTokenManager: { + refreshToken, + accessToken: token, + expirationTime: Date.now() + 3600 * 1000, + }, + createdAt: String(Date.now()), + lastLoginAt: String(Date.now()), + apiKey: API_KEY, + appName: APP_NAME, + }; + + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + store.put({ fbase_key: KEY, value: userObj }, KEY); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }; + req.onerror = () => reject(req.error); + }); + }, + { uid, email, token, refreshToken }, + ); + + await page.reload(); + await page.waitForLoadState("load"); + await page.waitForTimeout(3000); +} + +test.beforeAll(async () => { + seed = await seedGradingData(); +}); + +test.describe("Grading Modal", () => { + test("shows enhanced grading UI with assessment and conversation", async ({ + page, + }) => { + const { token, refreshToken } = await createFreshToken( + seed.mentor.email, + ); + + await injectAuthState( + page, + seed.mentor.uid, + seed.mentor.email, + token, + refreshToken, + ); + + // Navigate to the course + await page.goto(`/courses/${seed.courseId}`); + await page.waitForLoadState("load"); + await page.waitForTimeout(2000); + + // Verify we see the mentor view + await expect(page.getByText("E2E Test Course")).toBeVisible({ + timeout: 15000, + }); + + // Click 提交管理 (Submissions) tab + await page + .locator("nav button") + .filter({ hasText: "提交管理" }) + .click(); + await page.waitForTimeout(1000); + + // Select the assignment from dropdown + const select = page.locator("select").first(); + await select.waitFor({ state: "visible", timeout: 5000 }); + + const options = select.locator("option"); + const count = await options.count(); + for (let i = 0; i < count; i++) { + const text = await options.nth(i).textContent(); + if (text && text.includes("AI Ethics Debate")) { + await select.selectOption({ index: i }); + break; + } + } + await page.waitForTimeout(1000); + + // Should see at least one submission row with 評分 button + const gradeBtn = page + .locator("button") + .filter({ hasText: "評分" }) + .first(); + await expect(gradeBtn).toBeVisible({ timeout: 5000 }); + + // Click 評分 (Grade) button + await gradeBtn.click(); + + // Wait for modal to load data + await page.waitForTimeout(3000); + + // Screenshot the grading modal + await page.screenshot({ + path: "test-results/grading-modal.png", + }); + + // Verify AI 評估 (AI Assessment) section + await expect(page.getByText("AI 評估")).toBeVisible({ timeout: 5000 }); + + // Verify dimension labels (zh-tw) + await expect(page.getByText("論點品質")).toBeVisible(); + await expect(page.getByText("批判思考")).toBeVisible(); + await expect(page.getByText("開放性")).toBeVisible(); + await expect(page.getByText("連貫性")).toBeVisible(); + await expect(page.getByText("原則提取")).toBeVisible(); + + // Verify score indicators (4/5, 5/5, 3/5) + await expect(page.getByText("4/5").first()).toBeVisible(); + await expect(page.getByText("5/5").first()).toBeVisible(); + await expect(page.getByText("3/5").first()).toBeVisible(); + + // Verify 總分 (Overall) + await expect(page.getByText("總分")).toBeVisible(); + + // Verify 對話紀錄 (Conversation) section with turns + await expect(page.getByText("對話紀錄")).toBeVisible(); + await expect(page.getByText(/AI should be regulated/i)).toBeVisible(); + + // Verify grading form inputs exist + await expect(page.locator("input#grade-score")).toBeVisible(); + await expect(page.locator("textarea#grade-notes")).toBeVisible(); + + // Fill in a grade to demonstrate functionality + await page.locator("input#grade-score").fill("85"); + await page + .locator("textarea#grade-notes") + .fill("Great discussion on AI ethics."); + + // Final screenshot with grade filled in + await page.screenshot({ + path: "test-results/grading-modal-filled.png", + }); + }); +}); diff --git a/apps/mentora/tests/e2e/seed.ts b/apps/mentora/tests/e2e/seed.ts new file mode 100644 index 0000000..dd07418 --- /dev/null +++ b/apps/mentora/tests/e2e/seed.ts @@ -0,0 +1,338 @@ +/** + * Seed data in Firebase Emulator for e2e tests. + * Respects Firestore security rules by using proper auth tokens per operation. + */ + +const AUTH_EMULATOR = "http://localhost:9099"; +const FIRESTORE = "http://localhost:8080"; +const PROJECT = "demo-mentora"; +const API_KEY = "fake-api-key"; + +interface SeedUser { + uid: string; + email: string; + token: string; +} + +async function authUser(email: string, password: string): Promise { + // Try sign in first + const res = await fetch( + `${AUTH_EMULATOR}/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${API_KEY}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, returnSecureToken: true }), + }, + ); + if (res.ok) { + const d = await res.json(); + return { uid: d.localId, email, token: d.idToken }; + } + // Sign up + const res2 = await fetch( + `${AUTH_EMULATOR}/identitytoolkit.googleapis.com/v1/accounts:signUp?key=${API_KEY}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, returnSecureToken: true }), + }, + ); + if (!res2.ok) throw new Error(`auth failed: ${await res2.text()}`); + const d = await res2.json(); + return { uid: d.localId, email, token: d.idToken }; +} + +// Convert JS object → Firestore REST value format +function val(v: unknown): Record { + if (v === null || v === undefined) return { nullValue: null }; + if (typeof v === "string") return { stringValue: v }; + if (typeof v === "boolean") return { booleanValue: v }; + if (typeof v === "number" && Number.isInteger(v)) + return { integerValue: String(v) }; + if (typeof v === "number") return { doubleValue: v }; + if (Array.isArray(v)) + return { arrayValue: { values: v.map((x) => val(x)) } }; + if (typeof v === "object") { + const fields: Record = {}; + for (const [k, x] of Object.entries(v as Record)) + fields[k] = val(x); + return { mapValue: { fields } }; + } + return { stringValue: String(v) }; +} + +function toFields(obj: Record) { + const fields: Record = {}; + for (const [k, v] of Object.entries(obj)) fields[k] = val(v); + return { fields }; +} + +async function writeDoc( + path: string, + data: Record, + token: string, +) { + const url = `${FIRESTORE}/v1/projects/${PROJECT}/databases/(default)/documents/${path}`; + const res = await fetch(url, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(toFields(data)), + }); + if (!res.ok) { + throw new Error( + `writeDoc ${path} failed (${res.status}): ${await res.text()}`, + ); + } +} + +export async function createFreshToken( + email: string, + password = "test1234", +): Promise<{ token: string; refreshToken: string }> { + const res = await fetch( + `${AUTH_EMULATOR}/identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${API_KEY}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, returnSecureToken: true }), + }, + ); + if (!res.ok) throw new Error(`signIn failed: ${await res.text()}`); + const d = await res.json(); + return { token: d.idToken, refreshToken: d.refreshToken }; +} + +export interface SeedResult { + mentor: SeedUser; + student: SeedUser; + courseId: string; + assignmentId: string; +} + +export async function seedGradingData(): Promise { + const now = Date.now(); + const courseId = `e2e-course-${now}`; + const topicId = `e2e-topic-${now}`; + const assignmentId = `e2e-assign-${now}`; + const convId = `e2e-conv-${now}`; + + const mentor = await authUser("e2e-mentor@test.com", "test1234"); + const student = await authUser(`e2e-student-${now}@test.com`, "test1234"); + + // 1. User profiles (each user writes their own) + await writeDoc( + `users/${mentor.uid}`, + { + uid: mentor.uid, + role: "mentor", + displayName: "E2E Mentor", + email: mentor.email, + photoURL: null, + createdAt: now, + updatedAt: now, + }, + mentor.token, + ); + await writeDoc( + `users/${student.uid}`, + { + uid: student.uid, + role: "student", + displayName: "E2E Student", + email: student.email, + photoURL: null, + createdAt: now, + updatedAt: now, + }, + student.token, + ); + + // 2. Course (mentor creates, ownerId = mentor) + await writeDoc( + `courses/${courseId}`, + { + title: "E2E Test Course", + code: "E2E-001", + ownerId: mentor.uid, + visibility: "public", + theme: "Testing", + description: "Course for e2e testing", + thumbnail: { storagePath: "", url: "/course-placeholder.jpg" }, + createdAt: now, + updatedAt: now, + isDemo: false, + passwordHash: null, + demoPolicy: null, + announcements: [], + }, + mentor.token, + ); + + // 3. Topic (mentor creates) + await writeDoc( + `topics/${topicId}`, + { + id: topicId, + courseId, + title: "E2E Topic", + description: "Topic for e2e testing", + order: 1, + createdBy: mentor.uid, + createdAt: now, + updatedAt: now, + contents: [assignmentId], + contentTypes: ["assignment"], + }, + mentor.token, + ); + + // 4. Assignment (mentor creates) + await writeDoc( + `assignments/${assignmentId}`, + { + id: assignmentId, + courseId, + topicId, + orderInTopic: 1, + title: "AI Ethics Debate", + prompt: "Discuss the ethics of artificial intelligence.", + mode: "instant", + startAt: now - 86400000, + dueAt: now + 86400000, + allowLate: true, + allowResubmit: true, + createdBy: mentor.uid, + createdAt: now, + updatedAt: now, + }, + mentor.token, + ); + + // 5. Student creates their own submission (state: in_progress) + const baseSub = { + userId: student.uid, + state: "in_progress", + startedAt: now - 3600000, + submittedAt: null, + late: false, + scoreCompletion: null, + notes: null, + assessment: null, + assessmentError: null, + totalSpentUsd: 0, + budgetExhausted: false, + }; + await writeDoc( + `assignments/${assignmentId}/submissions/${student.uid}`, + baseSub, + student.token, + ); + + // 6. Student updates to submitted + await writeDoc( + `assignments/${assignmentId}/submissions/${student.uid}`, + { ...baseSub, state: "submitted", submittedAt: now - 1800000 }, + student.token, + ); + + // 7. Mentor updates submission with AI assessment + await writeDoc( + `assignments/${assignmentId}/submissions/${student.uid}`, + { + ...baseSub, + state: "submitted", + submittedAt: now - 1800000, + assessment: { + dimensions: { + argumentQuality: { + score: 4, + feedback: + "Strong logical reasoning with clear evidence.", + }, + criticalThinking: { + score: 3, + feedback: + "Good analysis but could explore more counterarguments.", + }, + principleExtraction: { + score: 4, + feedback: + "Successfully identified key ethical principles.", + }, + openness: { + score: 5, + feedback: + "Very open to considering alternative viewpoints.", + }, + coherence: { + score: 4, + feedback: "Well-structured and consistent argument.", + }, + }, + overallScore: 4, + overallFeedback: + "Excellent discussion showing strong analytical skills.", + generatedAt: now - 1000, + }, + }, + mentor.token, + ); + + // 8. Student creates conversation with turns + await writeDoc( + `conversations/${convId}`, + { + assignmentId, + userId: student.uid, + state: "closed", + lastActionAt: now - 1800000, + createdAt: now - 3600000, + updatedAt: now - 1800000, + tokenUsage: null, + turns: [ + { + id: "turn-1", + type: "topic", + text: "I believe AI should be regulated to prevent misuse, but we need to balance innovation with safety.", + createdAt: now - 3600000, + analysis: null, + pendingStartAt: null, + tokenUsage: null, + }, + { + id: "turn-2", + type: "idea", + text: "That is an interesting position. Can you think of a scenario where strict regulation might hinder beneficial AI development?", + createdAt: now - 3500000, + analysis: { stance: "pro-weak" }, + pendingStartAt: null, + tokenUsage: null, + }, + { + id: "turn-3", + type: "followup", + text: "In medical research, overly strict rules could slow down AI that helps diagnose diseases earlier. But I still think we need some guardrails.", + createdAt: now - 3400000, + analysis: null, + pendingStartAt: null, + tokenUsage: null, + }, + { + id: "turn-4", + type: "counterpoint", + text: "Good point about medical AI. However, without regulation, an AI misdiagnosis could cause serious harm. How do you weigh these risks?", + createdAt: now - 3300000, + analysis: { stance: "pro-weak" }, + pendingStartAt: null, + tokenUsage: null, + }, + ], + }, + student.token, + ); + + return { mentor, student, courseId, assignmentId }; +} diff --git a/packages/mentora-ai/src/builder/stage1-asking-stance.ts b/packages/mentora-ai/src/builder/stage1-asking-stance.ts index e032f17..9bbe0ca 100644 --- a/packages/mentora-ai/src/builder/stage1-asking-stance.ts +++ b/packages/mentora-ai/src/builder/stage1-asking-stance.ts @@ -23,6 +23,21 @@ export const AskingStanceClassifierSchema = z.object({ .object({ stance: z.string().optional().describe("The user's stated stance"), reasoning: z.string().optional().describe("The user's reasoning"), + stance_category: z + .enum([ + "pro-strong", + "pro-weak", + "con-strong", + "con-weak", + "neutral", + ]) + .optional() + .describe( + "Classify the user's stance relative to the topic question. " + + "'pro' = agrees with the proposition, 'con' = disagrees. " + + "'strong' = firm/definitive, 'weak' = tentative/partial. " + + "'neutral' = genuinely undecided or balanced.", + ), }) .describe("選填:若有提取到關鍵資訊放在這裡"), }); @@ -71,6 +86,13 @@ Rules: 1. If the input is too short, vague, irrelevant, or does not answer the specific question -> Output "TR_CLARIFY". 2. If the user expresses a clear stance (even if simple) -> Output "TR_V1_ESTABLISHED". +When outputting "TR_V1_ESTABLISHED", you MUST also classify the stance: +- "pro-strong": The user clearly and firmly agrees with the proposition (e.g., "Yes, absolutely", "I strongly support this") +- "pro-weak": The user leans toward agreeing but is tentative (e.g., "I think so", "Probably yes") +- "con-strong": The user clearly and firmly disagrees (e.g., "No, absolutely not", "I'm strongly against this") +- "con-weak": The user leans toward disagreeing but is tentative (e.g., "I don't think so", "Probably not") +- "neutral": The user is genuinely undecided or presents a balanced view + Analyze the user input below: Question: ${input.currentQuestion} User Input: ${input.userInput} diff --git a/packages/mentora-ai/src/builder/stage2-case-challenge.ts b/packages/mentora-ai/src/builder/stage2-case-challenge.ts index 387d165..026b2a1 100644 --- a/packages/mentora-ai/src/builder/stage2-case-challenge.ts +++ b/packages/mentora-ai/src/builder/stage2-case-challenge.ts @@ -31,6 +31,21 @@ export const CaseChallengeClassifierSchema = z.object({ .string() .optional() .describe("New or updated reasoning if applicable"), + stance_category: z + .enum([ + "pro-strong", + "pro-weak", + "con-strong", + "con-weak", + "neutral", + ]) + .optional() + .describe( + "Classify the user's CURRENT effective stance after this response. " + + "'pro' = agrees with the proposition, 'con' = disagrees. " + + "'strong' = firm/definitive, 'weak' = tentative/partial. " + + "'neutral' = genuinely undecided or balanced.", + ), }) .describe("選填:若有提取到關鍵資訊放在這裡"), }); @@ -90,6 +105,13 @@ Rules: 2. **TR_SCAFFOLD**: The user's answer contradicts their \`previous_stance\`, shows hesitation ("Maybe I was wrong"), or admits the counter-example is valid, implying a need to update their stance. 3. **TR_CASE_COMPLETED**: The user defends their stance logically, OR successfully integrates the case into their existing view without contradiction. +For ALL intents (not just TR_SCAFFOLD), you MUST classify the user's current effective stance in extracted_data.stance_category: +- "pro-strong": Firmly agrees with the proposition +- "pro-weak": Tentatively agrees +- "con-strong": Firmly disagrees +- "con-weak": Tentatively disagrees +- "neutral": Genuinely undecided or balanced + Context: Previous Stance: ${previousStance} Current Case Challenge: ${currentCase} diff --git a/packages/mentora-ai/src/orchestrator/handlers/asking-stance.ts b/packages/mentora-ai/src/orchestrator/handlers/asking-stance.ts index b9809fb..11e525c 100644 --- a/packages/mentora-ai/src/orchestrator/handlers/asking-stance.ts +++ b/packages/mentora-ai/src/orchestrator/handlers/asking-stance.ts @@ -129,7 +129,10 @@ export class AskingStanceHandler implements StageHandler { newState, ended: false, usage: executor.getTokenUsage(), - stanceSnapshot: { stance: "neutral" }, + stanceSnapshot: { + stance: + classification.extracted_data?.stance_category || "neutral", + }, }; } } diff --git a/packages/mentora-ai/src/orchestrator/handlers/case-challenge.ts b/packages/mentora-ai/src/orchestrator/handlers/case-challenge.ts index 2d4b1cf..b715f94 100644 --- a/packages/mentora-ai/src/orchestrator/handlers/case-challenge.ts +++ b/packages/mentora-ai/src/orchestrator/handlers/case-challenge.ts @@ -61,7 +61,7 @@ export class CaseChallengeHandler implements StageHandler { return this.handleScaffold(context, classification); case "TR_CASE_COMPLETED": - return this.handleCaseCompleted(context); + return this.handleCaseCompleted(context, classification); } } @@ -133,16 +133,15 @@ export class CaseChallengeHandler implements StageHandler { ); } - const stanceSnapshot = classification.extracted_data?.stance - ? { stance: "neutral" as const } - : { stance: "undetermined" as const }; - return { message, newState, ended: false, usage: executor.getTokenUsage(), - stanceSnapshot, + stanceSnapshot: { + stance: + classification.extracted_data?.stance_category || "neutral", + }, }; } @@ -151,6 +150,7 @@ export class CaseChallengeHandler implements StageHandler { */ private async handleCaseCompleted( context: StageContext, + classification: CaseChallengeClassifier, ): Promise { const { executor, state } = context; @@ -180,7 +180,10 @@ export class CaseChallengeHandler implements StageHandler { }, ended: false, usage: executor.getTokenUsage(), - stanceSnapshot: { stance: "neutral" }, + stanceSnapshot: { + stance: + classification.extracted_data?.stance_category || "neutral", + }, }; } } diff --git a/packages/mentora-ai/src/orchestrator/handlers/closure.ts b/packages/mentora-ai/src/orchestrator/handlers/closure.ts index 7220b16..dc17aeb 100644 --- a/packages/mentora-ai/src/orchestrator/handlers/closure.ts +++ b/packages/mentora-ai/src/orchestrator/handlers/closure.ts @@ -101,7 +101,6 @@ export class ClosureHandler implements StageHandler { }, ended: false, usage: executor.getTokenUsage(), - stanceSnapshot: { stance: "neutral" }, }; } @@ -157,7 +156,6 @@ export class ClosureHandler implements StageHandler { usage: executor.getTokenUsage(), assessment, assessmentError, - stanceSnapshot: { stance: "neutral" }, }; } } diff --git a/packages/mentora-ai/src/orchestrator/handlers/principle-reasoning.ts b/packages/mentora-ai/src/orchestrator/handlers/principle-reasoning.ts index 37d08dd..4594a0a 100644 --- a/packages/mentora-ai/src/orchestrator/handlers/principle-reasoning.ts +++ b/packages/mentora-ai/src/orchestrator/handlers/principle-reasoning.ts @@ -95,7 +95,6 @@ export class PrincipleReasoningHandler implements StageHandler { newState: transitionTo(state, DialogueStage.PRINCIPLE_REASONING), ended: false, usage: executor.getTokenUsage(), - stanceSnapshot: { stance: "undetermined" }, }; } @@ -127,7 +126,6 @@ export class PrincipleReasoningHandler implements StageHandler { newState: transitionTo(state, DialogueStage.PRINCIPLE_REASONING), ended: false, usage: executor.getTokenUsage(), - stanceSnapshot: { stance: "neutral" }, }; } @@ -179,7 +177,6 @@ export class PrincipleReasoningHandler implements StageHandler { }, ended: false, usage: executor.getTokenUsage(), - stanceSnapshot: { stance: "neutral" }, }; } @@ -234,7 +231,6 @@ export class PrincipleReasoningHandler implements StageHandler { }, ended: false, usage: executor.getTokenUsage(), - stanceSnapshot: { stance: "neutral" }, }; } }