Commit f2a9f00f authored by Kaifmohd's avatar Kaifmohd
Browse files

LLM-ASYST

parents
import { useEffect, useState } from "react"
import { useNavigate, useParams } from "react-router-dom"
import AppFrame from "../components/AppFrame"
import { deleteRun, getRunStudents, type RunStudentRow } from "../services/sessionsApi"
function BackendStudentsOverviewPage() {
const { runId } = useParams<{ runId: string }>()
const navigate = useNavigate()
const [students, setStudents] = useState<RunStudentRow[]>([])
const [error, setError] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
useEffect(() => {
if (!runId) return
let cancelled = false
const load = async () => {
try {
const data = await getRunStudents(runId, 1, 500)
if (cancelled) return
setStudents(data.items)
setError(null)
} catch (err) {
if (cancelled) return
setError(err instanceof Error ? err.message : "Unable to fetch students overview")
}
}
load()
return () => {
cancelled = true
}
}, [runId])
if (!runId) {
return (
<AppFrame>
<div className="rounded-xl bg-[var(--surface-container-low)] p-8 text-sm text-[var(--on-surface-variant)]">
Missing run id.
</div>
</AppFrame>
)
}
const handleDeleteRun = async () => {
const confirmed = window.confirm(
`Delete run ${runId} permanently?\n\nThis removes the uploaded file, parsed answers, LLM grades, human review decisions, and final results. This cannot be undone.`
)
if (!confirmed) return
setIsDeleting(true)
setError(null)
try {
await deleteRun(runId)
navigate("/", {
replace: true,
state: { message: "Grading run deleted successfully." }
})
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to delete run")
} finally {
setIsDeleting(false)
}
}
return (
<AppFrame>
<section className="space-y-6">
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="brand-font text-4xl font-extrabold tracking-tight">Student Overview (New)</h1>
<p className="mt-1 text-sm text-[var(--on-surface-variant)]">
Backend-powered student summary with review completion and scores.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="button"
onClick={() => navigate(`/overview-live/${encodeURIComponent(runId)}`)}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition-colors hover:bg-[var(--surface-container-low)]"
>
Back to Question Overview
</button>
<button
type="button"
onClick={handleDeleteRun}
disabled={isDeleting}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[rgb(123_19_24_/_35%)] px-4 py-2 text-sm font-semibold text-[var(--primary)] transition-colors hover:bg-[rgb(255_218_214_/_45%)] disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="material-symbols-outlined !text-base">delete</span>
{isDeleting ? "Deleting..." : "Delete Run"}
</button>
</div>
</header>
{error && (
<p className="rounded-lg bg-[rgb(255_218_214_/_60%)] px-4 py-3 text-sm text-[var(--primary)]">
{error}
</p>
)}
<section className="soft-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] border-collapse text-sm">
<thead className="bg-[var(--surface-container-low)]">
<tr>
<th className="px-5 py-4 text-left text-[11px] uppercase tracking-wider text-[var(--secondary)]">
Student
</th>
<th className="px-5 py-4 text-left text-[11px] uppercase tracking-wider text-[var(--secondary)]">
Email
</th>
<th className="px-5 py-4 text-left text-[11px] uppercase tracking-wider text-[var(--secondary)]">
Reviewed
</th>
<th className="px-5 py-4 text-left text-[11px] uppercase tracking-wider text-[var(--secondary)]">
Pending
</th>
<th className="px-5 py-4 text-left text-[11px] uppercase tracking-wider text-[var(--secondary)]">
Final Score
</th>
<th className="px-5 py-4 text-left text-[11px] uppercase tracking-wider text-[var(--secondary)]">
Review Complete
</th>
</tr>
</thead>
<tbody>
{students.map((student, index) => (
<tr
key={student.student_id}
className={[
"border-t border-slate-100",
index % 2 ? "bg-[rgb(241_244_246_/_45%)]" : ""
].join(" ")}
>
<td className="px-5 py-4 font-semibold text-[var(--on-surface)]">
{student.first_name} {student.last_name}
<p className="text-xs font-normal text-slate-500">{student.student_code}</p>
</td>
<td className="px-5 py-4 text-[var(--on-surface-variant)]">{student.email}</td>
<td className="px-5 py-4 text-[var(--on-surface)]">
{student.reviewed_count}/{student.answered_count}
</td>
<td className="px-5 py-4 text-[var(--primary)]">{student.pending_review_count}</td>
<td className="px-5 py-4 text-[var(--on-surface)]">
{student.total_final_score} / {student.total_max_points}
</td>
<td className="px-5 py-4">
{student.review_complete ? (
<span className="inline-flex items-center rounded-full bg-emerald-50 px-3 py-1 text-[11px] font-bold uppercase tracking-wide text-emerald-700">
Complete
</span>
) : (
<span className="inline-flex items-center rounded-full bg-[var(--primary-fixed)] px-3 py-1 text-[11px] font-bold uppercase tracking-wide text-[var(--primary)]">
Pending
</span>
)}
</td>
</tr>
))}
{students.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-500">
No students found for this run.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</section>
</AppFrame>
)
}
export default BackendStudentsOverviewPage
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { MemoryRouter, Route, Routes } from "react-router-dom"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { LabelEvaluationResponse, QuestionAnswersResponse } from "../services/sessionsApi"
import { getLabelEvaluation, getQuestionAnswers } from "../services/sessionsApi"
import EvaluationPage from "./EvaluationPage"
vi.mock("../services/sessionsApi", async importOriginal => {
const actual = await importOriginal<typeof import("../services/sessionsApi")>()
return {
...actual,
getLabelEvaluation: vi.fn(),
getQuestionAnswers: vi.fn(),
deleteRun: vi.fn()
}
})
const report: LabelEvaluationResponse = {
run_id: "eval-1",
filename: "evaluation.csv",
run_type: "evaluation",
low_support_threshold: 5,
questions: [
{
question_id: 1,
question_number: 1,
question_text: "What is RAM?",
max_points: 2,
total_answer_count: 10,
evaluated_answer_count: 10,
failed_llm_count: 0,
excluded_invalid_count: 0,
default_scale: "full",
full_scale: {
scale: "full",
labels: [
{
label: 1,
true_positive: 4,
predicted_count: 5,
support: 6,
precision: 0.8,
recall: 0.6667,
low_support: false
},
{
label: 2,
true_positive: 2,
predicted_count: 2,
support: 2,
precision: 1,
recall: 1,
low_support: true
}
]
},
whole_scale: {
scale: "whole",
labels: [
{
label: 1,
true_positive: 7,
predicted_count: 8,
support: 8,
precision: 0.875,
recall: 0.875,
low_support: false
}
]
}
}
],
totals: {
total_questions: 1,
total_answer_count: 10,
evaluated_answer_count: 10,
failed_llm_count: 0,
excluded_invalid_count: 0,
skipped_question_count: 0,
coverage_percent: 100
},
warnings: []
}
const answers: QuestionAnswersResponse = {
run_id: "eval-1",
question_id: 1,
question_number: 1,
question_text: "What is RAM?",
max_points: 2,
items: [
{
answer_id: 10,
student_id: 1,
student_name: "Ada Lovelace",
student_code: "S1",
email: "ada@example.test",
answer_text: "RAM is volatile memory.",
llm_score: 1,
observed_score: 1,
human_score: null,
final_score: null,
review_status: "pending",
review_required: true,
accepted_llm: false,
reviewed_at: null,
processing_status: "graded",
llm_feedback: "One relevant fact."
}
],
pagination: { page: 1, page_size: 500, total: 1 }
}
describe("EvaluationPage", () => {
beforeEach(() => {
vi.mocked(getLabelEvaluation).mockResolvedValue(report)
vi.mocked(getQuestionAnswers).mockResolvedValue(answers)
})
afterEach(cleanup)
it("reports neutral label metrics and filters answers by selected AI prediction", async () => {
render(
<MemoryRouter initialEntries={["/evaluation-live/eval-1"]}>
<Routes>
<Route path="/evaluation-live/:runId" element={<EvaluationPage />} />
</Routes>
</MemoryRouter>
)
expect(await screen.findByText("Evaluation by predicted grade")).toBeInTheDocument()
expect(screen.getByText("80%")).toBeInTheDocument()
expect(screen.getByText("4 of 5 AI predictions matched")).toBeInTheDocument()
expect(screen.getByText("Low support")).toBeInTheDocument()
expect(screen.queryByText(/Review every AI grade/)).not.toBeInTheDocument()
expect(screen.queryByText(/High-stakes/)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole("checkbox", { name: "Show AI predictions of 1 point for review" }))
await waitFor(() =>
expect(getQuestionAnswers).toHaveBeenCalledWith("eval-1", 1, 1, 500, {
predictedScores: [1],
scoreScale: "full"
})
)
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument()
expect(screen.getByText("Professor score:")).toBeInTheDocument()
})
it("switches to the whole-number report without changing stored scores", async () => {
render(
<MemoryRouter initialEntries={["/evaluation-live/eval-1"]}>
<Routes>
<Route path="/evaluation-live/:runId" element={<EvaluationPage />} />
</Routes>
</MemoryRouter>
)
await screen.findByText("evaluation.csv")
fireEvent.click(screen.getByRole("button", { name: "Whole numbers" }))
expect(screen.getAllByText("88%")).toHaveLength(2)
expect(screen.getByText(/Original scores remain unchanged/)).toBeInTheDocument()
})
})
import { useEffect, useMemo, useState } from "react"
import { Link, useNavigate, useParams } from "react-router-dom"
import AppFrame from "../components/AppFrame"
import LabelMetricsTable from "../components/LabelMetricsTable"
import {
deleteRun,
getLabelEvaluation,
getQuestionAnswers,
type LabelEvaluationResponse,
type LabelQuestionReport,
type LabelScaleKey,
type LabelScaleReport,
type QuestionAnswerRow
} from "../services/sessionsApi"
import { formatGrade } from "../utils/formatGrade"
function scaleFor(question: LabelQuestionReport, scale: LabelScaleKey): LabelScaleReport {
return scale === "whole" && question.whole_scale ? question.whole_scale : question.full_scale
}
function displayScore(value: number | null): string {
if (value === null) return "—"
return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)
}
function comparisonScore(value: number | null, scale: LabelScaleKey): number | null {
if (value === null) return null
return scale === "whole" ? Math.floor(value + 0.5) : value
}
function EvaluationPage() {
const { runId } = useParams<{ runId: string }>()
const navigate = useNavigate()
const [report, setReport] = useState<LabelEvaluationResponse | null>(null)
const [selectedQuestionId, setSelectedQuestionId] = useState<number | null>(null)
const [selectedScale, setSelectedScale] = useState<LabelScaleKey>("full")
const [selectedLabels, setSelectedLabels] = useState<number[]>([])
const [answers, setAnswers] = useState<QuestionAnswerRow[]>([])
const [loadingAnswers, setLoadingAnswers] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
useEffect(() => {
if (!runId) return
let cancelled = false
getLabelEvaluation(runId)
.then(data => {
if (cancelled) return
const first = data.questions[0] ?? null
setReport(data)
setSelectedQuestionId(first?.question_id ?? null)
setSelectedScale(first?.default_scale ?? "full")
setSelectedLabels([])
setAnswers([])
setError(null)
})
.catch(err => {
if (!cancelled) setError(err instanceof Error ? err.message : "Unable to load evaluation")
})
return () => {
cancelled = true
}
}, [runId])
const selectedQuestion = useMemo(
() => report?.questions.find(question => question.question_id === selectedQuestionId) ?? null,
[report, selectedQuestionId]
)
const selectedReport = selectedQuestion ? scaleFor(selectedQuestion, selectedScale) : null
useEffect(() => {
if (!runId || !selectedQuestion || selectedLabels.length === 0) {
setAnswers([])
setLoadingAnswers(false)
return
}
let cancelled = false
const loadAnswers = async () => {
setLoadingAnswers(true)
try {
const filters = { predictedScores: selectedLabels, scoreScale: selectedScale }
const first = await getQuestionAnswers(runId, selectedQuestion.question_id, 1, 500, filters)
let rows = [...first.items]
const pages = Math.max(1, Math.ceil(first.pagination.total / first.pagination.page_size))
for (let page = 2; page <= pages; page += 1) {
const next = await getQuestionAnswers(runId, selectedQuestion.question_id, page, 500, filters)
rows = rows.concat(next.items)
}
if (!cancelled) {
setAnswers(rows)
setError(null)
}
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : "Unable to load selected answers")
} finally {
if (!cancelled) setLoadingAnswers(false)
}
}
loadAnswers()
return () => {
cancelled = true
}
}, [runId, selectedQuestion, selectedScale, selectedLabels])
if (!runId) return <AppFrame><p className="soft-card p-8">Missing run id.</p></AppFrame>
const chooseQuestion = (question: LabelQuestionReport) => {
setSelectedQuestionId(question.question_id)
setSelectedScale(question.default_scale)
setSelectedLabels([])
setAnswers([])
}
const chooseScale = (scale: LabelScaleKey) => {
setSelectedScale(scale)
setSelectedLabels([])
setAnswers([])
}
const toggleLabel = (label: number) => {
setSelectedLabels(current =>
current.includes(label) ? current.filter(value => value !== label) : [...current, label].sort((a, b) => a - b)
)
}
const handleDelete = async () => {
if (!window.confirm(`Delete ${report?.filename ? `"${report.filename}"` : "this evaluation"} permanently?`)) return
setIsDeleting(true)
try {
await deleteRun(runId)
navigate("/", { replace: true, state: { message: "Evaluation run deleted successfully." } })
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to delete evaluation run")
} finally {
setIsDeleting(false)
}
}
return (
<AppFrame>
<section className="space-y-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<button type="button" onClick={() => navigate("/")} className="mb-4 inline-flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-[var(--primary)]">
<span className="material-symbols-outlined">arrow_back</span> Upload
</button>
<h1 className="brand-font text-3xl font-extrabold tracking-tight sm:text-4xl">Evaluation by predicted grade</h1>
<p className="mt-2 text-sm text-[var(--on-surface-variant)]">{report?.filename ?? "Loading evaluation..."}</p>
</div>
{report && (
<button type="button" onClick={handleDelete} disabled={isDeleting} className="text-xs font-semibold text-[var(--primary)] disabled:opacity-50">
{isDeleting ? "Deleting..." : "Delete evaluation"}
</button>
)}
</header>
{error && <p className="rounded-xl bg-[var(--primary-fixed)] px-4 py-3 text-sm text-[var(--primary)]">{error}</p>}
{!report && !error && <div className="soft-card p-8 text-sm text-[var(--on-surface-variant)]">Loading label evaluation...</div>}
{report && (
<>
<section className="soft-card flex flex-wrap items-center gap-x-8 gap-y-3 p-5 text-sm">
<p><strong>{report.totals.total_questions}</strong> questions</p>
<p><strong>{report.totals.evaluated_answer_count}</strong> of {report.totals.total_answer_count} answers compared</p>
{(report.totals.failed_llm_count > 0 || report.totals.excluded_invalid_count > 0) && (
<p className="text-amber-900">Some answers were excluded; see the warnings below.</p>
)}
</section>
{report.warnings.map((warning, index) => (
<p key={`${warning.code}-${warning.question_id}-${index}`} className="rounded-xl bg-amber-50 px-4 py-3 text-sm text-amber-950">{warning.message}</p>
))}
{selectedQuestion && selectedReport ? (
<>
<section className="soft-card overflow-hidden">
<div className="border-b border-slate-100 p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="label-meta text-slate-500">Question</p>
<h2 className="brand-font mt-1 text-xl font-bold">Q{selectedQuestion.question_number}: {selectedQuestion.question_text}</h2>
<p className="mt-1 text-xs text-[var(--on-surface-variant)]">Maximum {displayScore(selectedQuestion.max_points)} points</p>
</div>
<div className="flex flex-wrap gap-2">
{report.questions.map(question => (
<button key={question.question_id} type="button" onClick={() => chooseQuestion(question)} className={`rounded-lg px-3 py-2 text-xs font-bold ${question.question_id === selectedQuestion.question_id ? "bg-[var(--secondary-container)] text-[var(--on-secondary-container)]" : "bg-[var(--surface-container-low)] text-slate-600"}`}>
Q{question.question_number}
</button>
))}
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-3">
<div className="rounded-lg bg-[var(--surface-container-low)] p-3 text-xs leading-relaxed"><strong className="block text-sm">Precision</strong>When the AI predicts this grade, how often it matches you.</div>
<div className="rounded-lg bg-[var(--surface-container-low)] p-3 text-xs leading-relaxed"><strong className="block text-sm">Recall</strong>Of the answers you gave this grade, how many the AI identified.</div>
<div className="rounded-lg bg-[var(--surface-container-low)] p-3 text-xs leading-relaxed"><strong className="block text-sm">Support</strong>How many professor-graded examples exist for this grade.</div>
</div>
{selectedQuestion.whole_scale && (
<div className="mt-5 inline-flex rounded-lg bg-[var(--surface-container)] p-1" aria-label="Evaluation scale">
{(["full", "whole"] as const).map(scale => (
<button key={scale} type="button" onClick={() => chooseScale(scale)} className={`rounded-md px-4 py-2 text-xs font-bold ${selectedScale === scale ? "bg-white text-[var(--primary)] shadow-sm" : "text-slate-600"}`}>
{scale === "full" ? "0.5-point scale" : "Whole numbers"}
</button>
))}
</div>
)}
{selectedScale === "whole" && (
<p className="mt-3 text-xs text-[var(--on-surface-variant)]">Both AI and professor grades are rounded to the nearest whole number for this view. Original scores remain unchanged.</p>
)}
</div>
<LabelMetricsTable labels={selectedReport.labels} selectedLabels={selectedLabels} lowSupportThreshold={report.low_support_threshold} onToggle={toggleLabel} />
</section>
<section className="soft-card overflow-hidden">
<div className="border-b border-slate-100 p-5">
<h2 className="brand-font text-xl font-bold">Selected answers for review</h2>
<p className="mt-1 text-sm text-[var(--on-surface-variant)]">
{selectedLabels.length === 0
? "Select one or more AI-predicted grades in the table above."
: `Showing predictions for ${selectedLabels.map(formatGrade).join(", ")}.`}
</p>
</div>
{loadingAnswers ? (
<p className="p-5 text-sm text-slate-500">Loading selected answers...</p>
) : selectedLabels.length === 0 ? (
<p className="p-5 text-sm text-slate-500">No categories selected.</p>
) : answers.length === 0 ? (
<p className="p-5 text-sm text-slate-500">No answers match the selected categories.</p>
) : (
<div className="divide-y divide-slate-100">
{answers.map(answer => {
const aiCompared = comparisonScore(answer.llm_score, selectedScale)
const humanCompared = comparisonScore(answer.observed_score, selectedScale)
const matches = aiCompared !== null && humanCompared !== null && Math.abs(aiCompared - humanCompared) < 0.001
return (
<article key={answer.answer_id} className="grid gap-4 p-5 lg:grid-cols-[1fr_auto]">
<div>
<p className="font-bold">{answer.student_name || answer.student_code}</p>
<p className="mt-2 text-sm leading-relaxed text-[var(--on-surface-variant)]">{answer.answer_text}</p>
{answer.llm_feedback && <p className="mt-2 text-xs text-slate-500">AI explanation: {answer.llm_feedback}</p>}
</div>
<div className="min-w-52 rounded-lg bg-[var(--surface-container-low)] p-3 text-sm">
<p>AI score: <strong>{displayScore(answer.llm_score)}</strong></p>
<p className="mt-1">Professor score: <strong>{displayScore(answer.observed_score)}</strong></p>
{selectedScale === "whole" && <p className="mt-1 text-xs text-slate-500">Compared as {displayScore(aiCompared)} vs {displayScore(humanCompared)}</p>}
<span className={`mt-3 inline-flex rounded-full px-2 py-1 text-[10px] font-bold ${matches ? "bg-emerald-50 text-emerald-800" : "bg-amber-50 text-amber-900"}`}>{matches ? "Match" : "Different"}</span>
</div>
</article>
)
})}
</div>
)}
</section>
</>
) : (
<section className="soft-card p-8 text-sm text-slate-500">No valid question results are available.</section>
)}
<div className="flex justify-end"><Link to="/" className="text-sm font-semibold text-[var(--primary)]">Evaluate another file</Link></div>
</>
)}
</section>
</AppFrame>
)
}
export default EvaluationPage
import { useEffect, useMemo, useState } from "react"
import { useNavigate, useParams, useSearchParams } from "react-router-dom"
import AppFrame from "../components/AppFrame"
import { getGradingStatus } from "../services/sessionsApi"
function GradingProgressPage() {
const { runId } = useParams<{ runId: string }>()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const isEvaluation = searchParams.get("mode") === "evaluation"
const [status, setStatus] = useState<Awaited<ReturnType<typeof getGradingStatus>> | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!runId) return
let cancelled = false
let timer: number | null = null
const loadStatus = async () => {
try {
const data = await getGradingStatus(runId)
if (cancelled) return
setStatus(data)
setError(null)
const total = Math.max(data.total_to_process, data.db_total)
const done = data.db_completed + data.db_failed + data.db_review_required
const isComplete = total > 0 && done >= total && data.db_pending === 0 && data.db_processing === 0
if (!isComplete) {
timer = window.setTimeout(loadStatus, 2000)
}
} catch (err) {
if (cancelled) return
const message = err instanceof Error ? err.message : "Unable to fetch grading progress"
setError(message)
timer = window.setTimeout(loadStatus, 3000)
}
}
loadStatus()
return () => {
cancelled = true
if (timer) {
window.clearTimeout(timer)
}
}
}, [runId])
const progress = useMemo(() => {
if (!status) return { total: 0, done: 0, percent: 0, complete: false }
const total = Math.max(status.total_to_process, status.db_total)
const done = status.db_completed + status.db_failed + status.db_review_required
const percent = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0
const complete = total > 0 && done >= total && status.db_pending === 0 && status.db_processing === 0
return { total, done, percent, complete }
}, [status])
if (!runId) {
return (
<AppFrame>
<div className="rounded-xl bg-[var(--surface-container-low)] p-8 text-sm text-[var(--on-surface-variant)]">
Missing run id.
</div>
</AppFrame>
)
}
return (
<AppFrame>
<section className="mx-auto max-w-3xl space-y-6">
<div className="soft-card p-8 md:p-10">
<h1 className="brand-font text-3xl font-extrabold tracking-tight">
{isEvaluation ? "Evaluation In Progress" : "Grading In Progress"}
</h1>
<p className="mt-2 text-sm text-[var(--on-surface-variant)]">
Run ID: {runId}
</p>
<div className="mt-8">
<div className="mb-3 flex items-center justify-between text-sm">
<span className="font-semibold text-[var(--on-surface)]">Progress</span>
<span className="font-semibold text-[var(--primary)]">{progress.percent}%</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-[var(--surface-container-highest)]">
<div
className="h-full bg-[var(--primary)] transition-all"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-3 text-sm md:grid-cols-3">
<div className="rounded-lg bg-[var(--surface-container-low)] p-3">
<p className="label-meta text-slate-500">Completed Tasks</p>
<p className="mt-1 text-lg font-bold text-[var(--on-surface)]">
{progress.done}/{progress.total || "-"}
</p>
</div>
<div className="rounded-lg bg-[var(--surface-container-low)] p-3">
<p className="label-meta text-slate-500">Graded</p>
<p className="mt-1 text-lg font-bold text-emerald-700">{status?.db_completed ?? 0}</p>
</div>
<div className="rounded-lg bg-[var(--surface-container-low)] p-3">
<p className="label-meta text-slate-500">Failed</p>
<p className="mt-1 text-lg font-bold text-[var(--primary)]">{status?.db_failed ?? 0}</p>
</div>
</div>
{error && (
<p className="mt-4 rounded-lg bg-[rgb(255_218_214_/_60%)] px-4 py-3 text-sm text-[var(--primary)]">
{error}
</p>
)}
{status?.error && (
<p className="mt-4 rounded-lg bg-[rgb(255_218_214_/_60%)] px-4 py-3 text-sm text-[var(--primary)]">
Job Error: {status.error}
</p>
)}
<div className="mt-8 flex justify-end">
<button
type="button"
disabled={!progress.complete}
onClick={() =>
navigate(
isEvaluation
? `/evaluation-live/${encodeURIComponent(runId)}`
: `/overview-live/${encodeURIComponent(runId)}`
)
}
className="custom-gradient-primary rounded-lg px-6 py-3 text-sm font-bold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
>
{isEvaluation ? "Open Evaluation Report" : "Open Review Overview"}
</button>
</div>
</div>
</section>
</AppFrame>
)
}
export default GradingProgressPage
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { MemoryRouter, Route, Routes } from "react-router-dom"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { getRunHistory, startGradingRun, uploadSessionFile } from "../services/sessionsApi"
import UploadPage from "./UploadPage"
vi.mock("../services/sessionsApi", async importOriginal => {
const actual = await importOriginal<typeof import("../services/sessionsApi")>()
return {
...actual,
getRunHistory: vi.fn(),
uploadSessionFile: vi.fn(),
startGradingRun: vi.fn()
}
})
function renderUploadPage() {
return render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route path="/" element={<UploadPage />} />
<Route path="/grading-progress/:runId" element={<p>Progress page</p>} />
</Routes>
</MemoryRouter>
)
}
function selectFile() {
const file = new File(["FirstName,LastName,Email-Address"], "answers.csv", {
type: "text/csv"
})
fireEvent.change(screen.getByLabelText("Upload grading file"), {
target: { files: [file] }
})
}
describe("UploadPage grading method drawer", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(uploadSessionFile).mockResolvedValue({
upload_id: "run-1",
original_filename: "answers.csv",
stored_filename: "answers.csv",
relative_path: "uploads/answers.csv",
file_type: "csv",
message: "Uploaded"
})
vi.mocked(startGradingRun).mockResolvedValue({
run_id: "run-1",
status: "grading_started",
strategy: "p2_reference_discrete"
})
vi.mocked(getRunHistory).mockResolvedValue({ items: [] })
})
afterEach(cleanup)
it("shows a compact grading method summary instead of the old inline strategy radios", () => {
renderUploadPage()
expect(screen.getByRole("region", { name: "Grading method" })).toBeInTheDocument()
expect(screen.getByText("Reference + grading rules")).toBeInTheDocument()
expect(screen.queryByText("With Reference Answer")).not.toBeInTheDocument()
expect(screen.queryByText("Without Reference Answer")).not.toBeInTheDocument()
})
it("opens the drawer and updates the selected grading method", () => {
renderUploadPage()
fireEvent.click(screen.getByRole("button", { name: "Configure" }))
expect(screen.getByText("Choose grading method")).toBeInTheDocument()
fireEvent.click(screen.getByLabelText(/Without Reference Answer/))
fireEvent.click(screen.getByRole("button", { name: "Apply settings" }))
expect(screen.getByText("Without Reference Answer")).toBeInTheDocument()
expect(screen.queryByText("Choose grading method")).not.toBeInTheDocument()
})
it("shows grading rules only for strategies that use grading rules", () => {
renderUploadPage()
fireEvent.click(screen.getByRole("button", { name: "Configure" }))
expect(screen.getByText("Grading rules")).toBeInTheDocument()
expect(screen.getByText("Use default grading rules")).toBeInTheDocument()
fireEvent.click(screen.getByLabelText(/^Reference answer/))
expect(screen.queryByText("Grading rules")).not.toBeInTheDocument()
expect(screen.queryByText("Use default grading rules")).not.toBeInTheDocument()
expect(screen.queryByText("Add my own grading rules")).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText(/^Without Reference Answer/))
expect(screen.queryByText("Grading rules")).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText(/^Example-guided grading/))
expect(screen.getByText("Grading rules")).toBeInTheDocument()
expect(screen.getByText("Scored examples")).toBeInTheDocument()
})
it("sends scored examples for example-guided grading", async () => {
renderUploadPage()
selectFile()
fireEvent.click(screen.getByRole("button", { name: "Configure" }))
fireEvent.click(screen.getByLabelText(/Example-guided grading/))
fireEvent.change(screen.getByLabelText("How many examples?"), {
target: { value: "2" }
})
fireEvent.change(screen.getByLabelText("Example 1 question"), {
target: { value: "What is RAM?" }
})
fireEvent.change(screen.getByLabelText("Example 1 student answer"), {
target: { value: "RAM stores active program data." }
})
fireEvent.change(screen.getByLabelText("Example 1 awarded score"), {
target: { value: "2" }
})
fireEvent.change(screen.getByLabelText("Example 2 question"), {
target: { value: "What is RAM?" }
})
fireEvent.change(screen.getByLabelText("Example 2 student answer"), {
target: { value: "RAM is permanent disk storage." }
})
fireEvent.change(screen.getByLabelText("Example 2 awarded score"), {
target: { value: "0" }
})
fireEvent.click(screen.getByRole("button", { name: "Apply settings" }))
fireEvent.click(screen.getByRole("button", { name: /Upload & Start Grading/ }))
await waitFor(() =>
expect(startGradingRun).toHaveBeenCalledWith(
"run-1",
expect.objectContaining({
strategy: "p3_true_one_shot",
examples: [
{
question: "What is RAM?",
answer: "RAM stores active program data.",
score: "2"
},
{
question: "What is RAM?",
answer: "RAM is permanent disk storage.",
score: "0"
}
]
})
)
)
})
it("uses the shared grading method drawer and feedback option for evaluation", async () => {
renderUploadPage()
selectFile()
fireEvent.click(screen.getByLabelText(/Evaluate model/))
expect(screen.queryByText("Evaluation Output")).not.toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "Configure" }))
fireEvent.click(screen.getByLabelText(/^Reference answer/))
fireEvent.click(screen.getByLabelText(/Request short feedback/))
fireEvent.click(screen.getByRole("button", { name: "Apply settings" }))
expect(screen.getByText("Reference answer")).toBeInTheDocument()
expect(screen.getByText(/feedback/)).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: /Upload & Evaluate Model/ }))
await waitFor(() =>
expect(startGradingRun).toHaveBeenCalledWith(
"run-1",
expect.objectContaining({
strategy: "p1_reference_zero_shot",
include_feedback: true
})
)
)
})
it("does not send custom rules when default rules are selected", async () => {
renderUploadPage()
selectFile()
fireEvent.click(screen.getByRole("button", { name: /Upload & Start Grading/ }))
await waitFor(() =>
expect(startGradingRun).toHaveBeenCalledWith(
"run-1",
expect.not.objectContaining({
custom_grading_rules: expect.anything()
})
)
)
})
it("does not send saved custom rules for question-only or reference-answer strategies", async () => {
renderUploadPage()
selectFile()
fireEvent.click(screen.getByRole("button", { name: "Configure" }))
fireEvent.click(screen.getByText("Add my own grading rules"))
fireEvent.change(await screen.findByPlaceholderText(/Deduct 0.5 points/), {
target: { value: "Deduct 0.5 points for missing volatility." }
})
fireEvent.click(screen.getByLabelText(/^Reference answer/))
fireEvent.click(screen.getByRole("button", { name: "Apply settings" }))
fireEvent.click(screen.getByRole("button", { name: /Upload & Start Grading/ }))
await waitFor(() =>
expect(startGradingRun).toHaveBeenCalledWith(
"run-1",
expect.objectContaining({
strategy: "p1_reference_zero_shot"
})
)
)
expect(startGradingRun).toHaveBeenCalledWith(
"run-1",
expect.not.objectContaining({
custom_grading_rules: expect.anything()
})
)
})
})
import { useEffect, useState } from "react"
import type { ChangeEvent } from "react"
import { useLocation, useNavigate } from "react-router-dom"
import AppFrame from "../components/AppFrame"
import { getRunHistory, startGradingRun, uploadSessionFile } from "../services/sessionsApi"
import type {
ChatAiModel,
GradingStrategy,
LlmProfile,
RunHistoryItem,
RunType,
StartGradingRunOptions
} from "../services/sessionsApi"
import type { UploadMetadata } from "../types"
const CHAT_AI_MODELS: Array<{ value: ChatAiModel; label: string }> = [
{ value: "gemma-4-31b-it", label: "Gemma 4" },
{ value: "mistral-large-3-675b-instruct-2512", label: "Mistral Large 3" },
{ value: "qwen3-30b-a3b-instruct-2507", label: "Qwen 3" }
]
type GradingMethodStrategy =
| "p0_instruction_only"
| "p1_reference_zero_shot"
| "p2_reference_discrete"
| "p3_true_one_shot"
type RulesMode = "default" | "custom"
interface GradingExampleDraft {
question: string
answer: string
score: string
}
const GRADING_METHODS: Array<{
value: GradingMethodStrategy
label: string
description: string
summary: string
}> = [
{
value: "p0_instruction_only",
label: "Without Reference Answer",
description: "Use the question and valid score values only.",
summary: "No reference answer"
},
{
value: "p1_reference_zero_shot",
label: "Reference answer",
description: "Use the question, reference answer, and valid score values.",
summary: "Uses reference answers"
},
{
value: "p2_reference_discrete",
label: "Reference + grading rules",
description: "Use the reference answer plus clear grading rules. Recommended for most grading.",
summary: "Recommended"
},
{
value: "p3_true_one_shot",
label: "Example-guided grading",
description: "Use the reference answer, grading rules, and your scored example answers.",
summary: "Uses scored examples"
}
]
function createExampleDrafts(): GradingExampleDraft[] {
return [
{ question: "", answer: "", score: "" },
{ question: "", answer: "", score: "" },
{ question: "", answer: "", score: "" }
]
}
function getGradingMethod(strategy: GradingMethodStrategy) {
return GRADING_METHODS.find(method => method.value === strategy) ?? GRADING_METHODS[2]
}
function usesGradingRules(strategy: GradingMethodStrategy): boolean {
return strategy === "p2_reference_discrete" || strategy === "p3_true_one_shot"
}
function getExampleHint(count: number): string {
if (count === 2) {
return "Better: give one correct/high-scoring and one incorrect/low-scoring answer."
}
if (count === 3) {
return "Best: give one correct, one partially correct, and one incorrect answer."
}
return "Give one representative scored answer."
}
function isValidScoreText(value: string): boolean {
const parsed = Number(value.trim().replace(",", "."))
return Number.isFinite(parsed) && parsed >= 0
}
function formatHistoryDate(value: string): string {
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return ""
return parsed.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric"
})
}
function UploadPage() {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [fileName, setFileName] = useState<string | null>(null)
const [runType, setRunType] = useState<RunType>("grading")
const [strategy, setStrategy] = useState<GradingMethodStrategy>("p2_reference_discrete")
const [isStrategyDrawerOpen, setIsStrategyDrawerOpen] = useState(false)
const [rulesMode, setRulesMode] = useState<RulesMode>("default")
const [customGradingRules, setCustomGradingRules] = useState("")
const [exampleCount, setExampleCount] = useState(1)
const [exampleDrafts, setExampleDrafts] = useState<GradingExampleDraft[]>(createExampleDrafts)
const [drawerError, setDrawerError] = useState<string | null>(null)
const [includeFeedback, setIncludeFeedback] = useState(false)
const [llmProfile, setLlmProfile] = useState<LlmProfile>("local_gemma")
const [chatAiModel, setChatAiModel] = useState<ChatAiModel>("gemma-4-31b-it")
const [openAiModel, setOpenAiModel] = useState("")
const [openAiApiKey, setOpenAiApiKey] = useState("")
const [isUploading, setIsUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [uploadSuccess, setUploadSuccess] = useState<UploadMetadata | null>(null)
const [runHistory, setRunHistory] = useState<RunHistoryItem[]>([])
const [historyError, setHistoryError] = useState<string | null>(null)
const [isHistoryDrawerOpen, setIsHistoryDrawerOpen] = useState(false)
const navigate = useNavigate()
const location = useLocation()
const navigationMessage =
typeof location.state === "object" &&
location.state !== null &&
"message" in location.state &&
typeof location.state.message === "string"
? location.state.message
: null
const selectedStrategy: GradingStrategy = strategy
const selectedMethod = getGradingMethod(strategy)
const selectedMethodUsesRules = usesGradingRules(strategy)
const activeExampleDrafts = exampleDrafts.slice(0, exampleCount)
useEffect(() => {
let cancelled = false
getRunHistory("grading")
.then(data => {
if (cancelled) return
setRunHistory(data.items)
setHistoryError(null)
})
.catch(error => {
if (cancelled) return
setHistoryError(error instanceof Error ? error.message : "Unable to load previous files.")
})
return () => {
cancelled = true
}
}, [])
const validatePromptSettings = () => {
if (strategy !== "p3_true_one_shot") {
setDrawerError(null)
return true
}
for (let index = 0; index < activeExampleDrafts.length; index += 1) {
const example = activeExampleDrafts[index]
if (!example.question.trim() || !example.answer.trim() || !example.score.trim()) {
setDrawerError(`Complete question, answer, and score for example ${index + 1}.`)
return false
}
if (!isValidScoreText(example.score)) {
setDrawerError(`Enter a valid non-negative score for example ${index + 1}.`)
return false
}
}
setDrawerError(null)
return true
}
const buildPromptOptions = (): Pick<StartGradingRunOptions, "custom_grading_rules" | "include_feedback" | "examples"> => {
const options: Pick<StartGradingRunOptions, "custom_grading_rules" | "include_feedback" | "examples"> = {}
const trimmedRules = customGradingRules.trim()
if (selectedMethodUsesRules && rulesMode === "custom" && trimmedRules) {
options.custom_grading_rules = trimmedRules
}
if (includeFeedback) {
options.include_feedback = true
}
if (strategy === "p3_true_one_shot") {
options.examples = activeExampleDrafts.map(example => ({
question: example.question.trim(),
answer: example.answer.trim(),
score: example.score.trim()
}))
}
return options
}
const updateExampleDraft = (
index: number,
field: keyof GradingExampleDraft,
value: string
) => {
setExampleDrafts(current =>
current.map((example, itemIndex) =>
itemIndex === index ? { ...example, [field]: value } : example
)
)
setDrawerError(null)
}
const handleApplyDrawer = () => {
if (!validatePromptSettings()) return
setIsStrategyDrawerOpen(false)
}
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
setSelectedFile(file ?? null)
setFileName(file ? file.name : null)
setUploadError(null)
setUploadSuccess(null)
}
const handleUploadClick = async () => {
if (!selectedFile) {
setUploadError("Please select a CSV/XLSX file first.")
return
}
if (!validatePromptSettings()) {
setIsStrategyDrawerOpen(true)
setUploadError("Please complete the grading method settings.")
return
}
if (llmProfile === "openai_custom") {
if (!openAiModel.trim()) {
setUploadError("Please enter the OpenAI model name.")
return
}
if (!openAiApiKey.trim()) {
setUploadError("Please enter your OpenAI API key for this run.")
return
}
}
setIsUploading(true)
try {
const metadata = await uploadSessionFile(selectedFile, runType)
setUploadSuccess(metadata)
await startGradingRun(metadata.upload_id, {
strategy: selectedStrategy,
...buildPromptOptions(),
llm_profile: llmProfile,
...(llmProfile === "openai_custom"
? {
model: openAiModel.trim(),
api_key: openAiApiKey.trim()
}
: llmProfile === "chat_ai"
? {
model: chatAiModel
}
: {})
})
navigate(
`/grading-progress/${encodeURIComponent(metadata.upload_id)}${
runType === "evaluation" ? "?mode=evaluation" : ""
}`
)
} catch (error) {
const message =
error instanceof Error
? error.message
: "Unable to upload file to backend. Please check server logs."
setUploadError(message)
} finally {
setIsUploading(false)
}
}
return (
<AppFrame>
<section className="flex min-h-[70vh] items-center justify-center">
<div className="w-full max-w-3xl space-y-6">
<div className="flex justify-start">
<button
type="button"
onClick={() => setIsHistoryDrawerOpen(true)}
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-bold text-[var(--on-primary-fixed-variant)] shadow-sm transition-colors hover:border-[var(--primary)] hover:bg-[rgb(255_218_214_/_24%)]"
>
<span className="material-symbols-outlined !text-lg">history</span>
Previous files
</button>
</div>
<aside
aria-label="Previous graded files"
className="hidden"
>
<div>
<p className="label-meta text-slate-500">History</p>
<h2 className="brand-font mt-1 text-xl font-extrabold text-[var(--on-surface)]">
Previous files
</h2>
<p className="mt-1 text-xs text-[var(--on-surface-variant)]">
Reopen graded files that have not been deleted.
</p>
</div>
{historyError && (
<p className="rounded-lg bg-[rgb(255_218_214_/_55%)] px-3 py-2 text-xs text-[var(--primary)]">
{historyError}
</p>
)}
{!historyError && runHistory.length === 0 && (
<p className="rounded-lg bg-[var(--surface-container-low)] px-3 py-3 text-sm text-[var(--on-surface-variant)]">
No graded files yet.
</p>
)}
<div className="space-y-2">
{runHistory.slice(0, 8).map(run => (
<button
key={run.run_id}
type="button"
onClick={() => navigate(`/overview-live/${encodeURIComponent(run.run_id)}`)}
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-3 text-left transition-colors hover:border-[var(--primary)] hover:bg-[rgb(255_218_214_/_24%)]"
>
<span className="block truncate text-sm font-bold text-[var(--on-surface)]">
{run.filename}
</span>
<span className="mt-1 block text-xs text-[var(--on-surface-variant)]">
{run.total_questions} questions · {run.total_answers} answers
</span>
<span className="mt-1 flex items-center justify-between gap-2 text-[11px] font-semibold text-slate-500">
<span className="truncate">{run.status}</span>
<span>{formatHistoryDate(run.created_at)}</span>
</span>
</button>
))}
</div>
</aside>
<div className="w-full space-y-6">
{navigationMessage && (
<p className="rounded-lg bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
{navigationMessage}
</p>
)}
<div className="soft-card overflow-hidden">
<div className="p-6 sm:p-10">
<div className="group relative rounded-xl border-2 border-dashed border-[rgb(223_191_189_/_70%)] bg-[var(--surface)] transition-colors duration-300 hover:border-[var(--primary)]">
<div className="flex flex-col items-center justify-center space-y-5 px-8 py-16 text-center sm:py-20">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-[var(--primary-fixed)] text-[var(--primary-container)] transition-transform duration-300 group-hover:scale-110">
<span className="material-symbols-outlined !text-4xl">
upload_file
</span>
</div>
<div>
<h2 className="brand-font text-2xl font-bold">Attachment</h2>
<p className="mx-auto mt-2 max-w-xs text-sm text-[var(--on-surface-variant)]">
Drag your CSV/XLSX file here or click to browse.
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2 text-[10px] font-bold uppercase tracking-wider text-[var(--on-secondary-container)]">
<span className="rounded-full bg-[var(--secondary-container)] px-3 py-1">
.XLSX
</span>
<span className="rounded-full bg-[var(--secondary-container)] px-3 py-1">
.CSV
</span>
<span className="rounded-full bg-[var(--secondary-container)] px-3 py-1">
Max 50MB
</span>
</div>
{fileName && (
<p className="text-xs font-semibold text-[var(--on-surface-variant)]">
Selected: {fileName}
</p>
)}
</div>
<input
aria-label="Upload grading file"
type="file"
accept=".csv,.xlsx"
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
onChange={handleFileChange}
/>
</div>
<div className="mt-8 space-y-4">
<fieldset className="space-y-2">
<legend className="text-sm font-semibold text-[var(--on-primary-fixed-variant)]">
Workflow
</legend>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label className="flex items-start gap-3 rounded-lg bg-[var(--surface-container-highest)] px-4 py-3 text-sm text-[var(--on-surface)]">
<input
type="radio"
name="run-type"
value="grading"
checked={runType === "grading"}
onChange={() => setRunType("grading")}
className="mt-1 h-4 w-4 border-none text-[var(--primary)] focus:ring-[var(--primary)]"
/>
<span>
<span className="block font-semibold">Grade answers</span>
<span className="mt-1 block text-xs text-[var(--on-surface-variant)]">
Create a human review queue before export.
</span>
</span>
</label>
<label className="flex items-start gap-3 rounded-lg bg-[var(--surface-container-highest)] px-4 py-3 text-sm text-[var(--on-surface)]">
<input
type="radio"
name="run-type"
value="evaluation"
checked={runType === "evaluation"}
onChange={() => setRunType("evaluation")}
className="mt-1 h-4 w-4 border-none text-[var(--primary)] focus:ring-[var(--primary)]"
/>
<span>
<span className="block font-semibold">Evaluate model</span>
<span className="mt-1 block text-xs text-[var(--on-surface-variant)]">
Compare LLM scores with professor scores.
</span>
</span>
</label>
</div>
</fieldset>
<section
aria-label="Grading method"
className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-[var(--on-primary-fixed-variant)]">
AI grading method
</p>
<h3 className="mt-1 text-base font-bold text-[var(--on-surface)]">
{selectedMethod.label}
</h3>
<p className="mt-1 text-xs text-[var(--on-surface-variant)]">
{selectedMethod.summary}
{selectedMethodUsesRules
? rulesMode === "custom" && customGradingRules.trim()
? " · custom rules"
: " · default rules"
: ""}
{strategy === "p3_true_one_shot"
? ` · ${exampleCount} example${exampleCount === 1 ? "" : "s"}`
: ""}
{includeFeedback ? " · feedback" : ""}
</p>
</div>
<button
type="button"
onClick={() => {
setDrawerError(null)
setIsStrategyDrawerOpen(true)
}}
className="inline-flex items-center justify-center rounded-lg border border-[rgb(123_19_24_/_35%)] px-4 py-2 text-sm font-bold text-[var(--primary)] transition-colors hover:bg-[rgb(255_218_214_/_35%)]"
>
Configure
</button>
</div>
</section>
<fieldset className="space-y-2">
<legend className="text-sm font-semibold text-[var(--on-primary-fixed-variant)]">
Model Provider
</legend>
<div className="grid grid-cols-1 gap-2 md:grid-cols-3">
<label className="flex items-center gap-3 rounded-lg bg-[var(--surface-container-highest)] px-4 py-3 text-sm text-[var(--on-surface)]">
<input
type="radio"
name="llm-mode"
value="local_gemma"
checked={llmProfile === "local_gemma"}
onChange={() => setLlmProfile("local_gemma")}
className="h-4 w-4 border-none text-[var(--primary)] focus:ring-[var(--primary)]"
/>
<span>Local Gemma</span>
</label>
<label className="flex items-center gap-3 rounded-lg bg-[var(--surface-container-highest)] px-4 py-3 text-sm text-[var(--on-surface)]">
<input
type="radio"
name="llm-mode"
value="chat_ai"
checked={llmProfile === "chat_ai"}
onChange={() => setLlmProfile("chat_ai")}
className="h-4 w-4 border-none text-[var(--primary)] focus:ring-[var(--primary)]"
/>
<span>Academic Cloud Chat AI</span>
</label>
<label className="flex items-center gap-3 rounded-lg bg-[var(--surface-container-highest)] px-4 py-3 text-sm text-[var(--on-surface)]">
<input
type="radio"
name="llm-mode"
value="openai_custom"
checked={llmProfile === "openai_custom"}
onChange={() => setLlmProfile("openai_custom")}
className="h-4 w-4 border-none text-[var(--primary)] focus:ring-[var(--primary)]"
/>
<span>Use my OpenAI key</span>
</label>
</div>
</fieldset>
{llmProfile === "chat_ai" && (
<label className="block space-y-2 text-sm font-semibold text-[var(--on-primary-fixed-variant)]">
<span>Academic Cloud Model</span>
<select
value={chatAiModel}
onChange={event => setChatAiModel(event.target.value as ChatAiModel)}
className="w-full rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]"
>
{CHAT_AI_MODELS.map(model => (
<option key={model.value} value={model.value}>
{model.label}
</option>
))}
</select>
</label>
)}
{llmProfile === "openai_custom" && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="space-y-1 text-sm font-semibold text-[var(--on-primary-fixed-variant)]">
<span>OpenAI Model</span>
<input
type="text"
value={openAiModel}
onChange={event => setOpenAiModel(event.target.value)}
placeholder="e.g. gpt-4.1-mini"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]"
/>
</label>
<label className="space-y-1 text-sm font-semibold text-[var(--on-primary-fixed-variant)]">
<span>API Key</span>
<input
type="password"
value={openAiApiKey}
onChange={event => setOpenAiApiKey(event.target.value)}
placeholder="sk-..."
autoComplete="off"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]"
/>
</label>
</div>
)}
<button
type="button"
onClick={handleUploadClick}
disabled={isUploading || !selectedFile}
className="custom-gradient-primary soft-hover inline-flex w-full items-center justify-center gap-2 rounded-lg px-8 py-3 text-sm font-bold text-white shadow-lg shadow-[rgb(123_19_24_/_0.2)] disabled:cursor-not-allowed disabled:opacity-40"
>
{isUploading
? "Uploading and starting..."
: runType === "evaluation"
? "Upload & Evaluate Model"
: "Upload & Start Grading"}
<span className="material-symbols-outlined !text-lg">arrow_forward</span>
</button>
<p className="text-sm text-[var(--on-surface-variant)]">
Grading starts only when you click the upload button.
</p>
</div>
{uploadError && (
<p className="mt-4 rounded-lg bg-[rgb(255_218_214_/_60%)] px-4 py-3 text-sm text-[var(--primary)]">
{uploadError}
</p>
)}
{uploadSuccess && (
<div className="mt-4 space-y-1 rounded-lg bg-[rgb(223_191_189_/_28%)] px-4 py-3 text-sm text-[var(--on-surface)]">
<p className="font-semibold text-[var(--primary)]">
{uploadSuccess.message}
</p>
<p>Upload ID: {uploadSuccess.upload_id}</p>
<p>Stored As: {uploadSuccess.stored_filename}</p>
<p>Relative Path: {uploadSuccess.relative_path}</p>
<p>File Type: .{uploadSuccess.file_type}</p>
</div>
)}
</div>
</div>
</div>
</div>
</section>
{isHistoryDrawerOpen && (
<div className="fixed inset-0 z-[60]">
<button
type="button"
aria-label="Close previous files panel"
onClick={() => setIsHistoryDrawerOpen(false)}
className="absolute inset-0 h-full w-full bg-slate-950/30"
/>
<aside className="relative z-[61] h-full w-full max-w-sm overflow-y-auto bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<p className="label-meta text-slate-500">History</p>
<h2 className="brand-font mt-2 text-2xl font-extrabold text-[var(--on-surface)]">
Previous files
</h2>
<p className="mt-2 text-sm text-[var(--on-surface-variant)]">
Reopen graded files that have not been deleted.
</p>
</div>
<button
type="button"
onClick={() => setIsHistoryDrawerOpen(false)}
className="rounded-full p-2 text-slate-500 transition-colors hover:bg-[var(--surface-container-low)]"
aria-label="Close"
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<div className="mt-6 space-y-3">
{historyError && (
<p className="rounded-lg bg-[rgb(255_218_214_/_55%)] px-3 py-2 text-sm text-[var(--primary)]">
{historyError}
</p>
)}
{!historyError && runHistory.length === 0 && (
<p className="rounded-lg bg-[var(--surface-container-low)] px-4 py-4 text-sm text-[var(--on-surface-variant)]">
No graded files yet.
</p>
)}
{runHistory.slice(0, 12).map(run => (
<button
key={run.run_id}
type="button"
onClick={() => {
setIsHistoryDrawerOpen(false)
navigate(`/overview-live/${encodeURIComponent(run.run_id)}`)
}}
className="w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-left transition-colors hover:border-[var(--primary)] hover:bg-[rgb(255_218_214_/_24%)]"
>
<span className="block truncate text-sm font-bold text-[var(--on-surface)]">
{run.filename}
</span>
<span className="mt-1 block text-xs text-[var(--on-surface-variant)]">
{run.total_questions} questions · {run.total_answers} answers
</span>
<span className="mt-1 flex items-center justify-between gap-2 text-[11px] font-semibold text-slate-500">
<span className="truncate">{run.status}</span>
<span>{formatHistoryDate(run.created_at)}</span>
</span>
</button>
))}
</div>
</aside>
</div>
)}
{isStrategyDrawerOpen && (
<div className="fixed inset-0 z-[70]">
<button
type="button"
aria-label="Close grading method panel"
onClick={() => setIsStrategyDrawerOpen(false)}
className="absolute inset-0 h-full w-full bg-slate-950/30"
/>
<aside className="relative z-[71] ml-auto h-full w-full max-w-2xl overflow-y-auto bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<p className="label-meta text-slate-500">Grading setup</p>
<h2 className="brand-font mt-2 text-2xl font-extrabold">
Choose grading method
</h2>
<p className="mt-2 text-sm text-[var(--on-surface-variant)]">
These settings apply to the whole uploaded file.
</p>
</div>
<button
type="button"
onClick={() => setIsStrategyDrawerOpen(false)}
className="rounded-full p-2 text-slate-500 transition-colors hover:bg-[var(--surface-container-low)]"
aria-label="Close"
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<div className="mt-6 space-y-6">
<fieldset className="space-y-3">
<legend className="text-sm font-bold text-[var(--on-primary-fixed-variant)]">
Method
</legend>
<div className="space-y-3">
{GRADING_METHODS.map(method => (
<label
key={method.value}
className={[
"flex cursor-pointer gap-3 rounded-xl border px-4 py-3 text-sm transition-colors",
strategy === method.value
? "border-[var(--primary)] bg-[rgb(255_218_214_/_35%)]"
: "border-slate-200 bg-white hover:bg-[var(--surface-container-low)]"
].join(" ")}
>
<input
type="radio"
name="grading-method"
value={method.value}
checked={strategy === method.value}
onChange={() => {
setStrategy(method.value)
setDrawerError(null)
}}
className="mt-1 h-4 w-4 border-none text-[var(--primary)] focus:ring-[var(--primary)]"
/>
<span>
<span className="block font-bold text-[var(--on-surface)]">
{method.label}
</span>
<span className="mt-1 block text-xs leading-relaxed text-[var(--on-surface-variant)]">
{method.description}
</span>
</span>
</label>
))}
</div>
</fieldset>
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm transition-colors hover:bg-[var(--surface-container-low)]">
<input
type="checkbox"
checked={includeFeedback}
onChange={event => setIncludeFeedback(event.target.checked)}
className="mt-1 h-4 w-4 rounded border-slate-300 text-[var(--primary)] focus:ring-[var(--primary)]"
/>
<span>
<span className="block font-bold text-[var(--on-surface)]">
Request short feedback
</span>
<span className="mt-1 block text-xs leading-relaxed text-[var(--on-surface-variant)]">
Ask the AI for up to two concise bullet points explaining the score.
</span>
</span>
</label>
{selectedMethodUsesRules && (
<fieldset className="space-y-3">
<legend className="text-sm font-bold text-[var(--on-primary-fixed-variant)]">
Grading rules
</legend>
<div className="grid grid-cols-1 gap-2">
<label
onClick={() => {
setRulesMode("default")
setDrawerError(null)
}}
className="flex items-center gap-3 rounded-lg bg-[var(--surface-container-highest)] px-4 py-3 text-sm text-[var(--on-surface)]"
>
<input
type="radio"
name="rules-mode"
value="default"
checked={rulesMode === "default"}
onClick={() => {
setRulesMode("default")
setDrawerError(null)
}}
onChange={() => {
setRulesMode("default")
setDrawerError(null)
}}
className="h-4 w-4 border-none text-[var(--primary)] focus:ring-[var(--primary)]"
/>
<span>Use default grading rules</span>
</label>
<label
onClick={() => setRulesMode("custom")}
className="flex items-center gap-3 rounded-lg bg-[var(--surface-container-highest)] px-4 py-3 text-sm text-[var(--on-surface)]"
>
<input
type="radio"
name="rules-mode"
value="custom"
checked={rulesMode === "custom"}
onClick={() => setRulesMode("custom")}
onChange={() => setRulesMode("custom")}
className="h-4 w-4 border-none text-[var(--primary)] focus:ring-[var(--primary)]"
/>
<span>Add my own grading rules</span>
</label>
</div>
{rulesMode === "custom" && (
<label className="block space-y-2 text-sm font-semibold text-[var(--on-primary-fixed-variant)]">
<span>Custom grading rules</span>
<textarea
value={customGradingRules}
onChange={event => setCustomGradingRules(event.target.value)}
placeholder="Example: Deduct 0.5 points if the answer misses the main definition."
className="min-h-28 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]"
/>
<span className="block text-xs font-normal text-[var(--on-surface-variant)]">
If this is left blank, the default grading rules will be used.
</span>
</label>
)}
</fieldset>
)}
{strategy === "p3_true_one_shot" && (
<section className="space-y-4 rounded-xl border border-slate-200 p-4">
<div>
<h3 className="text-sm font-bold text-[var(--on-primary-fixed-variant)]">
Scored examples
</h3>
<p className="mt-1 text-xs leading-relaxed text-[var(--on-surface-variant)]">
{getExampleHint(exampleCount)}
</p>
</div>
<label className="block space-y-2 text-sm font-semibold text-[var(--on-primary-fixed-variant)]">
<span>How many examples?</span>
<select
value={exampleCount}
onChange={event => {
setExampleCount(Number(event.target.value))
setDrawerError(null)
}}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]"
>
<option value={1}>1 example</option>
<option value={2}>2 examples</option>
<option value={3}>3 examples</option>
</select>
</label>
<div className="space-y-5">
{activeExampleDrafts.map((example, index) => (
<div key={index} className="space-y-3 rounded-lg bg-[var(--surface-container-low)] p-4">
<h4 className="text-sm font-bold text-[var(--on-surface)]">
Example {index + 1}
</h4>
<label className="block space-y-1 text-xs font-bold text-[var(--on-primary-fixed-variant)]">
<span>Example {index + 1} question</span>
<input
type="text"
value={example.question}
onChange={event => updateExampleDraft(index, "question", event.target.value)}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]"
/>
</label>
<label className="block space-y-1 text-xs font-bold text-[var(--on-primary-fixed-variant)]">
<span>Example {index + 1} student answer</span>
<textarea
value={example.answer}
onChange={event => updateExampleDraft(index, "answer", event.target.value)}
className="min-h-20 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]"
/>
</label>
<label className="block space-y-1 text-xs font-bold text-[var(--on-primary-fixed-variant)]">
<span>Example {index + 1} awarded score</span>
<input
type="text"
inputMode="decimal"
value={example.score}
onChange={event => updateExampleDraft(index, "score", event.target.value)}
placeholder="e.g. 2"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-[var(--on-surface)] outline-none transition-colors focus:border-[var(--primary)]"
/>
</label>
</div>
))}
</div>
</section>
)}
{drawerError && (
<p className="rounded-lg bg-[rgb(255_218_214_/_60%)] px-4 py-3 text-sm text-[var(--primary)]">
{drawerError}
</p>
)}
<div className="flex gap-3">
<button
type="button"
onClick={handleApplyDrawer}
className="custom-gradient-primary flex-1 rounded-lg px-4 py-3 text-sm font-bold text-white"
>
Apply settings
</button>
<button
type="button"
onClick={() => setIsStrategyDrawerOpen(false)}
className="rounded-lg border border-slate-200 px-4 py-3 text-sm font-bold text-slate-600 hover:bg-[var(--surface-container-low)]"
>
Cancel
</button>
</div>
</div>
</aside>
</div>
)}
</AppFrame>
)
}
export default UploadPage
import type { UploadMetadata } from "../types"
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, "") ??
"http://localhost:8000"
async function parseError(response: Response): Promise<string> {
try {
const payload = (await response.json()) as { detail?: string }
if (payload?.detail) return payload.detail
} catch {
// Ignore JSON parse errors and fall back to status text.
}
return response.statusText || "Request failed"
}
export interface GradeStartResponse {
run_id: string
status: string
strategy?: string
}
export type GradingStrategy =
| "p0_instruction_only"
| "p1_reference_zero_shot"
| "p2_reference_discrete"
| "p3_true_one_shot"
| "p0_instruction_only_with_feedback"
| "p1_reference_zero_shot_with_feedback"
export type LlmProfile = "local_gemma" | "chat_ai" | "openai_custom"
export type ChatAiModel =
| "gemma-4-31b-it"
| "mistral-large-3-675b-instruct-2512"
| "qwen3-30b-a3b-instruct-2507"
export type RunType = "grading" | "evaluation"
export interface StartGradingRunOptions {
strategy?: GradingStrategy
custom_grading_rules?: string
include_feedback?: boolean
examples?: Array<{
question: string
answer: string
score: string
}>
llm_profile?: LlmProfile
provider?: string
model?: string
base_url?: string
api_key?: string
}
export interface GradeStatusResponse {
run_id: string
strategy: string | null
job_status: string
started_at: string | null
finished_at: string | null
processed: number
completed: number
failed: number
review_required: number
total_to_process: number
error: string | null
db_pending: number
db_processing: number
db_completed: number
db_failed: number
db_review_required: number
db_total: number
}
export interface RunDeleteResponse {
run_id: string
deleted: boolean
answers_deleted: number
students_deleted: number
questions_deleted: number
grading_run_deleted: number
file_deleted: boolean
file_path: string | null
}
export interface RunHistoryItem {
run_id: string
filename: string
run_type: RunType
status: string
created_at: string
total_students: number
total_questions: number
total_answers: number
}
export interface RunHistoryResponse {
items: RunHistoryItem[]
}
export interface RunQuestionSummary {
question_id: number
question_number: number
question_text: string
max_points: number | null
attempted_count: number
reviewed_count: number
pending_review_count: number
avg_llm_score: number | null
avg_final_score: number | null
predicted_score_counts: Array<{ score: number; count: number }>
}
export interface RunQuestionsResponse {
run_id: string
items: RunQuestionSummary[]
}
export interface QuestionAnswerRow {
answer_id: number
student_id: number
student_name: string
student_code: string
email: string
answer_text: string
llm_score: number | null
observed_score: number | null
human_score: number | null
final_score: number | null
review_status: "pending" | "accepted" | "overridden"
review_required: boolean
accepted_llm: boolean
reviewed_at: string | null
processing_status: string
llm_feedback: string | null
}
export type LabelScaleKey = "full" | "whole"
export interface LabelMetricRow {
label: number
true_positive: number
predicted_count: number
support: number
precision: number | null
recall: number | null
low_support: boolean
}
export interface LabelScaleReport {
scale: LabelScaleKey
labels: LabelMetricRow[]
}
export interface LabelQuestionReport {
question_id: number
question_number: number
question_text: string
max_points: number
total_answer_count: number
evaluated_answer_count: number
failed_llm_count: number
excluded_invalid_count: number
full_scale: LabelScaleReport
whole_scale: LabelScaleReport | null
default_scale: LabelScaleKey
}
export interface LabelEvaluationWarning {
code: string
message: string
count: number
question_id: number | null
}
export interface LabelEvaluationResponse {
run_id: string
filename: string
run_type: string
low_support_threshold: number
questions: LabelQuestionReport[]
totals: {
total_questions: number
total_answer_count: number
evaluated_answer_count: number
failed_llm_count: number
excluded_invalid_count: number
skipped_question_count: number
coverage_percent: number | null
}
warnings: LabelEvaluationWarning[]
}
export interface QuestionAnswersResponse {
run_id: string
question_id: number
question_number: number
question_text: string
max_points: number | null
items: QuestionAnswerRow[]
pagination: {
page: number
page_size: number
total: number
}
}
export interface ReviewSummaryResponse {
run_id: string
filename: string
grading_status: string
total_students: number
total_questions: number
total_answers: number
total_graded: number
total_failed: number
total_review_pending: number
total_accepted: number
total_overridden: number
review_completion_percent: number
export_ready: boolean
}
export interface ReviewActionResponse {
answer_id: number
run_id: string
student_id: number
question_id: number
processing_status: string
review_status: "pending" | "accepted" | "overridden"
review_required: boolean
llm_score: number | null
human_score: number | null
final_score: number | null
accepted_llm: boolean
review_note: string | null
reviewed_by: string | null
reviewed_at: string | null
graded_at: string | null
}
export interface RunStudentRow {
student_id: number
student_code: string
first_name: string
last_name: string
email: string
answered_count: number
reviewed_count: number
pending_review_count: number
total_final_score: number
total_max_points: number
review_complete: boolean
}
export interface RunStudentsResponse {
run_id: string
items: RunStudentRow[]
pagination: {
page: number
page_size: number
total: number
}
}
export async function uploadSessionFile(file: File, runType: RunType = "grading"): Promise<UploadMetadata> {
const formData = new FormData()
formData.append("file", file)
const uploadResponse = await fetch(`${API_BASE_URL}/upload?run_type=${encodeURIComponent(runType)}`, {
method: "POST",
body: formData
})
if (!uploadResponse.ok) {
const message = await parseError(uploadResponse)
throw new Error(`Upload failed: ${message}`)
}
return (await uploadResponse.json()) as UploadMetadata
}
export async function startGradingRun(
runId: string,
options: StartGradingRunOptions = {}
): Promise<GradeStartResponse> {
const response = await fetch(
`${API_BASE_URL}/grade/${encodeURIComponent(runId)}/start`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
strategy: options.strategy ?? "p1_reference_zero_shot",
custom_grading_rules: options.custom_grading_rules,
include_feedback: options.include_feedback,
examples: options.examples,
llm_profile: options.llm_profile,
provider: options.provider,
model: options.model,
base_url: options.base_url,
api_key: options.api_key
})
}
)
if (!response.ok) {
const message = await parseError(response)
throw new Error(`Unable to start grading: ${message}`)
}
return (await response.json()) as GradeStartResponse
}
export async function getGradingStatus(runId: string): Promise<GradeStatusResponse> {
const response = await fetch(`${API_BASE_URL}/grade/${encodeURIComponent(runId)}/status`)
if (!response.ok) {
const message = await parseError(response)
throw new Error(`Unable to fetch grading status: ${message}`)
}
return (await response.json()) as GradeStatusResponse
}
export async function getRunReviewSummary(runId: string): Promise<ReviewSummaryResponse> {
const response = await fetch(`${API_BASE_URL}/runs/${encodeURIComponent(runId)}/review-summary`)
if (!response.ok) {
const message = await parseError(response)
throw new Error(`Unable to fetch run summary: ${message}`)
}
return (await response.json()) as ReviewSummaryResponse
}
export async function getRunHistory(runType: RunType = "grading"): Promise<RunHistoryResponse> {
const url = new URL(`${API_BASE_URL}/runs`)
url.searchParams.set("run_type", runType)
const response = await fetch(url.toString())
if (!response.ok) {
const message = await parseError(response)
throw new Error(`Unable to fetch run history: ${message}`)
}
return (await response.json()) as RunHistoryResponse
}
export async function deleteRun(runId: string): Promise<RunDeleteResponse> {
const response = await fetch(`${API_BASE_URL}/runs/${encodeURIComponent(runId)}`, {
method: "DELETE"
})
if (!response.ok) {
const message = await parseError(response)
throw new Error(`Unable to delete run: ${message}`)
}
return (await response.json()) as RunDeleteResponse
}
export async function getRunQuestions(runId: string): Promise<RunQuestionsResponse> {
const response = await fetch(`${API_BASE_URL}/runs/${encodeURIComponent(runId)}/questions`)
if (!response.ok) {
const message = await parseError(response)
throw new Error(`Unable to fetch questions: ${message}`)
}
return (await response.json()) as RunQuestionsResponse
}
export async function getRunStudents(
runId: string,
page = 1,
pageSize = 200
): Promise<RunStudentsResponse> {
const url = new URL(`${API_BASE_URL}/runs/${encodeURIComponent(runId)}/students`)
url.searchParams.set("page", String(page))
url.searchParams.set("page_size", String(pageSize))
const response = await fetch(url.toString())
if (!response.ok) {
const message = await parseError(response)
throw new Error(`Unable to fetch students: ${message}`)
}
return (await response.json()) as RunStudentsResponse
}
export async function getQuestionAnswers(
runId: string,
questionId: number,
page = 1,
pageSize = 200,
filters: {
predictedScores?: number[]
scoreScale?: LabelScaleKey
} = {}
): Promise<QuestionAnswersResponse> {
const url = new URL(
`${API_BASE_URL}/runs/${encodeURIComponent(runId)}/questions/${questionId}/answers`
)
url.searchParams.set("page", String(page))
url.searchParams.set("page_size", String(pageSize))
if (filters.scoreScale) url.searchParams.set("score_scale", filters.scoreScale)
filters.predictedScores?.forEach(score => url.searchParams.append("predicted_score", String(score)))
const response = await fetch(url.toString())
if (!response.ok) {
const message = await parseError(response)
throw new Error(`Unable to fetch question answers: ${message}`)
}
return (await response.json()) as QuestionAnswersResponse
}
export async function getLabelEvaluation(runId: string): Promise<LabelEvaluationResponse> {
const response = await fetch(`${API_BASE_URL}/runs/${encodeURIComponent(runId)}/label-evaluation`)
if (!response.ok) {
const message = await parseError(response)
throw new Error(`Unable to fetch label evaluation: ${message}`)
}
return (await response.json()) as LabelEvaluationResponse
}
export async function reviewAnswer(
answerId: number,
payload:
| { action: "accept_llm"; review_note?: string; reviewed_by?: string }
| {
action: "override_score"
human_score: number
review_note?: string
reviewed_by?: string
}
): Promise<ReviewActionResponse> {
const response = await fetch(`${API_BASE_URL}/answers/${answerId}/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
if (!response.ok) {
const message = await parseError(response)
throw new Error(`Unable to save review decision: ${message}`)
}
return (await response.json()) as ReviewActionResponse
}
import "@testing-library/jest-dom/vitest"
export interface UploadMetadata {
message: string
original_filename: string
stored_filename: string
file_type: string
relative_path: string
upload_id: string
}
import { describe, expect, it } from "vitest"
import { resolveGradingStrategy } from "./evaluationPrompt"
describe("resolveGradingStrategy", () => {
it("defaults evaluation to the normal score-only strategy", () => {
expect(resolveGradingStrategy("evaluation", "p1_reference_zero_shot", "score_only")).toBe(
"p1_reference_zero_shot"
)
})
it("maps evaluation explanation mode to feedback strategies", () => {
expect(
resolveGradingStrategy("evaluation", "p0_instruction_only", "score_with_explanation")
).toBe("p0_instruction_only_with_feedback")
expect(
resolveGradingStrategy("evaluation", "p1_reference_zero_shot", "score_with_explanation")
).toBe("p1_reference_zero_shot_with_feedback")
})
it("does not change normal grading", () => {
expect(
resolveGradingStrategy("grading", "p1_reference_zero_shot", "score_with_explanation")
).toBe("p1_reference_zero_shot")
})
})
import type { GradingStrategy, RunType } from "../services/sessionsApi"
export type EvaluationOutputMode = "score_only" | "score_with_explanation"
export function resolveGradingStrategy(
runType: RunType,
strategy: GradingStrategy,
evaluationOutputMode: EvaluationOutputMode
): GradingStrategy {
if (runType !== "evaluation" || evaluationOutputMode === "score_only") {
return strategy
}
return strategy === "p0_instruction_only"
? "p0_instruction_only_with_feedback"
: "p1_reference_zero_shot_with_feedback"
}
export function formatGrade(value: number): string {
const formatted = Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)
return `${formatted} ${value === 1 ? "point" : "points"}`
}
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
})
sequenceDiagram
actor Instructor
participant UI as React UI
participant API as FastAPI Backend
participant DB as SQLite
participant LLM as OpenAI-Compatible LLM
Instructor->>UI: Select CSV/XLSX, prompt strategy, provider
UI->>API: Upload file
API->>API: Store uploaded file
API->>API: Parse and normalize rows
API->>DB: Create run, students, questions, answers
API-->>UI: upload_id / run_id
UI->>API: Start grading run
API->>API: Start background grading thread
UI->>API: Poll grading status every 2s
loop For each pending answer
API->>DB: Load answer and question
API->>API: Build P0 or P1 prompt
API->>LLM: Chat-completions request
LLM-->>API: Numeric score text
API->>API: Parse, clamp, snap score
API->>DB: Save llm_score and mark review_required
end
UI->>API: Load review overview and answers
Instructor->>UI: Accept LLM score or override score
UI->>API: Submit answer review
API->>DB: Save final_score and review status
Instructor->>UI: Export or delete run
UI->>API: Export Moodle CSV
UI->>API: Delete grading run
<svg id="my-svg" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="max-width: 1491px; background-color: white;" viewBox="-50 -10 1491 1344" role="graphics-document document" aria-roledescription="sequence"><g><rect x="1201" y="1258" fill="#eaeaea" stroke="#666" width="190" height="65" name="LLM" rx="3" ry="3" class="actor actor-bottom"/><text x="1296" y="1290.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="1296" dy="0">OpenAI-Compatible LLM</tspan></text></g><g><rect x="1001" y="1258" fill="#eaeaea" stroke="#666" width="150" height="65" name="DB" rx="3" ry="3" class="actor actor-bottom"/><text x="1076" y="1290.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="1076" dy="0">SQLite</tspan></text></g><g><rect x="659" y="1258" fill="#eaeaea" stroke="#666" width="150" height="65" name="API" rx="3" ry="3" class="actor actor-bottom"/><text x="734" y="1290.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="734" dy="0">FastAPI Backend</tspan></text></g><g><rect x="361" y="1258" fill="#eaeaea" stroke="#666" width="150" height="65" name="UI" rx="3" ry="3" class="actor actor-bottom"/><text x="436" y="1290.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="436" dy="0">React UI</tspan></text></g><g/><g><line id="actor4" x1="1296" y1="65" x2="1296" y2="1258" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="LLM" data-et="life-line" data-id="LLM"/><g id="root-4" data-et="participant" data-type="participant" data-id="LLM"><rect x="1201" y="0" fill="#eaeaea" stroke="#666" width="190" height="65" name="LLM" rx="3" ry="3" class="actor actor-top"/><text x="1296" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="1296" dy="0">OpenAI-Compatible LLM</tspan></text></g></g><g><line id="actor3" x1="1076" y1="65" x2="1076" y2="1258" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="DB" data-et="life-line" data-id="DB"/><g id="root-3" data-et="participant" data-type="participant" data-id="DB"><rect x="1001" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="DB" rx="3" ry="3" class="actor actor-top"/><text x="1076" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="1076" dy="0">SQLite</tspan></text></g></g><g><line id="actor2" x1="734" y1="65" x2="734" y2="1258" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="API" data-et="life-line" data-id="API"/><g id="root-2" data-et="participant" data-type="participant" data-id="API"><rect x="659" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="API" rx="3" ry="3" class="actor actor-top"/><text x="734" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="734" dy="0">FastAPI Backend</tspan></text></g></g><g><line id="actor1" x1="436" y1="65" x2="436" y2="1258" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="UI" data-et="life-line" data-id="UI"/><g id="root-1" data-et="participant" data-type="participant" data-id="UI"><rect x="361" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="UI" rx="3" ry="3" class="actor actor-top"/><text x="436" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="436" dy="0">React UI</tspan></text></g></g><g><line id="actor0" x1="75" y1="80" x2="75" y2="1258" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="Instructor" data-et="life-line" data-id="Instructor"/></g><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#552222;}#my-svg .error-text{fill:#552222;stroke:#552222;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:#333333;stroke:#333333;}#my-svg .marker.cross{stroke:#333333;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .actor{stroke:#9370DB;fill:#ECECFF;stroke-width:1;}#my-svg rect.actor.outer-path[data-look="neo"]{filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#my-svg rect.note[data-look="neo"]{stroke:#aaaa33;fill:#fff5ad;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#my-svg text.actor&gt;tspan{fill:black;stroke:none;}#my-svg .actor-line{stroke:#9370DB;}#my-svg .innerArc{stroke-width:1.5;stroke-dasharray:none;}#my-svg .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#my-svg .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#my-svg [id$="-arrowhead"] path{fill:#333;stroke:#333;}#my-svg .sequenceNumber{fill:white;}#my-svg [id$="-sequencenumber"]{fill:#333;}#my-svg [id$="-crosshead"] path{fill:#333;stroke:#333;}#my-svg .messageText{fill:#333;stroke:none;}#my-svg .labelBox{stroke:#9370DB;fill:#ECECFF;filter:none;}#my-svg .labelText,#my-svg .labelText&gt;tspan{fill:black;stroke:none;}#my-svg .loopText,#my-svg .loopText&gt;tspan{fill:black;stroke:none;}#my-svg .sectionTitle,#my-svg .sectionTitle&gt;tspan{fill:black;stroke:none;}#my-svg .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#9370DB;fill:#9370DB;}#my-svg .note{stroke:#aaaa33;fill:#fff5ad;}#my-svg .noteText,#my-svg .noteText&gt;tspan{fill:black;stroke:none;font-weight:normal;}#my-svg .activation0{fill:#f4f4f4;stroke:#666;}#my-svg .activation1{fill:#f4f4f4;stroke:#666;}#my-svg .activation2{fill:#f4f4f4;stroke:#666;}#my-svg .actorPopupMenu{position:absolute;}#my-svg .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#my-svg .actor-man circle,#my-svg line{fill:#ECECFF;stroke-width:2px;}#my-svg g rect.rect{filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));stroke:#9370DB;}#my-svg .node .neo-node{stroke:#9370DB;}#my-svg [data-look="neo"].node rect,#my-svg [data-look="neo"].cluster rect,#my-svg [data-look="neo"].node polygon{stroke:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#my-svg [data-look="neo"].node path{stroke:#9370DB;stroke-width:1px;}#my-svg [data-look="neo"].node .outer-path{filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#my-svg [data-look="neo"].node .neo-line path{stroke:#9370DB;filter:none;}#my-svg [data-look="neo"].node circle{stroke:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#my-svg [data-look="neo"].node circle .state-start{fill:#000000;}#my-svg [data-look="neo"].icon-shape .icon{fill:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#my-svg [data-look="neo"].icon-shape .icon-neo path{stroke:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g/><defs><symbol id="my-svg-computer" width="24" height="24"><path transform="scale(.5)" d="M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z"/></symbol></defs><defs><symbol id="my-svg-database" fill-rule="evenodd" clip-rule="evenodd"><path transform="scale(.5)" d="M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z"/></symbol></defs><defs><symbol id="my-svg-clock" width="24" height="24"><path transform="scale(.5)" d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z"/></symbol></defs><defs><marker id="my-svg-arrowhead" refX="7.9" refY="5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M -1 0 L 10 5 L 0 10 z"/></marker></defs><defs><marker id="my-svg-crosshead" markerWidth="15" markerHeight="8" orient="auto" refX="4" refY="4.5"><path fill="none" stroke="#000000" stroke-width="1pt" d="M 1,2 L 6,7 M 6,2 L 1,7" style="stroke-dasharray: 0, 0;"/></marker></defs><defs><marker id="my-svg-filled-head" refX="15.5" refY="7" markerWidth="20" markerHeight="28" orient="auto"><path d="M 18,7 L9,13 L14,7 L9,1 Z"/></marker></defs><defs><marker id="my-svg-sequencenumber" refX="15" refY="15" markerWidth="60" markerHeight="40" orient="auto"><circle cx="15" cy="15" r="6"/></marker></defs><defs><marker id="my-svg-solidTopArrowHead" refX="7.9" refY="7.25" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 8 L 0 8 z"/></marker></defs><defs><marker id="my-svg-solidBottomArrowHead" refX="7.9" refY="0.75" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 0 L 0 8 z"/></marker></defs><defs><marker id="my-svg-stickTopArrowHead" refX="7.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 7 7" stroke="black" stroke-width="1.5" fill="none"/></marker></defs><defs><marker id="my-svg-stickBottomArrowHead" refX="7.5" refY="0" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 7 L 7 0" stroke="black" stroke-width="1.5" fill="none"/></marker></defs><g data-et="control-structure" data-id="i16"><line x1="646" y1="561" x2="1307" y2="561" class="loopLine"/><line x1="1307" y1="561" x2="1307" y2="930" class="loopLine"/><line x1="646" y1="930" x2="1307" y2="930" class="loopLine"/><line x1="646" y1="561" x2="646" y2="930" class="loopLine"/><polygon points="646,561 696,561 696,574 687.6,581 646,581" class="labelBox"/><text x="671" y="574" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="labelText" style="font-size: 16px; font-weight: 400;">loop</text><text x="1001.5" y="579" text-anchor="middle" class="loopText" style="font-size: 16px; font-weight: 400;"><tspan x="1001.5">[For each pending answer]</tspan></text></g><g class="actor-man actor-top" name="Instructor" data-et="participant" data-type="actor" data-id="Instructor" style="stroke: rgb(147, 112, 219);"><line id="actor-man-torso0" x1="75" y1="25" x2="75" y2="45"/><line id="actor-man-arms0" x1="57" y1="33" x2="93" y2="33"/><line x1="57" y1="60" x2="75" y2="45"/><line x1="75" y1="45" x2="91" y2="60"/><circle cx="75" cy="10" r="15" width="150" height="65"/><text x="75" y="67.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-man" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="75" dy="0">Instructor</tspan></text></g><text x="254" y="80" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Select CSV/XLSX, prompt strategy, provider</text><line x1="76" y1="109" x2="432" y2="109" class="messageLine0" data-et="message" data-id="i0" data-from="Instructor" data-to="UI" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="584" y="124" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Upload file</text><line x1="437" y1="153" x2="730" y2="153" class="messageLine0" data-et="message" data-id="i1" data-from="UI" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="735" y="168" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Store uploaded file</text><path d="M 735,197 C 795,187 795,227 735,217" class="messageLine0" data-et="message" data-id="i2" data-from="API" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="735" y="242" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Parse and normalize rows</text><path d="M 735,271 C 795,261 795,301 735,291" class="messageLine0" data-et="message" data-id="i3" data-from="API" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="904" y="316" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Create run, students, questions, answers</text><line x1="735" y1="345" x2="1072" y2="345" class="messageLine0" data-et="message" data-id="i4" data-from="API" data-to="DB" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="587" y="360" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">upload_id / run_id</text><line x1="733" y1="389" x2="440" y2="389" class="messageLine1" data-et="message" data-id="i5" data-from="API" data-to="UI" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"/><text x="584" y="404" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Start grading run</text><line x1="437" y1="433" x2="730" y2="433" class="messageLine0" data-et="message" data-id="i6" data-from="UI" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="735" y="448" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Start background grading thread</text><path d="M 735,477 C 795,467 795,507 735,497" class="messageLine0" data-et="message" data-id="i7" data-from="API" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="584" y="522" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Poll grading status every 2s</text><line x1="437" y1="551" x2="730" y2="551" class="messageLine0" data-et="message" data-id="i8" data-from="UI" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="904" y="611" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Load answer and question</text><line x1="735" y1="640" x2="1072" y2="640" class="messageLine0" data-et="message" data-id="i10" data-from="API" data-to="DB" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="735" y="655" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Build P0 or P1 prompt</text><path d="M 735,684 C 795,674 795,714 735,704" class="messageLine0" data-et="message" data-id="i11" data-from="API" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="1014" y="729" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Chat-completions request</text><line x1="735" y1="758" x2="1292" y2="758" class="messageLine0" data-et="message" data-id="i12" data-from="API" data-to="LLM" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="1017" y="773" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Numeric score text</text><line x1="1295" y1="802" x2="738" y2="802" class="messageLine1" data-et="message" data-id="i13" data-from="LLM" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"/><text x="735" y="817" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Parse, clamp, snap score</text><path d="M 735,846 C 795,836 795,876 735,866" class="messageLine0" data-et="message" data-id="i14" data-from="API" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="904" y="891" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Save llm_score and mark review_required</text><line x1="735" y1="920" x2="1072" y2="920" class="messageLine0" data-et="message" data-id="i15" data-from="API" data-to="DB" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="584" y="945" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Load review overview and answers</text><line x1="437" y1="974" x2="730" y2="974" class="messageLine0" data-et="message" data-id="i17" data-from="UI" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="254" y="989" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Accept LLM score or override score</text><line x1="76" y1="1018" x2="432" y2="1018" class="messageLine0" data-et="message" data-id="i18" data-from="Instructor" data-to="UI" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="584" y="1033" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Submit answer review</text><line x1="437" y1="1062" x2="730" y2="1062" class="messageLine0" data-et="message" data-id="i19" data-from="UI" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="904" y="1077" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Save final_score and review status</text><line x1="735" y1="1106" x2="1072" y2="1106" class="messageLine0" data-et="message" data-id="i20" data-from="API" data-to="DB" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="254" y="1121" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Export or delete run</text><line x1="76" y1="1150" x2="432" y2="1150" class="messageLine0" data-et="message" data-id="i21" data-from="Instructor" data-to="UI" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="584" y="1165" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Export Moodle CSV</text><line x1="437" y1="1194" x2="730" y2="1194" class="messageLine0" data-et="message" data-id="i22" data-from="UI" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><text x="584" y="1209" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">Delete grading run</text><line x1="437" y1="1238" x2="730" y2="1238" class="messageLine0" data-et="message" data-id="i23" data-from="UI" data-to="API" stroke-width="2" stroke="none" marker-end="url(#my-svg-arrowhead)" style="fill: none;"/><g class="actor-man actor-bottom" name="Instructor" style="stroke: rgb(147, 112, 219);"><line id="actor-man-torso4" x1="75" y1="1283" x2="75" y2="1303"/><line id="actor-man-arms4" x1="57" y1="1291" x2="93" y2="1291"/><line x1="57" y1="1318" x2="75" y2="1303"/><line x1="75" y1="1303" x2="91" y2="1318"/><circle cx="75" cy="1268" r="15" width="150" height="65"/><text x="75" y="1325.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-man" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="75" dy="0">Instructor</tspan></text></g></svg>
\ No newline at end of file
flowchart LR
UIChoice["UI Provider Choice"]
LocalGemma["Local Gemma\nllm_profile local_gemma"]
ChatAIChoice["Academic Cloud Chat AI\nllm_profile chat_ai"]
OpenAICustom["Use my OpenAI key\nllm_profile openai_custom"]
BackendProfile["Backend profile mapping\nservices/llm/profiles.py"]
OpenAICompat["OpenAI-compatible provider\nchat completions API"]
DockerSidecar["Default or CUDA Docker\nlocal-llm service"]
HostOverride["Host LLM override\nhost.docker.internal"]
GWDG["GWDG Chat AI\nChat AI base URL and API key"]
PaidOpenAI["OpenAI\nruntime API key only"]
UIChoice --> LocalGemma
UIChoice --> ChatAIChoice
UIChoice --> OpenAICustom
LocalGemma --> BackendProfile
ChatAIChoice --> BackendProfile
OpenAICustom --> BackendProfile
BackendProfile --> OpenAICompat
OpenAICompat --> DockerSidecar
OpenAICompat --> HostOverride
OpenAICompat --> GWDG
OpenAICompat --> PaidOpenAI
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment