@machinespirits/eval 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +161 -0
  3. package/config/eval-settings.yaml +18 -0
  4. package/config/evaluation-rubric-learner.yaml +277 -0
  5. package/config/evaluation-rubric.yaml +613 -0
  6. package/config/interaction-eval-scenarios.yaml +93 -50
  7. package/config/learner-agents.yaml +124 -193
  8. package/config/machinespirits-eval.code-workspace +11 -0
  9. package/config/providers.yaml +60 -0
  10. package/config/suggestion-scenarios.yaml +1399 -0
  11. package/config/tutor-agents.yaml +716 -0
  12. package/docs/EVALUATION-VARIABLES.md +589 -0
  13. package/docs/REPLICATION-PLAN.md +577 -0
  14. package/index.js +15 -6
  15. package/package.json +16 -22
  16. package/routes/evalRoutes.js +88 -36
  17. package/scripts/analyze-judge-reliability.js +401 -0
  18. package/scripts/analyze-run.js +97 -0
  19. package/scripts/analyze-run.mjs +282 -0
  20. package/scripts/analyze-validation-failures.js +141 -0
  21. package/scripts/check-run.mjs +17 -0
  22. package/scripts/code-impasse-strategies.js +1132 -0
  23. package/scripts/compare-runs.js +44 -0
  24. package/scripts/compare-suggestions.js +80 -0
  25. package/scripts/compare-transformation.js +116 -0
  26. package/scripts/dig-into-run.js +158 -0
  27. package/scripts/eval-cli.js +2626 -0
  28. package/scripts/generate-paper-figures.py +452 -0
  29. package/scripts/qualitative-analysis-ai.js +1313 -0
  30. package/scripts/qualitative-analysis.js +688 -0
  31. package/scripts/seed-db.js +87 -0
  32. package/scripts/show-failed-suggestions.js +64 -0
  33. package/scripts/validate-content.js +192 -0
  34. package/server.js +3 -2
  35. package/services/__tests__/evalConfigLoader.test.js +338 -0
  36. package/services/anovaStats.js +499 -0
  37. package/services/contentResolver.js +407 -0
  38. package/services/dialogueTraceAnalyzer.js +454 -0
  39. package/services/evalConfigLoader.js +625 -0
  40. package/services/evaluationRunner.js +2171 -270
  41. package/services/evaluationStore.js +564 -29
  42. package/services/learnerConfigLoader.js +75 -5
  43. package/services/learnerRubricEvaluator.js +284 -0
  44. package/services/learnerTutorInteractionEngine.js +375 -0
  45. package/services/processUtils.js +18 -0
  46. package/services/progressLogger.js +98 -0
  47. package/services/promptRecommendationService.js +31 -26
  48. package/services/promptRewriter.js +427 -0
  49. package/services/rubricEvaluator.js +543 -70
  50. package/services/streamingReporter.js +104 -0
  51. package/services/turnComparisonAnalyzer.js +494 -0
  52. package/components/MobileEvalDashboard.tsx +0 -267
  53. package/components/comparison/DeltaAnalysisTable.tsx +0 -137
  54. package/components/comparison/ProfileComparisonCard.tsx +0 -176
  55. package/components/comparison/RecognitionABMode.tsx +0 -385
  56. package/components/comparison/RecognitionMetricsPanel.tsx +0 -135
  57. package/components/comparison/WinnerIndicator.tsx +0 -64
  58. package/components/comparison/index.ts +0 -5
  59. package/components/mobile/BottomSheet.tsx +0 -233
  60. package/components/mobile/DimensionBreakdown.tsx +0 -210
  61. package/components/mobile/DocsView.tsx +0 -363
  62. package/components/mobile/LogsView.tsx +0 -481
  63. package/components/mobile/PsychodynamicQuadrant.tsx +0 -261
  64. package/components/mobile/QuickTestView.tsx +0 -1098
  65. package/components/mobile/RecognitionTypeChart.tsx +0 -124
  66. package/components/mobile/RecognitionView.tsx +0 -809
  67. package/components/mobile/RunDetailView.tsx +0 -261
  68. package/components/mobile/RunHistoryView.tsx +0 -367
  69. package/components/mobile/ScoreRadial.tsx +0 -211
  70. package/components/mobile/StreamingLogPanel.tsx +0 -230
  71. package/components/mobile/SynthesisStrategyChart.tsx +0 -140
  72. package/docs/research/ABLATION-DIALOGUE-ROUNDS.md +0 -52
  73. package/docs/research/ABLATION-MODEL-SELECTION.md +0 -53
  74. package/docs/research/ADVANCED-EVAL-ANALYSIS.md +0 -60
  75. package/docs/research/ANOVA-RESULTS-2026-01-14.md +0 -257
  76. package/docs/research/COMPREHENSIVE-EVALUATION-PLAN.md +0 -586
  77. package/docs/research/COST-ANALYSIS.md +0 -56
  78. package/docs/research/CRITICAL-REVIEW-RECOGNITION-TUTORING.md +0 -340
  79. package/docs/research/DYNAMIC-VS-SCRIPTED-ANALYSIS.md +0 -291
  80. package/docs/research/EVAL-SYSTEM-ANALYSIS.md +0 -306
  81. package/docs/research/FACTORIAL-RESULTS-2026-01-14.md +0 -301
  82. package/docs/research/IMPLEMENTATION-PLAN-CRITIQUE-RESPONSE.md +0 -1988
  83. package/docs/research/LONGITUDINAL-DYADIC-EVALUATION.md +0 -282
  84. package/docs/research/MULTI-JUDGE-VALIDATION-2026-01-14.md +0 -147
  85. package/docs/research/PAPER-EXTENSION-DYADIC.md +0 -204
  86. package/docs/research/PAPER-UNIFIED.md +0 -659
  87. package/docs/research/PAPER-UNIFIED.pdf +0 -0
  88. package/docs/research/PROMPT-IMPROVEMENTS-2026-01-14.md +0 -356
  89. package/docs/research/SESSION-NOTES-2026-01-11-RECOGNITION-EVAL.md +0 -419
  90. package/docs/research/apa.csl +0 -2133
  91. package/docs/research/archive/PAPER-DRAFT-RECOGNITION-TUTORING.md +0 -1637
  92. package/docs/research/archive/paper-multiagent-tutor.tex +0 -978
  93. package/docs/research/paper-draft/full-paper.md +0 -136
  94. package/docs/research/paper-draft/images/pasted-image-2026-01-24T03-47-47-846Z-d76a7ae2.png +0 -0
  95. package/docs/research/paper-draft/references.bib +0 -515
  96. package/docs/research/transcript-baseline.md +0 -139
  97. package/docs/research/transcript-recognition-multiagent.md +0 -187
  98. package/hooks/useEvalData.ts +0 -625
  99. package/server-init.js +0 -45
  100. package/services/benchmarkService.js +0 -1892
  101. package/types.ts +0 -165
  102. package/utils/haptics.ts +0 -45
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Prompt Rewriter Service
3
+ *
4
+ * Synthesizes session evolution directives for dynamic prompt rewriting.
5
+ * Two strategies available:
6
+ *
7
+ * 1. Template-based (synthesizeDirectives): Deterministic, cheap, pattern-matching
8
+ * 2. LLM-based (synthesizeDirectivesLLM): Uses superego model for rich, contextual directives
9
+ *
10
+ * Both analyze turn results, dialogue traces, and conversation history to
11
+ * generate session-specific directives that are prepended to ego's system prompt.
12
+ */
13
+
14
+ import { unifiedAIProvider } from '@machinespirits/tutor-core';
15
+
16
+ /**
17
+ * Synthesize directives from accumulated turn data.
18
+ *
19
+ * @param {Object} options
20
+ * @param {Array} options.turnResults - Results from previous turns
21
+ * @param {Array} options.consolidatedTrace - Full dialogue trace so far
22
+ * @param {Array} options.conversationHistory - Conversation history entries
23
+ * @returns {string|null} XML directive block to prepend, or null if no directives
24
+ */
25
+ export function synthesizeDirectives({ turnResults = [], consolidatedTrace = [], conversationHistory = [] }) {
26
+ if (turnResults.length === 0) return null;
27
+
28
+ const directives = [];
29
+
30
+ // 1. Score trajectory — detect quality decline
31
+ const scoreTrajectory = analyzeScoreTrajectory(turnResults);
32
+ if (scoreTrajectory) directives.push(scoreTrajectory);
33
+
34
+ // 2. Superego feedback — extract last critique
35
+ const superegoFeedback = extractSuperegoFeedback(consolidatedTrace, turnResults.length);
36
+ if (superegoFeedback) directives.push(superegoFeedback);
37
+
38
+ // 3. Learner question detection
39
+ const learnerQuestions = detectLearnerQuestions(conversationHistory);
40
+ if (learnerQuestions) directives.push(learnerQuestions);
41
+
42
+ // 4. Strategy stagnation — repeated suggestion types
43
+ const stagnation = detectStrategyStagnation(turnResults);
44
+ if (stagnation) directives.push(stagnation);
45
+
46
+ // 5. Recognition signals — learner contributions to build on
47
+ const recognitionSignals = detectRecognitionSignals(conversationHistory, turnResults);
48
+ if (recognitionSignals) directives.push(recognitionSignals);
49
+
50
+ if (directives.length === 0) return null;
51
+
52
+ const numbered = directives.map((d, i) => `${i + 1}. ${d}`).join('\n');
53
+ return `<session_evolution>
54
+ Based on the dialogue so far, prioritize the following in your next response:
55
+
56
+ ${numbered}
57
+ </session_evolution>`;
58
+ }
59
+
60
+ /**
61
+ * Analyze score trajectory across turns. If quality decreased, generate directive.
62
+ */
63
+ function analyzeScoreTrajectory(turnResults) {
64
+ if (turnResults.length < 2) return null;
65
+
66
+ const scores = turnResults.filter(t => t.turnScore !== null && t.turnScore !== undefined).map(t => t.turnScore);
67
+ if (scores.length < 2) return null;
68
+
69
+ const last = scores[scores.length - 1];
70
+ const prev = scores[scores.length - 2];
71
+ const delta = last - prev;
72
+
73
+ if (delta < -5) {
74
+ // Find what scored well in the earlier turn
75
+ const bestPrevDim = findBestDimension(turnResults[turnResults.length - 2]);
76
+ const worstCurrDim = findWorstDimension(turnResults[turnResults.length - 1]);
77
+
78
+ let directive = `Quality declined from the previous turn (${prev.toFixed(0)} → ${last.toFixed(0)}).`;
79
+ if (bestPrevDim) directive += ` Your ${bestPrevDim} was strongest before — maintain that approach.`;
80
+ if (worstCurrDim) directive += ` Focus on improving ${worstCurrDim}.`;
81
+ return directive;
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Extract the most recent superego feedback from the dialogue trace.
89
+ */
90
+ function extractSuperegoFeedback(consolidatedTrace, turnCount) {
91
+ if (!consolidatedTrace || consolidatedTrace.length === 0) return null;
92
+
93
+ // Find the last superego entry from the most recent turn
94
+ const superegoEntries = consolidatedTrace.filter(
95
+ entry => entry.agent === 'superego' && entry.action !== 'deliberation'
96
+ );
97
+
98
+ if (superegoEntries.length === 0) return null;
99
+
100
+ const lastEntry = superegoEntries[superegoEntries.length - 1];
101
+ const feedback = lastEntry.contextSummary || lastEntry.detail;
102
+ if (!feedback) return null;
103
+
104
+ // Look for rejection or revision feedback
105
+ const detail = lastEntry.detail || '';
106
+ const isRejection = detail.includes('"approved": false') || detail.includes('"approved":false');
107
+ const hasRevisions = detail.includes('"specificRevisions"') || detail.includes('"revise"');
108
+
109
+ if (isRejection || hasRevisions) {
110
+ // Extract the feedback text
111
+ const feedbackMatch = detail.match(/"feedback"\s*:\s*"([^"]+)"/);
112
+ if (feedbackMatch) {
113
+ return `The internal critic flagged an issue in your last response: "${feedbackMatch[1].substring(0, 150)}". Address this concern in your next suggestion.`;
114
+ }
115
+ return `The internal critic requested revisions on your last response. Ensure your next suggestion addresses its feedback.`;
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Detect if the learner asked direct questions that need answering.
123
+ */
124
+ function detectLearnerQuestions(conversationHistory) {
125
+ if (conversationHistory.length === 0) return null;
126
+
127
+ const lastEntry = conversationHistory[conversationHistory.length - 1];
128
+ const message = lastEntry.learnerMessage || '';
129
+
130
+ if (!message) return null;
131
+
132
+ // Check for question marks or question-like patterns
133
+ const hasQuestionMark = message.includes('?');
134
+ const hasQuestionWords = /\b(what|why|how|when|where|which|can|could|would|should|do|does|is|are)\b/i.test(message);
135
+
136
+ if (hasQuestionMark || (hasQuestionWords && message.length > 20)) {
137
+ // Extract the likely question
138
+ const sentences = message.split(/[.!?]+/).filter(s => s.trim().length > 0);
139
+ const questions = sentences.filter(s => s.includes('?') || /^\s*(what|why|how|when|where|which)\b/i.test(s));
140
+
141
+ if (questions.length > 0) {
142
+ const firstQuestion = questions[0].trim().substring(0, 120);
143
+ return `The learner asked a direct question: "${firstQuestion}". Your response must address this question specifically before suggesting new content.`;
144
+ }
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ /**
151
+ * Detect if the tutor is repeating the same suggestion strategy.
152
+ */
153
+ function detectStrategyStagnation(turnResults) {
154
+ if (turnResults.length < 3) return null;
155
+
156
+ const recentTypes = turnResults.slice(-3).map(t => t.suggestion?.type).filter(Boolean);
157
+ if (recentTypes.length < 3) return null;
158
+
159
+ const allSame = recentTypes.every(t => t === recentTypes[0]);
160
+ if (allSame) {
161
+ return `You have suggested "${recentTypes[0]}" type content for the last ${recentTypes.length} turns. Consider varying your approach — try a different suggestion type (e.g., reflection, simulation, review) to maintain engagement.`;
162
+ }
163
+
164
+ // Check if action targets are too similar (same lecture family)
165
+ const recentTargets = turnResults.slice(-3).map(t => t.suggestion?.actionTarget).filter(Boolean);
166
+ if (recentTargets.length >= 3) {
167
+ const targetPrefixes = recentTargets.map(t => t.split('-').slice(0, 2).join('-'));
168
+ if (targetPrefixes.every(p => p === targetPrefixes[0])) {
169
+ return `Your last ${recentTargets.length} suggestions all pointed to the same course section. Consider broadening — connect to related content in other courses or suggest a different modality (simulation, journal, text analysis).`;
170
+ }
171
+ }
172
+
173
+ return null;
174
+ }
175
+
176
+ /**
177
+ * Detect recognition signals from the learner worth building on.
178
+ */
179
+ function detectRecognitionSignals(conversationHistory, turnResults) {
180
+ if (conversationHistory.length === 0) return null;
181
+
182
+ const lastEntry = conversationHistory[conversationHistory.length - 1];
183
+ const message = lastEntry.learnerMessage || '';
184
+ if (!message) return null;
185
+
186
+ const signals = [];
187
+
188
+ // Check for learner offering their own interpretation/metaphor
189
+ const interpretationPatterns = [
190
+ /\bi think\b/i,
191
+ /\bit seems like\b/i,
192
+ /\bmaybe\s+it['']?s\b/i,
193
+ /\bwhat if\b/i,
194
+ /\bkind of like\b/i,
195
+ /\bsort of like\b/i,
196
+ /\breminds me of\b/i,
197
+ /\bis like\b/i,
198
+ ];
199
+
200
+ const hasInterpretation = interpretationPatterns.some(p => p.test(message));
201
+ if (hasInterpretation) {
202
+ signals.push('offered their own interpretation');
203
+ }
204
+
205
+ // Check for learner pushback / critique
206
+ const pushbackPatterns = [
207
+ /\bbut\s+(what about|doesn['']t|isn['']t|that doesn['']t)\b/i,
208
+ /\bi disagree\b/i,
209
+ /\bi don['']t think\b/i,
210
+ /\bthat['']s not\b/i,
211
+ /\bdoesn['']t (apply|work|make sense)\b/i,
212
+ ];
213
+
214
+ const hasPushback = pushbackPatterns.some(p => p.test(message));
215
+ if (hasPushback) {
216
+ signals.push('pushed back with a substantive critique');
217
+ }
218
+
219
+ // Check for concept connections
220
+ const connectionPatterns = [
221
+ /\bconnects to\b/i,
222
+ /\brelated to\b/i,
223
+ /\bsimilar to\b/i,
224
+ /\bjust like\b/i,
225
+ /\bthis is like\b/i,
226
+ ];
227
+
228
+ const hasConnection = connectionPatterns.some(p => p.test(message));
229
+ if (hasConnection) {
230
+ signals.push('connected concepts across topics');
231
+ }
232
+
233
+ if (signals.length === 0) return null;
234
+
235
+ const signalList = signals.join(', ');
236
+ const snippet = message.substring(0, 100);
237
+ return `The learner ${signalList} ("${snippet}..."). Build on their contribution — acknowledge their specific language and develop it further rather than redirecting.`;
238
+ }
239
+
240
+ /**
241
+ * Find the best-scoring dimension for a turn result.
242
+ */
243
+ function findBestDimension(turnResult) {
244
+ if (!turnResult?.scores) return null;
245
+ let best = null;
246
+ let bestScore = -Infinity;
247
+ for (const [dim, score] of Object.entries(turnResult.scores)) {
248
+ if (score != null && score > bestScore) {
249
+ bestScore = score;
250
+ best = dim;
251
+ }
252
+ }
253
+ return best;
254
+ }
255
+
256
+ /**
257
+ * Find the worst-scoring dimension for a turn result.
258
+ */
259
+ function findWorstDimension(turnResult) {
260
+ if (!turnResult?.scores) return null;
261
+ let worst = null;
262
+ let worstScore = Infinity;
263
+ for (const [dim, score] of Object.entries(turnResult.scores)) {
264
+ if (score != null && score < worstScore) {
265
+ worstScore = score;
266
+ worst = dim;
267
+ }
268
+ }
269
+ return worst;
270
+ }
271
+
272
+ // ============================================================================
273
+ // LLM-Based Directive Synthesis
274
+ // ============================================================================
275
+
276
+ /**
277
+ * Synthesize directives using an LLM for contextually rich evolution guidance.
278
+ *
279
+ * Unlike the template-based approach, this uses the superego model to analyze
280
+ * the full dialogue context and generate targeted, non-generic directives.
281
+ *
282
+ * @param {Object} options
283
+ * @param {Array} options.turnResults - Results from previous turns
284
+ * @param {Array} options.consolidatedTrace - Full dialogue trace so far
285
+ * @param {Array} options.conversationHistory - Conversation history entries
286
+ * @param {Object} options.config - Profile config containing model info
287
+ * @returns {Promise<string|null>} XML directive block to prepend, or null if synthesis fails
288
+ */
289
+ export async function synthesizeDirectivesLLM({
290
+ turnResults = [],
291
+ consolidatedTrace = [],
292
+ conversationHistory = [],
293
+ config = {},
294
+ }) {
295
+ if (turnResults.length === 0) return null;
296
+
297
+ // Build context summary for the LLM
298
+ const contextSummary = buildContextSummaryForLLM(turnResults, consolidatedTrace, conversationHistory);
299
+
300
+ // Determine model to use (superego model from profile, or fallback)
301
+ const superegoModel = config.superego?.model || 'moonshotai/kimi-k2.5';
302
+ const provider = config.superego?.provider || 'openrouter';
303
+
304
+ const systemPrompt = `You are a pedagogical analyst reviewing an ongoing tutoring dialogue. Your task is to synthesize 2-5 specific, actionable directives that will help the tutor improve in the next turn.
305
+
306
+ CRITICAL RULES:
307
+ - Directives must be SPECIFIC to this dialogue — reference actual learner statements, scores, and patterns
308
+ - Avoid generic advice like "be more engaging" or "personalize responses"
309
+ - Each directive should address a concrete issue or opportunity observed in the data
310
+ - Directives should build on what's working, not just fix problems
311
+ - If the dialogue is going well, focus on deepening rather than correcting
312
+
313
+ OUTPUT FORMAT:
314
+ Return ONLY a numbered list of 2-5 directives, one per line. No preamble, no explanation after.
315
+
316
+ Example output:
317
+ 1. The learner's analogy comparing dialectics to "debugging code" in turn 2 shows technical framing — extend this metaphor when introducing Aufhebung.
318
+ 2. Score trajectory shows personalization dropping (87→71). The last response addressed "students" generally — use the learner's name and reference their earlier question about AI ethics.
319
+ 3. Superego flagged lack of curriculum grounding. The learner is exploring emergence — connect to 479-lecture-5 which covers complexity and emergent properties.`;
320
+
321
+ const userMessage = `Analyze this tutoring dialogue and generate evolution directives:
322
+
323
+ ${contextSummary}
324
+
325
+ Generate 2-5 specific directives for the next turn:`;
326
+
327
+ try {
328
+ const response = await unifiedAIProvider.call({
329
+ provider,
330
+ model: superegoModel,
331
+ systemPrompt,
332
+ messages: [{ role: 'user', content: userMessage }],
333
+ preset: 'deliberation',
334
+ config: {
335
+ temperature: 0.3, // Lower temp for focused analysis
336
+ maxTokens: 500,
337
+ },
338
+ });
339
+
340
+ const directives = response.content?.trim();
341
+ if (!directives || directives.length < 20) {
342
+ console.log('[promptRewriter] LLM returned empty or too-short directives');
343
+ return null;
344
+ }
345
+
346
+ // Wrap in session_evolution XML block
347
+ return `<session_evolution>
348
+ Based on analysis of the dialogue so far, prioritize the following in your next response:
349
+
350
+ ${directives}
351
+ </session_evolution>`;
352
+ } catch (error) {
353
+ console.error('[promptRewriter] LLM directive synthesis failed:', error.message);
354
+ // Fallback to template-based directives
355
+ return synthesizeDirectives({ turnResults, consolidatedTrace, conversationHistory });
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Build a structured context summary for the LLM to analyze.
361
+ */
362
+ function buildContextSummaryForLLM(turnResults, consolidatedTrace, conversationHistory) {
363
+ const parts = [];
364
+
365
+ // 1. Score trajectory
366
+ const scores = turnResults
367
+ .filter(t => t.turnScore !== null && t.turnScore !== undefined)
368
+ .map((t, i) => `Turn ${i + 1}: ${t.turnScore.toFixed(1)}`);
369
+ if (scores.length > 0) {
370
+ parts.push(`## Score Trajectory\n${scores.join(' → ')}`);
371
+ }
372
+
373
+ // 2. Dimension breakdown for last turn
374
+ const lastTurn = turnResults[turnResults.length - 1];
375
+ if (lastTurn?.scores) {
376
+ const dimScores = Object.entries(lastTurn.scores)
377
+ .filter(([_, v]) => v != null)
378
+ .map(([k, v]) => ` - ${k}: ${v}`)
379
+ .join('\n');
380
+ if (dimScores) {
381
+ parts.push(`## Last Turn Dimension Scores\n${dimScores}`);
382
+ }
383
+ }
384
+
385
+ // 3. Superego feedback from trace
386
+ const superegoFeedback = consolidatedTrace
387
+ .filter(e => e.agent === 'superego')
388
+ .slice(-3) // Last 3 superego entries
389
+ .map(e => {
390
+ const summary = e.contextSummary || '';
391
+ const detail = e.detail || '';
392
+ // Extract key feedback
393
+ const feedbackMatch = detail.match(/"feedback"\s*:\s*"([^"]+)"/);
394
+ return feedbackMatch ? feedbackMatch[1].substring(0, 200) : summary.substring(0, 200);
395
+ })
396
+ .filter(Boolean);
397
+ if (superegoFeedback.length > 0) {
398
+ parts.push(`## Recent Superego Feedback\n${superegoFeedback.map((f, i) => `${i + 1}. ${f}`).join('\n')}`);
399
+ }
400
+
401
+ // 4. Conversation history (learner messages)
402
+ const learnerMsgs = conversationHistory
403
+ .filter(h => h.learnerMessage)
404
+ .slice(-3) // Last 3 learner messages
405
+ .map((h, i) => `Turn ${h.turnIndex + 1}: "${h.learnerMessage.substring(0, 150)}${h.learnerMessage.length > 150 ? '...' : ''}"`);
406
+ if (learnerMsgs.length > 0) {
407
+ parts.push(`## Recent Learner Messages\n${learnerMsgs.join('\n')}`);
408
+ }
409
+
410
+ // 5. Tutor suggestion types
411
+ const suggTypes = turnResults
412
+ .filter(t => t.suggestion?.type)
413
+ .map((t, i) => `Turn ${i + 1}: ${t.suggestion.type}${t.suggestion.actionTarget ? ` (${t.suggestion.actionTarget})` : ''}`);
414
+ if (suggTypes.length > 0) {
415
+ parts.push(`## Tutor Suggestion Types\n${suggTypes.join('\n')}`);
416
+ }
417
+
418
+ // 6. Learner emotional state if available
419
+ const emotionalStates = turnResults
420
+ .filter(t => t.learnerEmotionalState)
421
+ .map((t, i) => `Turn ${i + 1}: ${t.learnerEmotionalState}`);
422
+ if (emotionalStates.length > 0) {
423
+ parts.push(`## Learner Emotional States\n${emotionalStates.join('\n')}`);
424
+ }
425
+
426
+ return parts.join('\n\n');
427
+ }