feat(*): Improve the display of tool usage

This commit is contained in:
Josh Creek
2025-04-21 16:54:52 +01:00
parent 76fcdac563
commit 4f5babbe23
10 changed files with 381 additions and 189 deletions
+7 -1
View File
@@ -1,7 +1,13 @@
import { ToolUtility } from '@azure/ai-projects';
export const conceptVerifierToolMeta = {
key: 'verifyConceptCoverage',
userDescription: 'Checking for required concepts from the rubric',
icon: '📋',
};
export const conceptVerifierTool = ToolUtility.createFunctionTool({
name: 'verifyConceptCoverage',
name: conceptVerifierToolMeta.key,
description: 'Checks which rubriclisted concepts appear (or are missing) in the submission.',
parameters: {
type: 'object',
+7 -1
View File
@@ -1,7 +1,13 @@
import { ToolUtility } from '@azure/ai-projects';
export const essayAnalyzerToolMeta = {
key: 'analyzeEssay',
userDescription: 'Analyzing essay structure and argument quality',
icon: '📝',
};
export const essayAnalyzerTool = ToolUtility.createFunctionTool({
name: 'analyzeEssay',
name: essayAnalyzerToolMeta.key,
description: 'Analyzes an essay for structure, argument, and evidence.',
parameters: {
type: 'object',
+7 -1
View File
@@ -1,7 +1,13 @@
import { ToolUtility } from '@azure/ai-projects';
export const feedbackGeneratorToolMeta = {
key: 'generateFeedback',
userDescription: 'Generating personalized feedback for the student',
icon: '🌟',
};
export const feedbackGeneratorTool = ToolUtility.createFunctionTool({
name: 'generateFeedback',
name: feedbackGeneratorToolMeta.key,
description: 'Produces two praise points and two actionable “try next time” suggestions based on submission and rubric.',
parameters: {
type: 'object',
+7 -7
View File
@@ -1,7 +1,7 @@
export { rubricMatcherTool } from './rubricMatcherTool';
export { essayAnalyzerTool } from './essayAnalyzerTool';
export { conceptVerifierTool } from './conceptVerifierTool';
export { feedbackGeneratorTool } from './feedbackGeneratorTool';
export { metacognitiveReflectionPromptTool } from './metacognitiveReflectionPromptTool';
export { selfAssessmentTool } from './selfAssessmentTool';
export { spellingAndGrammarCheckerTool } from './spellingAndGrammarCheckerTool';
export { rubricMatcherTool, rubricMatcherToolMeta } from './rubricMatcherTool';
export { essayAnalyzerTool, essayAnalyzerToolMeta } from './essayAnalyzerTool';
export { conceptVerifierTool, conceptVerifierToolMeta } from './conceptVerifierTool';
export { feedbackGeneratorTool, feedbackGeneratorToolMeta } from './feedbackGeneratorTool';
export { metacognitiveReflectionPromptTool, metacognitiveReflectionPromptToolMeta } from './metacognitiveReflectionPromptTool';
export { selfAssessmentTool, selfAssessmentToolMeta } from './selfAssessmentTool';
export { spellingAndGrammarCheckerTool, spellingAndGrammarCheckerToolMeta } from './spellingAndGrammarCheckerTool';
@@ -1,7 +1,13 @@
import { ToolUtility } from '@azure/ai-projects';
export const metacognitiveReflectionPromptToolMeta = {
key: 'generateReflectionPrompts',
userDescription: 'Suggesting ways the student can improve next time',
icon: '💭',
};
export const metacognitiveReflectionPromptTool = ToolUtility.createFunctionTool({
name: 'generateReflectionPrompts',
name: metacognitiveReflectionPromptToolMeta.key,
description: 'Based on rubric shortfalls, generates prompts like “How could you strengthen your evidence in paragraph 2?”',
parameters: {
type: 'object',
+7 -1
View File
@@ -1,7 +1,13 @@
import { ToolUtility } from '@azure/ai-projects';
export const rubricMatcherToolMeta = {
key: 'matchRubric',
userDescription: 'Assessing the submission against the provided rubric',
icon: '📊',
};
export const rubricMatcherTool = ToolUtility.createFunctionTool({
name: 'matchRubric',
name: rubricMatcherToolMeta.key,
description: 'Matches a submission to a rubric and returns a score/criteria breakdown.',
parameters: {
type: 'object',
+7 -1
View File
@@ -1,7 +1,13 @@
import { ToolUtility } from '@azure/ai-projects';
export const selfAssessmentToolMeta = {
key: 'createSelfAssessment',
userDescription: 'Creating self-reflection questions for the student',
icon: '🔎',
};
export const selfAssessmentTool = ToolUtility.createFunctionTool({
name: 'createSelfAssessment',
name: selfAssessmentToolMeta.key,
description: 'Generates 3 short questions that prompt the student to reflect on their own work based on rubric gaps.',
parameters: {
type: 'object',
@@ -1,7 +1,13 @@
import { ToolUtility } from '@azure/ai-projects';
export const spellingAndGrammarCheckerToolMeta = {
key: 'checkSpellingAndGrammar',
userDescription: 'Checking for spelling and grammar mistakes',
icon: '📝',
};
export const spellingAndGrammarCheckerTool = ToolUtility.createFunctionTool({
name: 'checkSpellingAndGrammar',
name: spellingAndGrammarCheckerToolMeta.key,
description: 'Identifies spelling, punctuation, and syntax issues in the submission and suggests corrections.',
parameters: {
type: 'object',
+145
View File
@@ -0,0 +1,145 @@
<script lang="ts">
import {
rubricMatcherToolMeta,
essayAnalyzerToolMeta,
conceptVerifierToolMeta,
feedbackGeneratorToolMeta,
metacognitiveReflectionPromptToolMeta,
selfAssessmentToolMeta,
spellingAndGrammarCheckerToolMeta
} from '$lib/agent/tools';
export let toolEvents: Array<{ tool: string, time: string }> = [];
// Map keys to meta objects for lookup
const TOOL_META_MAP: Record<string, { userDescription: string; icon: string }> = {
[rubricMatcherToolMeta.key]: rubricMatcherToolMeta,
[essayAnalyzerToolMeta.key]: essayAnalyzerToolMeta,
[conceptVerifierToolMeta.key]: conceptVerifierToolMeta,
[feedbackGeneratorToolMeta.key]: feedbackGeneratorToolMeta,
[metacognitiveReflectionPromptToolMeta.key]: metacognitiveReflectionPromptToolMeta,
[selfAssessmentToolMeta.key]: selfAssessmentToolMeta,
[spellingAndGrammarCheckerToolMeta.key]: spellingAndGrammarCheckerToolMeta
};
// Build steps from toolEvents, filling in status, icon, and userDescription
$: steps = toolEvents.map((event, idx) => {
const meta = TOOL_META_MAP[event.tool];
return {
description: meta ? meta.userDescription : event.tool,
icon: meta ? meta.icon : '🤖',
status: idx < toolEvents.length - 1 ? 'done' : 'running',
time: event.time
};
});
// If no events yet, show agent 'thinking'
$: agentThinking = toolEvents.length === 0;
$: statusMessage = agentThinking
? 'The AI Agent is analyzing your submission and selecting the best tools...'
: 'The AI Agent is now processing your submission...';
$: liveMessage = agentThinking
? statusMessage
: steps.length > 0
? steps[steps.length - 1].description
: statusMessage;
</script>
<style>
.agent-container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 2rem;
}
.agent-avatar {
font-size: 3rem;
margin-bottom: 1rem;
user-select: none;
}
.status-message {
font-weight: 600;
margin-bottom: 1.5rem;
text-align: center;
}
.progress-bar-bg {
width: 100%;
max-width: 400px;
height: 10px;
background: #e0e7ef;
border-radius: 5px;
margin-bottom: 2rem;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #5b9df9 0%, #4ade80 100%);
transition: width 0.5s;
}
ul.steps {
list-style: none;
padding: 0;
width: 100%;
max-width: 400px;
}
li.step {
display: flex;
align-items: center;
padding: 0.75rem 0.5rem;
border-radius: 6px;
margin-bottom: 0.5rem;
background: #f8fafc;
font-size: 1.05rem;
outline: none;
}
li.step[aria-current="step"] {
background: #e0f2fe;
font-weight: bold;
}
.step-icon {
margin-right: 1rem;
font-size: 1.5rem;
min-width: 2rem;
text-align: center;
}
.visually-hidden {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>
<div class="agent-container" role="region" aria-label="Submission Processing">
<div class="agent-avatar" aria-hidden="true">🤖</div>
<div class="status-message" id="agent-status">{statusMessage}</div>
<div class="progress-bar-bg" aria-hidden="true">
<div
class="progress-bar-fill"
style="width: {steps.length ? ((steps.filter(s => s.status === 'done').length) / (steps.length)) * 100 : 0}%"
></div>
</div>
<div aria-live="polite" class="visually-hidden">{liveMessage}</div>
<ul class="steps" aria-label="Processing steps">
{#if agentThinking}
<li class="step" aria-current="step">
<span class="step-icon" aria-hidden="true">💡</span>
<span>AI Agent is analyzing your submission…</span>
</li>
{:else}
{#each steps as step, idx}
<li class="step {step.status}">
<span class="step-icon" aria-hidden="true">{step.icon}</span>
<span class="step-desc">{step.description}</span>
<span class="step-time">{new Date(step.time).toLocaleTimeString()}
{step.status === 'done'
? '✅'
: step.status === 'running'
? '⏳'
: '🛠️'}
</span>
</li>
{/each}
{/if}
</ul>
</div>
+180 -175
View File
@@ -1,186 +1,191 @@
<script lang="ts">
import { onMount } from 'svelte';
import AgenticProgress from '$lib/components/AgenticProgress.svelte';
let file: File | null = null;
let textInput = '';
let taskDescription = '';
let submitting = false;
let successMsg = '';
let errorMsg = '';
let toolEvents: Array<{ tool: string, time: string }> = [];
let roomId: string = '';
let ws: WebSocket | null = null;
const PARTYKIT_BASE_URL = import.meta.env.VITE_PARTYKIT_BASE_URL;
let file: File | null = null;
let textInput = '';
let taskDescription = '';
let submitting = false;
let successMsg = '';
let errorMsg = '';
let toolEvents: Array<{ tool: string; time: string }> = [];
let roomId: string = '';
let ws: WebSocket | null = null;
const PARTYKIT_BASE_URL = import.meta.env.VITE_PARTYKIT_BASE_URL;
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
file = target.files[0];
textInput = '';
} else {
file = null;
}
successMsg = '';
errorMsg = '';
}
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
file = target.files[0];
textInput = '';
} else {
file = null;
}
successMsg = '';
errorMsg = '';
}
function handleTextChange(event: Event) {
const target = event.target as HTMLTextAreaElement;
textInput = target.value;
if (textInput.length > 0) file = null;
successMsg = '';
errorMsg = '';
}
function handleTextChange(event: Event) {
const target = event.target as HTMLTextAreaElement;
textInput = target.value;
if (textInput.length > 0) file = null;
successMsg = '';
errorMsg = '';
}
async function handleSubmit(event: Event) {
event.preventDefault();
submitting = true;
successMsg = '';
errorMsg = '';
toolEvents = [];
async function handleSubmit(event: Event) {
event.preventDefault();
submitting = true;
successMsg = '';
errorMsg = '';
toolEvents = [];
// Generate a unique roomId for this submission
roomId = crypto.randomUUID();
// Connect to PartyKit for this session
if (ws) ws.close();
ws = new WebSocket(`${PARTYKIT_BASE_URL}/party/tool-usage-server-${roomId}`);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
toolEvents = [...toolEvents, data];
} catch {}
};
// Generate a unique roomId for this submission
roomId = crypto.randomUUID();
// Connect to PartyKit for this session
if (ws) ws.close();
ws = new WebSocket(`${PARTYKIT_BASE_URL}/party/tool-usage-server-${roomId}`);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
toolEvents = [...toolEvents, data];
} catch {}
};
if (!taskDescription.trim()) {
errorMsg = 'Please provide a description of the task or assignment.';
submitting = false;
return;
}
const formData = new FormData();
formData.append('task', taskDescription.trim());
if (file) {
formData.append('file', file);
} else if (textInput.trim().length > 0) {
formData.append('text', textInput.trim());
}
formData.append('roomId', roomId);
errorMsg = '';
try {
const response = await fetch('/api/grade', {
method: 'POST',
body: formData
});
let result;
try {
result = await response.json();
} catch {
errorMsg = 'Received an invalid response from the server.';
submitting = false;
return;
}
if (response.ok && result.success) {
// Save to localStorage history
try {
let prev = JSON.parse(localStorage.getItem('assessmentHistory') || '[]');
const entry = {
timestamp: Date.now(),
...result,
submission: result.submission || textInput || '',
task: taskDescription,
};
prev.unshift(entry);
localStorage.setItem('assessmentHistory', JSON.stringify(prev.slice(0, 50)));
} catch {}
// Redirect to /results
window.location.assign('/results');
file = null;
textInput = '';
taskDescription = '';
return;
} else {
if (result && result.error) {
errorMsg = `Sorry, something went wrong: ${result.error}`;
} else if (response.status === 413) {
errorMsg = 'The uploaded file is too large. Please upload a smaller file.';
} else if (response.status >= 500) {
errorMsg = 'The server encountered an error. Please try again later.';
} else {
errorMsg = 'An unknown error occurred. Please check your input and try again.';
}
}
} catch (err) {
errorMsg = 'Network or server error. Please check your connection and try again.';
} finally {
submitting = false;
}
}
if (!taskDescription.trim()) {
errorMsg = 'Please provide a description of the task or assignment.';
submitting = false;
return;
}
const formData = new FormData();
formData.append('task', taskDescription.trim());
if (file) {
formData.append('file', file);
} else if (textInput.trim().length > 0) {
formData.append('text', textInput.trim());
}
formData.append('roomId', roomId);
errorMsg = '';
try {
const response = await fetch('/api/grade', {
method: 'POST',
body: formData
});
let result;
try {
result = await response.json();
} catch {
errorMsg = 'Received an invalid response from the server.';
submitting = false;
return;
}
if (response.ok && result.success) {
// Save to localStorage history
try {
let prev = JSON.parse(localStorage.getItem('assessmentHistory') || '[]');
const entry = {
timestamp: Date.now(),
...result,
submission: result.submission || textInput || '',
task: taskDescription
};
prev.unshift(entry);
localStorage.setItem('assessmentHistory', JSON.stringify(prev.slice(0, 50)));
} catch {}
// Redirect to /results
window.location.assign('/results');
file = null;
textInput = '';
taskDescription = '';
return;
} else {
if (result && result.error) {
errorMsg = `Sorry, something went wrong: ${result.error}`;
} else if (response.status === 413) {
errorMsg = 'The uploaded file is too large. Please upload a smaller file.';
} else if (response.status >= 500) {
errorMsg = 'The server encountered an error. Please try again later.';
} else {
errorMsg = 'An unknown error occurred. Please check your input and try again.';
}
}
} catch (err) {
errorMsg = 'Network or server error. Please check your connection and try again.';
} finally {
submitting = false;
}
}
</script>
<section class="max-w-xl mx-auto py-12 relative">
<h1 class="text-2xl font-bold mb-4">Upload Student Submissions</h1>
<p class="mb-6">Upload student assignments for instant AI-powered assessment and feedback.</p>
{#if errorMsg}
<div class="mb-4 p-3 border border-red-300 bg-red-50 text-red-700 rounded" aria-live="assertive" role="alert">
<strong>Error:</strong> {errorMsg}
</div>
{/if}
{#if submitting}
<div id="submitting-container" class="absolute inset-0 flex flex-col items-center justify-center bg-white bg-opacity-80 z-20" role="status" aria-busy="true" aria-live="polite" tabindex="-1">
<svg class="animate-spin h-10 w-10 text-blue-600 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
<span class="sr-only">Loading...</span>
<span class="text-blue-700 font-semibold">Grading in progress...</span>
<div class="mt-4 w-full max-w-md">
{#each toolEvents as event, i}
<div class="flex items-center space-x-2 text-sm text-gray-700 bg-blue-50 rounded p-2 my-1">
<span>🛠️ {event.tool}</span>
<span class="text-xs text-gray-400">{new Date(event.time).toLocaleTimeString()}</span>
</div>
{/each}
</div>
</div>
{/if}
<form class="space-y-6" on:submit|preventDefault={handleSubmit}>
<div class="mb-4">
<label class="block mb-1 font-semibold" for="task">Task/Assignment Description <span class="text-red-500">*</span></label>
<textarea id="task" class="w-full border rounded p-2" rows="2" bind:value={taskDescription} required aria-required="true" aria-label="Task or Assignment Description" disabled={submitting}></textarea>
</div>
<div class="mb-4">
<label class="block font-semibold mb-1" for="file">Upload a file (PDF, DOCX, TXT):</label>
<input
id="file"
type="file"
accept=".pdf,.doc,.docx,.txt"
class="block w-full border p-2 rounded"
on:change={handleFileChange}
disabled={submitting}
/>
<span class="text-gray-500 text-sm">Or paste the student's submission below.</span>
</div>
<div class="mb-4">
<label class="block font-semibold mb-1" for="textInput">Paste assignment text:</label>
<textarea
id="textInput"
rows="5"
class="w-full border p-2 rounded"
bind:value={textInput}
on:input={handleTextChange}
disabled={submitting}
placeholder="Paste or type student answer here..."
></textarea>
</div>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 w-full mt-4" disabled={submitting || (!file && textInput.trim().length === 0)}>
{submitting ? 'Uploading...' : 'Submit'}
</button>
{#if errorMsg}
<div class="mt-4 text-red-600">{errorMsg}</div>
{/if}
{#if successMsg}
<div class="mt-4 text-green-600">{successMsg}</div>
{/if}
</form>
<section class="relative mx-auto max-w-xl py-12">
<h1 class="mb-4 text-2xl font-bold">Upload Student Submissions</h1>
<p class="mb-6">Upload student assignments for instant AI-powered assessment and feedback.</p>
{#if errorMsg}
<div
class="mb-4 rounded border border-red-300 bg-red-50 p-3 text-red-700"
aria-live="assertive"
role="alert"
>
<strong>Error:</strong>
{errorMsg}
</div>
{/if}
{#if submitting}
<AgenticProgress {toolEvents} />
{:else}
<form class="space-y-6" on:submit|preventDefault={handleSubmit}>
<div class="mb-4">
<label class="mb-1 block font-semibold" for="task"
>Task/Assignment Description <span class="text-red-500">*</span></label
>
<textarea
id="task"
class="w-full rounded border p-2"
rows="2"
bind:value={taskDescription}
required
aria-required="true"
aria-label="Task or Assignment Description"
disabled={submitting}
></textarea>
</div>
<div class="mb-4">
<label class="mb-1 block font-semibold" for="file">Upload a file (PDF, DOCX, TXT):</label>
<input
id="file"
type="file"
accept=".pdf,.doc,.docx,.txt"
class="block w-full rounded border p-2"
on:change={handleFileChange}
disabled={submitting}
/>
<span class="text-sm text-gray-500">Or paste the student's submission below.</span>
</div>
<div class="mb-4">
<label class="mb-1 block font-semibold" for="textInput">Paste assignment text:</label>
<textarea
id="textInput"
rows="5"
class="w-full rounded border p-2"
bind:value={textInput}
on:input={handleTextChange}
disabled={submitting}
placeholder="Paste or type student answer here..."
></textarea>
</div>
<button
type="submit"
class="mt-4 w-full rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
disabled={submitting || (!file && textInput.trim().length === 0)}
>
{submitting ? 'Uploading...' : 'Submit'}
</button>
{#if errorMsg}
<div class="mt-4 text-red-600">{errorMsg}</div>
{/if}
{#if successMsg}
<div class="mt-4 text-green-600">{successMsg}</div>
{/if}
</form>
{/if}
</section>