feat(*): Enable HITL functionality

This commit is contained in:
Josh Creek
2025-04-25 23:08:02 +01:00
parent 00ef1476d9
commit 8394a24ac1
5 changed files with 326 additions and 128 deletions
+4 -5
View File
@@ -36,7 +36,7 @@ const allTools = [
spellingAndGrammarCheckerTool spellingAndGrammarCheckerTool
]; ];
const tools = allTools.map(t => t.definition); const tools = allTools.map((t) => t.definition);
const instructions = ` const instructions = `
You are a helpful agent that can assist with providing feedback on a student's work for a teacher. You are a helpful agent that can assist with providing feedback on a student's work for a teacher.
@@ -49,14 +49,14 @@ Whenever you need to:
• craft a metacognitive prompt → call "${metacognitiveReflectionPromptTool.definition.name}" • craft a metacognitive prompt → call "${metacognitiveReflectionPromptTool.definition.name}"
• guide self-assessment → call "${selfAssessmentTool.definition.name}" • guide self-assessment → call "${selfAssessmentTool.definition.name}"
• check spelling & grammar → call "${spellingAndGrammarCheckerTool.definition.name}" • check spelling & grammar → call "${spellingAndGrammarCheckerTool.definition.name}"
If a user message begins with [HUMAN REVIEW]:, treat it as authoritative teacher feedback and use it as the basis for grading. Do not escalate to human review again.
`; `;
export const toolResources = allTools.reduce<Record<string, any>>((map, t) => { export const toolResources = allTools.reduce<Record<string, any>>((map, t) => {
// Try all plausible locations for the tool name // Try all plausible locations for the tool name
const name = const name =
t.definition.name || t.definition.name || t.definition.function?.name || Object.keys(t.resources ?? {})[0];
t.definition.function?.name ||
Object.keys(t.resources ?? {})[0];
if (!name) { if (!name) {
console.warn('Tool missing name:', t); console.warn('Tool missing name:', t);
@@ -67,7 +67,6 @@ export const toolResources = allTools.reduce<Record<string, any>>((map, t) => {
}, {}); }, {});
export async function getOrCreateAgent() { export async function getOrCreateAgent() {
const agentName = `assessment-feedback-agent`; const agentName = `assessment-feedback-agent`;
// List all agents (may need pagination for large numbers) // List all agents (may need pagination for large numbers)
const agentsResponse = await client.agents.listAgents(); const agentsResponse = await client.agents.listAgents();
+66 -5
View File
@@ -40,7 +40,10 @@ RESPONSE FORMAT (respond with a single JSON object, no extra text):
Respond ONLY with the JSON object, with no preamble or explanation.`; Respond ONLY with the JSON object, with no preamble or explanation.`;
} }
export function buildGradingPromptWithSupportForHumanInTheLoop(submission: string, task: string): string { export function buildGradingPromptWithSupportForHumanInTheLoop(
submission: string,
task: string
): string {
return `You are an expert secondary school teacher and AI assessment agent. Assess the following student submission in the context of the assignment/task provided. return `You are an expert secondary school teacher and AI assessment agent. Assess the following student submission in the context of the assignment/task provided.
TASK/ASSIGNMENT: TASK/ASSIGNMENT:
@@ -128,7 +131,6 @@ EXAMPLES:
- Reasoning: The task and submission are both clear and evaluatable without a rubric. - Reasoning: The task and submission are both clear and evaluatable without a rubric.
Respond ONLY with the JSON object, with no preamble or explanation.`; Respond ONLY with the JSON object, with no preamble or explanation.`;
} }
function fallbackGrade(submission: string, task: string): OpenAIResponse { function fallbackGrade(submission: string, task: string): OpenAIResponse {
@@ -223,7 +225,8 @@ async function processRunStream(
export async function gradeSubmissionWithAgent( export async function gradeSubmissionWithAgent(
submission: string, submission: string,
task: string, task: string,
roomId: string roomId: string,
hitl: boolean = false
): Promise<OpenAIResponse> { ): Promise<OpenAIResponse> {
if (!client || !agent) { if (!client || !agent) {
throw new Error('Agent not available'); throw new Error('Agent not available');
@@ -232,7 +235,9 @@ export async function gradeSubmissionWithAgent(
const thread = await client.agents.createThread(); const thread = await client.agents.createThread();
await client.agents.createMessage(thread.id, { await client.agents.createMessage(thread.id, {
role: 'user', role: 'user',
content: buildGradingPrompt(submission, task) content: hitl
? buildGradingPromptWithSupportForHumanInTheLoop(submission, task)
: buildGradingPrompt(submission, task)
}); });
let initialStream: AsyncIterable<RunStreamEvent>; let initialStream: AsyncIterable<RunStreamEvent>;
@@ -264,9 +269,65 @@ export async function gradeSubmissionWithAgent(
const jsonText = match ? match[0] : raw; const jsonText = match ? match[0] : raw;
try { try {
return JSON.parse(jsonText) as OpenAIResponse; const parsed = JSON.parse(jsonText) as OpenAIResponse;
if (parsed.grade === 'HUMAN_REVIEW_REQUIRED') {
return {
...parsed,
success: true,
threadId: thread.id,
runId: finalRun?.id ?? undefined,
hitlContext: { threadId: thread.id, runId: finalRun?.id ?? undefined }
};
}
return parsed;
} catch (parseErr) { } catch (parseErr) {
console.error('JSON parse failed, returning fallback:', parseErr); console.error('JSON parse failed, returning fallback:', parseErr);
return { ...fallbackGrade(submission, task), reasoning: raw }; return { ...fallbackGrade(submission, task), reasoning: raw };
} }
} }
export async function resumeAgentWithHumanReview(
humanReview: string,
context: { threadId: string; runId?: string; roomId?: string; [key: string]: any }
): Promise<OpenAIResponse> {
const { threadId, roomId = '' } = context;
await client.agents.createMessage(threadId, {
role: 'user',
content: `[HUMAN REVIEW]: ${humanReview}`
});
let stream: AsyncIterable<RunStreamEvent>;
try {
const runInvoker = client.agents.createRun(threadId, agent.id, {
parallelToolCalls: false
});
stream = await runInvoker.stream();
} catch (err) {
console.error('Failed to start run:', err);
return fallbackGrade('', '');
}
let finalRun: ThreadRunOutput;
try {
finalRun = await processRunStream(threadId, stream, roomId);
} catch (err) {
console.error('Agent streaming failed:', err);
return fallbackGrade('', '');
}
const msgs = await client.agents.listMessages(threadId);
const assistant = msgs.data.find((m) => m.role === 'assistant');
const raw = assistant ? extractTextFromMessage(assistant) : '';
// strip anything before the JSON object
const match = raw.match(/\{[\s\S]*\}$/);
const jsonText = match ? match[0] : raw;
try {
const parsed = JSON.parse(jsonText) as OpenAIResponse;
return parsed;
} catch (parseErr) {
console.error('JSON parse failed, returning fallback:', parseErr);
return { ...fallbackGrade('', ''), reasoning: raw };
}
}
+18 -7
View File
@@ -9,17 +9,23 @@ export const POST: RequestHandler = async (event: RequestEvent) => {
const text = data.get('text'); const text = data.get('text');
const task = data.get('task'); const task = data.get('task');
const roomId = data.get('roomId') as string | undefined; const roomId = data.get('roomId') as string | undefined;
const hitl = data.get('hitl') === 'true';
let submission = ''; let submission = '';
if (!task || typeof task !== 'string' || !task.trim()) { if (!task || typeof task !== 'string' || !task.trim()) {
return new Response(JSON.stringify({ error: 'Task/assignment description is required.' }), { status: 400 }); return new Response(JSON.stringify({ error: 'Task/assignment description is required.' }), {
status: 400
});
} }
if (file && typeof file === 'object' && 'arrayBuffer' in file && 'name' in file) { if (file && typeof file === 'object' && 'arrayBuffer' in file && 'name' in file) {
// Only support .txt for now for hackathon speed // Only support .txt for now for hackathon speed
const fileObj = file as File; const fileObj = file as File;
if (!fileObj.name.endsWith('.txt')) { if (!fileObj.name.endsWith('.txt')) {
return new Response(JSON.stringify({ error: 'Only .txt files are supported at this time.' }), { status: 400 }); return new Response(
JSON.stringify({ error: 'Only .txt files are supported at this time.' }),
{ status: 400 }
);
} }
const buffer = await fileObj.arrayBuffer(); const buffer = await fileObj.arrayBuffer();
submission = new TextDecoder('utf-8').decode(buffer); submission = new TextDecoder('utf-8').decode(buffer);
@@ -30,18 +36,23 @@ export const POST: RequestHandler = async (event: RequestEvent) => {
} }
// Call the AI grading agent with threading and fallback // Call the AI grading agent with threading and fallback
const aiResult = await gradeSubmissionWithAgent(submission, task.trim(), roomId); const aiResult = await gradeSubmissionWithAgent(submission, task.trim(), roomId ?? '', hitl);
return new Response(JSON.stringify({ return new Response(
JSON.stringify({
success: true, success: true,
task: task.trim(), task: task.trim(),
feedback: `Grade: ${aiResult.grade}\nStrengths: ${aiResult.strengths}\nAreas for Improvement: ${aiResult.areasForImprovement}\nIndividualized Activity: ${aiResult.individualizedActivity}\nSpelling and Grammar: ${aiResult.spellingAndGrammar}\nReasoning: ${aiResult.reasoning}`, feedback: `Grade: ${aiResult.grade}\nStrengths: ${aiResult.strengths}\nAreas for Improvement: ${aiResult.areasForImprovement}\nIndividualized Activity: ${aiResult.individualizedActivity}\nSpelling and Grammar: ${aiResult.spellingAndGrammar}\nReasoning: ${aiResult.reasoning}`,
...aiResult ...aiResult
}), { }),
{
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); }
);
} catch (err: any) { } catch (err: any) {
return new Response(JSON.stringify({ error: err.message || 'Internal server error.' }), { status: 500 }); return new Response(JSON.stringify({ error: err.message || 'Internal server error.' }), {
status: 500
});
} }
}; };
+23
View File
@@ -0,0 +1,23 @@
import type { RequestHandler } from '@sveltejs/kit';
import { resumeAgentWithHumanReview } from '$lib/server/ai';
export const POST: RequestHandler = async (event) => {
try {
const { humanReview, context } = await event.request.json();
const aiResult = await resumeAgentWithHumanReview(humanReview, context);
return new Response(
JSON.stringify({
success: true,
...aiResult
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message || 'Internal server error.' }), {
status: 500
});
}
};
+112 -8
View File
@@ -9,9 +9,16 @@
let errorMsg = ''; let errorMsg = '';
let toolEvents: Array<{ tool: string; time: string }> = []; let toolEvents: Array<{ tool: string; time: string }> = [];
let roomId: string = ''; let roomId: string = '';
let hitl = false; // Human-in-the-Loop toggle
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
const PARTYKIT_BASE_URL = import.meta.env.VITE_PARTYKIT_BASE_URL; const PARTYKIT_BASE_URL = import.meta.env.VITE_PARTYKIT_BASE_URL;
// HITL state
let humanReviewRequired = false;
let aiReasoning = '';
let humanReviewText = '';
let hitlContext: any = null;
function handleFileChange(event: Event) { function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) { if (target.files && target.files.length > 0) {
@@ -40,7 +47,7 @@
const THINKING_EVENT = { tool: 'thinking', time: new Date().toISOString() }; const THINKING_EVENT = { tool: 'thinking', time: new Date().toISOString() };
function ensureThinkingStep(events) { function ensureThinkingStep(events) {
// Remove any existing 'thinking' event // Remove any existing 'thinking' event
const filtered = events.filter(e => e.tool !== 'thinking'); const filtered = events.filter((e) => e.tool !== 'thinking');
return [THINKING_EVENT, ...filtered]; return [THINKING_EVENT, ...filtered];
} }
toolEvents = [THINKING_EVENT]; toolEvents = [THINKING_EVENT];
@@ -71,6 +78,7 @@
formData.append('text', textInput.trim()); formData.append('text', textInput.trim());
} }
formData.append('roomId', roomId); formData.append('roomId', roomId);
formData.append('hitl', hitl ? 'true' : 'false');
errorMsg = ''; errorMsg = '';
try { try {
@@ -78,17 +86,27 @@
method: 'POST', method: 'POST',
body: formData body: formData
}); });
let result; let result;
try { try {
result = await response.json(); result = await response.json();
} catch { } catch (err) {
console.error('Error parsing /api/grade response:', err);
errorMsg = 'Received an invalid response from the server.'; errorMsg = 'Received an invalid response from the server.';
submitting = false; submitting = false;
return; return;
} }
if (response.ok && result.success) { if (response.ok && result.success) {
// Save to localStorage history if (result.grade === 'HUMAN_REVIEW_REQUIRED') {
humanReviewRequired = true;
aiReasoning = result.reasoning;
hitlContext = result.hitlContext || { threadId: result.threadId, runId: result.runId };
submitting = false;
return;
}
try { try {
// Save to localStorage history
let prev = JSON.parse(localStorage.getItem('assessmentHistory') || '[]'); let prev = JSON.parse(localStorage.getItem('assessmentHistory') || '[]');
const entry = { const entry = {
timestamp: Date.now(), timestamp: Date.now(),
@@ -100,7 +118,7 @@
localStorage.setItem('assessmentHistory', JSON.stringify(prev.slice(0, 50))); localStorage.setItem('assessmentHistory', JSON.stringify(prev.slice(0, 50)));
} catch {} } catch {}
// Redirect to /results // Redirect to /results if HITL is not needed
window.location.assign('/results'); window.location.assign('/results');
file = null; file = null;
@@ -108,6 +126,7 @@
taskDescription = ''; taskDescription = '';
return; return;
} else { } else {
console.error('Grade API error or non-success:', response, result);
if (result && result.error) { if (result && result.error) {
errorMsg = `Sorry, something went wrong: ${result.error}`; errorMsg = `Sorry, something went wrong: ${result.error}`;
} else if (response.status === 413) { } else if (response.status === 413) {
@@ -119,12 +138,51 @@
} }
} }
} catch (err) { } catch (err) {
console.error('Error submitting grade:', err);
errorMsg = 'Network or server error. Please check your connection and try again.'; errorMsg = 'Network or server error. Please check your connection and try again.';
} finally { } finally {
// Disabled to make the transition to the results page smoother // Disabled to make the transition to the results page smoother
// submitting = false; // submitting = false;
} }
} }
async function submitHumanReview() {
submitting = true;
errorMsg = '';
try {
const response = await fetch('/api/hitl-review', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
humanReview: humanReviewText,
context: hitlContext
})
});
const result = await response.json();
if (response.ok && result.success) {
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)));
window.location.assign('/results');
} else {
console.error('HITL review API error or non-success:', response, result);
errorMsg = result.error || 'Unknown error during HITL review.';
}
} catch (err) {
console.error('Error submitting HITL review:', err);
errorMsg = 'Network or server error during HITL review.';
} finally {
submitting = false;
}
}
</script> </script>
<section class="relative mx-auto max-w-4xl py-12"> <section class="relative mx-auto max-w-4xl py-12">
@@ -138,13 +196,31 @@
{errorMsg} {errorMsg}
</div> </div>
{/if} {/if}
{#if submitting}
{#if humanReviewRequired}
<div class="mb-6 rounded border-l-4 border-yellow-400 bg-yellow-50 p-4">
<h2 class="mb-2 font-bold text-yellow-800">Human Review Needed</h2>
<p class="mb-3 text-gray-800">{aiReasoning}</p>
<textarea
bind:value={humanReviewText}
rows="4"
class="mb-3 w-full rounded border p-2"
placeholder="Enter your feedback, grade, or comments..."
></textarea>
<button
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
on:click|preventDefault={submitHumanReview}
disabled={submitting || !humanReviewText.trim()}
>
Submit Review
</button>
</div>
{:else if submitting}
<AgenticProgress {toolEvents} /> <AgenticProgress {toolEvents} />
{:else} {:else}
<h1 class="mb-4 text-2xl font-bold">Upload Student Submissions</h1> <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> <p class="mb-6">Upload student assignments for instant AI-powered assessment and feedback.</p>
<form class="space-y-6" on:submit|preventDefault={handleSubmit}> <form class="space-y-6" on:submit|preventDefault={handleSubmit}>
<div class="mb-4"> <div class="mb-4">
<label class="mb-1 block font-semibold" for="task" <label class="mb-1 block font-semibold" for="task"
>Task/Assignment Description <span class="text-red-500">*</span></label >Task/Assignment Description <span class="text-red-500">*</span></label
@@ -161,20 +237,48 @@
data-testid="task-input" data-testid="task-input"
></textarea> ></textarea>
</div> </div>
<div class="mb-4">
<label class="flex cursor-pointer items-center gap-3 select-none" for="hitl-toggle">
<span class="relative inline-block h-7 w-12 align-middle select-none">
<input
type="checkbox"
id="hitl-toggle"
class="peer absolute h-0 w-0 opacity-0"
bind:checked={hitl}
disabled={submitting}
/>
<span
class="block h-7 w-12 rounded-full bg-gray-300 transition-colors peer-checked:bg-blue-600"
></span>
<span
class="dot absolute top-1 left-1 h-5 w-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5"
></span>
</span>
<span class="font-medium text-gray-900"
>EXPERIMENTAL: Let the agent request human review if needed (Human-in-the-Loop)</span
>
</label>
<div class="mt-1 ml-1 text-sm text-gray-500">
If enabled, the AI can ask for human help on unclear or unevaluable submissions.
</div>
</div>
<div class="mb-4"> <div class="mb-4">
<label class="mb-1 block font-semibold" for="file">Upload a file (PDF, DOCX, TXT):</label> <label class="mb-1 block font-semibold" for="file">Upload a file (PDF, DOCX, TXT):</label>
<input <input
id="file" id="file"
type="file" type="file"
accept=".pdf,.doc,.docx,.txt" accept=".pdf,.doc,.docx,.txt"
class="block w-full rounded border p-2 bg-gray-100 cursor-not-allowed" class="block w-full cursor-not-allowed rounded border bg-gray-100 p-2"
on:change={handleFileChange} on:change={handleFileChange}
disabled disabled
/> />
<span class="text-sm text-red-600">File upload is disabled for this hackathon demo.</span> <span class="text-sm text-red-600">File upload is disabled for this hackathon demo.</span>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="mb-1 block font-semibold" for="textInput">Paste assignment text: <span class="text-red-500">*</span></label> <label class="mb-1 block font-semibold" for="textInput"
>Paste assignment text: <span class="text-red-500">*</span></label
>
<textarea <textarea
id="textInput" id="textInput"
rows="5" rows="5"