labelapp v1
This commit is contained in:
parent
3260a9c5d9
commit
3505c45cdc
1
bun.lock
1
bun.lock
@ -26,6 +26,7 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bun": "^1.3.11",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
1202
labelapp/.sampled-ids.json
Normal file
1202
labelapp/.sampled-ids.json
Normal file
File diff suppressed because it is too large
Load Diff
750
labelapp/app/admin/page.tsx
Normal file
750
labelapp/app/admin/page.tsx
Normal file
@ -0,0 +1,750 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
const CATEGORIES = [
|
||||
"Board Governance",
|
||||
"Management Role",
|
||||
"Risk Management Process",
|
||||
"Third-Party Risk",
|
||||
"Incident Disclosure",
|
||||
"Strategy Integration",
|
||||
"None/Other",
|
||||
] as const;
|
||||
|
||||
const SPECIFICITIES = [
|
||||
{ value: 1, label: "1 - Generic/Boilerplate" },
|
||||
{ value: 2, label: "2 - Somewhat Specific" },
|
||||
{ value: 3, label: "3 - Specific" },
|
||||
{ value: 4, label: "4 - Highly Specific" },
|
||||
] as const;
|
||||
|
||||
interface QueueLabel {
|
||||
annotatorId: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
specificity: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
paragraphId: string;
|
||||
paragraphText: string;
|
||||
labels: QueueLabel[];
|
||||
stage1Category: string | null;
|
||||
stage1Specificity: number | null;
|
||||
splitSeverity: number;
|
||||
}
|
||||
|
||||
interface AnnotatorProgress {
|
||||
id: string;
|
||||
displayName: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface MetricsData {
|
||||
progress: {
|
||||
totalParagraphs: number;
|
||||
fullyLabeled: number;
|
||||
adjudicated: number;
|
||||
perAnnotator: AnnotatorProgress[];
|
||||
};
|
||||
agreement: {
|
||||
consensusRate: number;
|
||||
avgKappa: number;
|
||||
kappaMatrix: {
|
||||
annotators: string[];
|
||||
values: number[][];
|
||||
};
|
||||
krippendorffsAlpha: number;
|
||||
perCategory: Record<string, number>;
|
||||
};
|
||||
confusionMatrix: {
|
||||
labels: string[];
|
||||
matrix: number[][];
|
||||
};
|
||||
}
|
||||
|
||||
function kappaColor(value: number): string {
|
||||
if (value >= 0.75) return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300";
|
||||
if (value >= 0.5) return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300";
|
||||
return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300";
|
||||
}
|
||||
|
||||
function heatmapColor(value: number, max: number): string {
|
||||
if (max === 0) return "";
|
||||
const intensity = value / max;
|
||||
if (intensity > 0.7) return "bg-blue-200 dark:bg-blue-900/50";
|
||||
if (intensity > 0.4) return "bg-blue-100 dark:bg-blue-900/30";
|
||||
if (intensity > 0.1) return "bg-blue-50 dark:bg-blue-900/15";
|
||||
return "";
|
||||
}
|
||||
|
||||
function AdjudicationQueue() {
|
||||
const [queue, setQueue] = useState<QueueItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<QueueItem | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [resolveCategory, setResolveCategory] = useState("");
|
||||
const [resolveSpecificity, setResolveSpecificity] = useState("");
|
||||
const [resolveNotes, setResolveNotes] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [expandedParagraphs, setExpandedParagraphs] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const fetchQueue = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/adjudicate");
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error ?? "Failed to load queue");
|
||||
}
|
||||
const data = await res.json();
|
||||
setQueue(data.queue);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueue();
|
||||
}, [fetchQueue]);
|
||||
|
||||
function openResolveDialog(item: QueueItem) {
|
||||
setSelectedItem(item);
|
||||
setResolveCategory("");
|
||||
setResolveSpecificity("");
|
||||
setResolveNotes("");
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleSubmitResolution() {
|
||||
if (!selectedItem || !resolveCategory || !resolveSpecificity) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/adjudicate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
paragraphId: selectedItem.paragraphId,
|
||||
finalCategory: resolveCategory,
|
||||
finalSpecificity: Number(resolveSpecificity),
|
||||
notes: resolveNotes || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error ?? "Failed to resolve");
|
||||
}
|
||||
|
||||
setQueue((prev) =>
|
||||
prev.filter((item) => item.paragraphId !== selectedItem.paragraphId),
|
||||
);
|
||||
setDialogOpen(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Unknown error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand(paragraphId: string) {
|
||||
setExpandedParagraphs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(paragraphId)) {
|
||||
next.delete(paragraphId);
|
||||
} else {
|
||||
next.add(paragraphId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Loading adjudication queue...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-red-600">{error}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Adjudication Queue</CardTitle>
|
||||
<CardDescription>
|
||||
{queue.length === 0
|
||||
? "All disagreements have been resolved."
|
||||
: `${queue.length} paragraph${queue.length === 1 ? "" : "s"} need${queue.length === 1 ? "s" : ""} adjudication`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{queue.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No paragraphs pending manual review. Consensus and majority
|
||||
agreements are auto-resolved.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{queue.map((item) => {
|
||||
const isExpanded = expandedParagraphs.has(item.paragraphId);
|
||||
const truncatedText =
|
||||
item.paragraphText.length > 200 && !isExpanded
|
||||
? item.paragraphText.slice(0, 200) + "..."
|
||||
: item.paragraphText;
|
||||
|
||||
return (
|
||||
<Card key={item.paragraphId} className="border">
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
{/* Severity badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant={
|
||||
item.splitSeverity >= 3
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{item.splitSeverity >= 3
|
||||
? "3-way split"
|
||||
: "2-way split"}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{item.paragraphId}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Paragraph text */}
|
||||
<div>
|
||||
<p className="text-sm leading-relaxed">
|
||||
{truncatedText}
|
||||
</p>
|
||||
{item.paragraphText.length > 200 && (
|
||||
<button
|
||||
onClick={() => toggleExpand(item.paragraphId)}
|
||||
className="text-xs text-primary hover:underline mt-1"
|
||||
>
|
||||
{isExpanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stage 1 reference */}
|
||||
{item.stage1Category && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stage 1: {item.stage1Category}
|
||||
{item.stage1Specificity !== null &&
|
||||
` (specificity ${item.stage1Specificity})`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Annotator labels side by side */}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Annotator</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Specificity</TableHead>
|
||||
<TableHead>Notes</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{item.labels.map((label) => (
|
||||
<TableRow key={label.annotatorId}>
|
||||
<TableCell className="font-medium">
|
||||
{label.displayName}
|
||||
</TableCell>
|
||||
<TableCell>{label.category}</TableCell>
|
||||
<TableCell>{label.specificity}</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-[200px] truncate">
|
||||
{label.notes ?? "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => openResolveDialog(item)}>
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Resolution Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Resolve Adjudication</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select the final category and specificity for this paragraph.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Category selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Content Category</label>
|
||||
<RadioGroup
|
||||
value={resolveCategory}
|
||||
onValueChange={setResolveCategory}
|
||||
>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<div key={cat} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={cat} />
|
||||
<label className="text-sm cursor-pointer">{cat}</label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Specificity selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Specificity Level</label>
|
||||
<RadioGroup
|
||||
value={resolveSpecificity}
|
||||
onValueChange={setResolveSpecificity}
|
||||
>
|
||||
{SPECIFICITIES.map((spec) => (
|
||||
<div key={spec.value} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={String(spec.value)} />
|
||||
<label className="text-sm cursor-pointer">
|
||||
{spec.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Notes{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={resolveNotes}
|
||||
onChange={(e) => setResolveNotes(e.target.value)}
|
||||
placeholder="Reasoning for this decision..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleSubmitResolution}
|
||||
disabled={
|
||||
!resolveCategory || !resolveSpecificity || submitting
|
||||
}
|
||||
>
|
||||
{submitting ? "Submitting..." : "Submit Resolution"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsDashboard() {
|
||||
const [metrics, setMetrics] = useState<MetricsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchMetrics() {
|
||||
try {
|
||||
const res = await fetch("/api/metrics");
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error ?? "Failed to load metrics");
|
||||
}
|
||||
const data = await res.json();
|
||||
setMetrics(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchMetrics();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Computing metrics...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !metrics) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-red-600">
|
||||
{error ?? "Failed to load metrics"}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { progress, agreement, confusionMatrix: cm } = metrics;
|
||||
|
||||
const labeledPct =
|
||||
progress.totalParagraphs > 0
|
||||
? Math.round((progress.fullyLabeled / progress.totalParagraphs) * 100)
|
||||
: 0;
|
||||
|
||||
const confMax = Math.max(...cm.matrix.flat(), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Progress</CardTitle>
|
||||
<CardDescription>
|
||||
{progress.fullyLabeled} / {progress.totalParagraphs} fully labeled,{" "}
|
||||
{progress.adjudicated} adjudicated
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Progress value={labeledPct}>
|
||||
<ProgressLabel>Fully Labeled</ProgressLabel>
|
||||
<ProgressValue />
|
||||
</Progress>
|
||||
|
||||
<Separator />
|
||||
|
||||
<h4 className="text-sm font-medium">Per-Annotator Progress</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Annotator</TableHead>
|
||||
<TableHead className="text-right">Completed</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
<TableHead className="text-right">%</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{progress.perAnnotator.map((a) => {
|
||||
const pct =
|
||||
a.total > 0 ? Math.round((a.completed / a.total) * 100) : 0;
|
||||
return (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-medium">
|
||||
{a.displayName}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{a.completed}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{a.total}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{pct}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Agreement Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Inter-Rater Agreement</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card className="border">
|
||||
<CardContent className="pt-4 pb-3 text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{(agreement.consensusRate * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Consensus Rate (all 3 agree)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border">
|
||||
<CardContent className="pt-4 pb-3 text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{agreement.avgKappa.toFixed(3)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Avg Cohen's Kappa
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border">
|
||||
<CardContent className="pt-4 pb-3 text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{agreement.krippendorffsAlpha.toFixed(3)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Krippendorff's Alpha (specificity)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Kappa Matrix */}
|
||||
{agreement.kappaMatrix.annotators.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">
|
||||
Pairwise Cohen's Kappa
|
||||
</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead />
|
||||
{agreement.kappaMatrix.annotators.map((name) => (
|
||||
<TableHead
|
||||
key={name}
|
||||
className="text-center text-xs px-1"
|
||||
>
|
||||
{name}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{agreement.kappaMatrix.annotators.map((name, i) => (
|
||||
<TableRow key={name}>
|
||||
<TableCell className="font-medium text-xs">
|
||||
{name}
|
||||
</TableCell>
|
||||
{agreement.kappaMatrix.values[i].map((val, j) => (
|
||||
<TableCell
|
||||
key={j}
|
||||
className={`text-center text-xs tabular-nums px-1 ${
|
||||
i === j ? "bg-muted" : kappaColor(val)
|
||||
}`}
|
||||
>
|
||||
{i === j ? "-" : val.toFixed(2)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded bg-emerald-100 dark:bg-emerald-900/30" />
|
||||
Good (≥0.75)
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded bg-amber-100 dark:bg-amber-900/30" />
|
||||
Moderate (0.5-0.75)
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-3 rounded bg-red-100 dark:bg-red-900/30" />
|
||||
Poor (<0.5)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Per-Category Agreement */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">
|
||||
Per-Category Agreement Rates
|
||||
</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead className="text-right">Agreement</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{CATEGORIES.map((cat) => {
|
||||
const rate = agreement.perCategory[cat] ?? 0;
|
||||
return (
|
||||
<TableRow key={cat}>
|
||||
<TableCell>{cat}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<Badge
|
||||
variant={
|
||||
rate >= 0.75
|
||||
? "default"
|
||||
: rate >= 0.5
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{(rate * 100).toFixed(1)}%
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Confusion Matrix */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Confusion Matrix</CardTitle>
|
||||
<CardDescription>
|
||||
Aggregated across all annotator pairs (row = annotator A, col =
|
||||
annotator B)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs" />
|
||||
{cm.labels.map((label) => (
|
||||
<TableHead
|
||||
key={label}
|
||||
className="text-center text-xs px-1 max-w-[80px]"
|
||||
title={label}
|
||||
>
|
||||
{label.length > 12
|
||||
? label.slice(0, 10) + ".."
|
||||
: label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cm.labels.map((rowLabel, i) => (
|
||||
<TableRow key={rowLabel}>
|
||||
<TableCell
|
||||
className="font-medium text-xs max-w-[100px] truncate"
|
||||
title={rowLabel}
|
||||
>
|
||||
{rowLabel.length > 14
|
||||
? rowLabel.slice(0, 12) + ".."
|
||||
: rowLabel}
|
||||
</TableCell>
|
||||
{cm.matrix[i].map((val, j) => (
|
||||
<TableCell
|
||||
key={j}
|
||||
className={`text-center text-xs tabular-nums px-1 ${
|
||||
i === j
|
||||
? "font-bold"
|
||||
: ""
|
||||
} ${heatmapColor(val, confMax)}`}
|
||||
>
|
||||
{val}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-4 sm:p-8">
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
SEC cyBERT Labeling Project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="adjudication">
|
||||
<TabsList>
|
||||
<TabsTrigger value="adjudication">Adjudication Queue</TabsTrigger>
|
||||
<TabsTrigger value="metrics">Metrics Dashboard</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="adjudication">
|
||||
<AdjudicationQueue />
|
||||
</TabsContent>
|
||||
<TabsContent value="metrics">
|
||||
<MetricsDashboard />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
351
labelapp/app/api/adjudicate/__test__/adjudicate.test.ts
Normal file
351
labelapp/app/api/adjudicate/__test__/adjudicate.test.ts
Normal file
@ -0,0 +1,351 @@
|
||||
import { describe, test, expect, beforeAll, afterAll, mock } from "bun:test";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
annotators,
|
||||
paragraphs,
|
||||
assignments,
|
||||
humanLabels,
|
||||
adjudications,
|
||||
} from "@/db/schema";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
|
||||
const cookieJar = new Map<string, string>();
|
||||
const mockCookieStore = {
|
||||
get(name: string) {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { value } : undefined;
|
||||
},
|
||||
set(name: string, value: string, _opts?: unknown) {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete(name: string) {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
};
|
||||
|
||||
mock.module("next/headers", () => ({
|
||||
cookies: async () => mockCookieStore,
|
||||
}));
|
||||
|
||||
const { createSession } = await import("@/lib/auth");
|
||||
const { GET, POST } = await import("../route");
|
||||
|
||||
const ADMIN_USER = {
|
||||
id: "admin",
|
||||
displayName: "Admin",
|
||||
password: "adminpass",
|
||||
};
|
||||
|
||||
const TEST_ANNOTATORS = [
|
||||
{ id: "adj-ann-1", displayName: "Annotator 1", password: "pass1" },
|
||||
{ id: "adj-ann-2", displayName: "Annotator 2", password: "pass2" },
|
||||
{ id: "adj-ann-3", displayName: "Annotator 3", password: "pass3" },
|
||||
];
|
||||
|
||||
const TEST_PARAGRAPHS = [
|
||||
{
|
||||
id: "adj-para-agree",
|
||||
text: "The board of directors oversees the company cybersecurity risk management.",
|
||||
wordCount: 11,
|
||||
paragraphIndex: 0,
|
||||
companyName: "Test Corp",
|
||||
cik: "0009999999",
|
||||
ticker: "ADJT",
|
||||
filingType: "10-K",
|
||||
filingDate: "2024-03-15",
|
||||
fiscalYear: 2024,
|
||||
accessionNumber: "0009999999-24-000001",
|
||||
secItem: "Item 1C",
|
||||
},
|
||||
{
|
||||
id: "adj-para-disagree",
|
||||
text: "We employ third-party vendors and manage internal risk processes simultaneously.",
|
||||
wordCount: 10,
|
||||
paragraphIndex: 1,
|
||||
companyName: "Test Corp",
|
||||
cik: "0009999999",
|
||||
ticker: "ADJT",
|
||||
filingType: "10-K",
|
||||
filingDate: "2024-03-15",
|
||||
fiscalYear: 2024,
|
||||
accessionNumber: "0009999999-24-000001",
|
||||
secItem: "Item 1C",
|
||||
},
|
||||
{
|
||||
id: "adj-para-majority",
|
||||
text: "Our CISO reports quarterly to the board on cybersecurity matters.",
|
||||
wordCount: 10,
|
||||
paragraphIndex: 2,
|
||||
companyName: "Test Corp",
|
||||
cik: "0009999999",
|
||||
ticker: "ADJT",
|
||||
filingType: "10-K",
|
||||
filingDate: "2024-03-15",
|
||||
fiscalYear: 2024,
|
||||
accessionNumber: "0009999999-24-000001",
|
||||
secItem: "Item 1C",
|
||||
},
|
||||
];
|
||||
|
||||
const PARAGRAPH_IDS = TEST_PARAGRAPHS.map((p) => p.id);
|
||||
const ALL_ANNOTATOR_IDS = [
|
||||
ADMIN_USER.id,
|
||||
...TEST_ANNOTATORS.map((a) => a.id),
|
||||
];
|
||||
|
||||
async function cleanup() {
|
||||
// Clean in dependency order
|
||||
await db
|
||||
.delete(adjudications)
|
||||
.where(inArray(adjudications.paragraphId, PARAGRAPH_IDS));
|
||||
await db
|
||||
.delete(humanLabels)
|
||||
.where(inArray(humanLabels.paragraphId, PARAGRAPH_IDS));
|
||||
await db
|
||||
.delete(assignments)
|
||||
.where(inArray(assignments.paragraphId, PARAGRAPH_IDS));
|
||||
await db.delete(paragraphs).where(inArray(paragraphs.id, PARAGRAPH_IDS));
|
||||
for (const a of TEST_ANNOTATORS) {
|
||||
await db.delete(annotators).where(eq(annotators.id, a.id));
|
||||
}
|
||||
// Only delete admin if it exists (don't fail if not present)
|
||||
await db.delete(annotators).where(eq(annotators.id, ADMIN_USER.id));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
// Create test data
|
||||
await db
|
||||
.insert(annotators)
|
||||
.values([ADMIN_USER, ...TEST_ANNOTATORS])
|
||||
.onConflictDoNothing();
|
||||
await db.insert(paragraphs).values(TEST_PARAGRAPHS);
|
||||
|
||||
// Assign all paragraphs to all 3 annotators
|
||||
const assignmentRows = TEST_PARAGRAPHS.flatMap((p) =>
|
||||
TEST_ANNOTATORS.map((a) => ({
|
||||
paragraphId: p.id,
|
||||
annotatorId: a.id,
|
||||
})),
|
||||
);
|
||||
await db.insert(assignments).values(assignmentRows);
|
||||
|
||||
// Set up admin session
|
||||
cookieJar.clear();
|
||||
await createSession(ADMIN_USER.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
describe("GET /api/adjudicate", () => {
|
||||
test("returns empty queue when no labels exist", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.queue).toEqual([]);
|
||||
});
|
||||
|
||||
test("auto-resolves consensus (3/3 agree) and returns empty queue", async () => {
|
||||
// Insert 3 agreeing labels for adj-para-agree
|
||||
const labels = TEST_ANNOTATORS.map((a) => ({
|
||||
paragraphId: "adj-para-agree",
|
||||
annotatorId: a.id,
|
||||
contentCategory: "Board Governance",
|
||||
specificityLevel: 3,
|
||||
sessionId: "test-adj-session",
|
||||
}));
|
||||
await db.insert(humanLabels).values(labels);
|
||||
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.queue).toEqual([]);
|
||||
|
||||
// Verify adjudication was created
|
||||
const [adj] = await db
|
||||
.select()
|
||||
.from(adjudications)
|
||||
.where(eq(adjudications.paragraphId, "adj-para-agree"));
|
||||
|
||||
expect(adj).toBeDefined();
|
||||
expect(adj.method).toBe("consensus");
|
||||
expect(adj.finalCategory).toBe("Board Governance");
|
||||
expect(adj.finalSpecificity).toBe(3);
|
||||
});
|
||||
|
||||
test("auto-resolves majority (2/3 agree) and returns empty queue", async () => {
|
||||
// Insert 2 agreeing + 1 disagreeing for adj-para-majority
|
||||
await db.insert(humanLabels).values([
|
||||
{
|
||||
paragraphId: "adj-para-majority",
|
||||
annotatorId: TEST_ANNOTATORS[0].id,
|
||||
contentCategory: "Management Role",
|
||||
specificityLevel: 2,
|
||||
sessionId: "test-adj-session",
|
||||
},
|
||||
{
|
||||
paragraphId: "adj-para-majority",
|
||||
annotatorId: TEST_ANNOTATORS[1].id,
|
||||
contentCategory: "Management Role",
|
||||
specificityLevel: 2,
|
||||
sessionId: "test-adj-session",
|
||||
},
|
||||
{
|
||||
paragraphId: "adj-para-majority",
|
||||
annotatorId: TEST_ANNOTATORS[2].id,
|
||||
contentCategory: "Board Governance",
|
||||
specificityLevel: 3,
|
||||
sessionId: "test-adj-session",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.queue).toEqual([]);
|
||||
|
||||
// Verify adjudication was created
|
||||
const [adj] = await db
|
||||
.select()
|
||||
.from(adjudications)
|
||||
.where(eq(adjudications.paragraphId, "adj-para-majority"));
|
||||
|
||||
expect(adj).toBeDefined();
|
||||
expect(adj.method).toBe("majority");
|
||||
expect(adj.finalCategory).toBe("Management Role");
|
||||
expect(adj.finalSpecificity).toBe(2);
|
||||
});
|
||||
|
||||
test("returns 3-way disagreement in queue", async () => {
|
||||
// Insert 3 fully disagreeing labels for adj-para-disagree
|
||||
await db.insert(humanLabels).values([
|
||||
{
|
||||
paragraphId: "adj-para-disagree",
|
||||
annotatorId: TEST_ANNOTATORS[0].id,
|
||||
contentCategory: "Third-Party Risk",
|
||||
specificityLevel: 1,
|
||||
sessionId: "test-adj-session",
|
||||
},
|
||||
{
|
||||
paragraphId: "adj-para-disagree",
|
||||
annotatorId: TEST_ANNOTATORS[1].id,
|
||||
contentCategory: "Risk Management Process",
|
||||
specificityLevel: 2,
|
||||
sessionId: "test-adj-session",
|
||||
},
|
||||
{
|
||||
paragraphId: "adj-para-disagree",
|
||||
annotatorId: TEST_ANNOTATORS[2].id,
|
||||
contentCategory: "Management Role",
|
||||
specificityLevel: 3,
|
||||
sessionId: "test-adj-session",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.queue).toHaveLength(1);
|
||||
expect(data.queue[0].paragraphId).toBe("adj-para-disagree");
|
||||
expect(data.queue[0].labels).toHaveLength(3);
|
||||
expect(data.queue[0].splitSeverity).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/adjudicate", () => {
|
||||
test("resolves an adjudication", async () => {
|
||||
const req = new Request("http://localhost:3000/api/adjudicate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
paragraphId: "adj-para-disagree",
|
||||
finalCategory: "Third-Party Risk",
|
||||
finalSpecificity: 2,
|
||||
notes: "Resolved via discussion",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.ok).toBe(true);
|
||||
|
||||
// Verify adjudication row
|
||||
const [adj] = await db
|
||||
.select()
|
||||
.from(adjudications)
|
||||
.where(eq(adjudications.paragraphId, "adj-para-disagree"));
|
||||
|
||||
expect(adj).toBeDefined();
|
||||
expect(adj.method).toBe("discussion");
|
||||
expect(adj.finalCategory).toBe("Third-Party Risk");
|
||||
expect(adj.finalSpecificity).toBe(2);
|
||||
expect(adj.adjudicatorId).toBe("admin");
|
||||
expect(adj.notes).toBe("Resolved via discussion");
|
||||
});
|
||||
|
||||
test("GET returns empty queue after resolution", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.queue).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects invalid category", async () => {
|
||||
const req = new Request("http://localhost:3000/api/adjudicate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
paragraphId: "adj-para-disagree",
|
||||
finalCategory: "Invalid",
|
||||
finalSpecificity: 2,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test("rejects invalid specificity", async () => {
|
||||
const req = new Request("http://localhost:3000/api/adjudicate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
paragraphId: "adj-para-disagree",
|
||||
finalCategory: "Board Governance",
|
||||
finalSpecificity: 5,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/adjudicate - unauthorized", () => {
|
||||
test("returns 401 without session", async () => {
|
||||
cookieJar.clear();
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("returns 403 for non-admin user", async () => {
|
||||
cookieJar.clear();
|
||||
await createSession(TEST_ANNOTATORS[0].id);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// Restore admin session for other tests
|
||||
cookieJar.clear();
|
||||
await createSession(ADMIN_USER.id);
|
||||
});
|
||||
});
|
||||
269
labelapp/app/api/adjudicate/route.ts
Normal file
269
labelapp/app/api/adjudicate/route.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
adjudications,
|
||||
annotators,
|
||||
humanLabels,
|
||||
paragraphs,
|
||||
} from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/auth";
|
||||
|
||||
const VALID_CATEGORIES = [
|
||||
"Board Governance",
|
||||
"Management Role",
|
||||
"Risk Management Process",
|
||||
"Third-Party Risk",
|
||||
"Incident Disclosure",
|
||||
"Strategy Integration",
|
||||
"None/Other",
|
||||
] as const;
|
||||
|
||||
const VALID_SPECIFICITY = [1, 2, 3, 4] as const;
|
||||
|
||||
interface LabelRecord {
|
||||
annotatorId: string;
|
||||
contentCategory: string;
|
||||
specificityLevel: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface QueueItem {
|
||||
paragraphId: string;
|
||||
paragraphText: string;
|
||||
labels: {
|
||||
annotatorId: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
specificity: number;
|
||||
notes: string | null;
|
||||
}[];
|
||||
stage1Category: string | null;
|
||||
stage1Specificity: number | null;
|
||||
splitSeverity: number; // 3 = three-way split, 2 = two-way split
|
||||
}
|
||||
|
||||
function findMajority(values: string[]): string | null {
|
||||
const counts = new Map<string, number>();
|
||||
for (const v of values) {
|
||||
counts.set(v, (counts.get(v) ?? 0) + 1);
|
||||
}
|
||||
for (const [val, count] of counts) {
|
||||
if (count >= 2) return val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMajorityNum(values: number[]): number | null {
|
||||
const counts = new Map<number, number>();
|
||||
for (const v of values) {
|
||||
counts.set(v, (counts.get(v) ?? 0) + 1);
|
||||
}
|
||||
for (const [val, count] of counts) {
|
||||
if (count >= 2) return val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function countDistinct(values: string[]): number {
|
||||
return new Set(values).size;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.annotatorId !== "admin") {
|
||||
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Load all human labels
|
||||
const allLabels = await db.select().from(humanLabels);
|
||||
|
||||
// Load all existing adjudications
|
||||
const allAdj = await db
|
||||
.select({ paragraphId: adjudications.paragraphId })
|
||||
.from(adjudications);
|
||||
const adjudicatedSet = new Set(allAdj.map((a) => a.paragraphId));
|
||||
|
||||
// Load all paragraphs (for text and stage1 data)
|
||||
const allParagraphs = await db.select().from(paragraphs);
|
||||
const paragraphMap = new Map(allParagraphs.map((p) => [p.id, p]));
|
||||
|
||||
// Load all annotators (for display names)
|
||||
const allAnnotators = await db.select().from(annotators);
|
||||
const annotatorMap = new Map(allAnnotators.map((a) => [a.id, a.displayName]));
|
||||
|
||||
// Group labels by paragraph
|
||||
const byParagraph = new Map<string, LabelRecord[]>();
|
||||
for (const label of allLabels) {
|
||||
if (!byParagraph.has(label.paragraphId)) {
|
||||
byParagraph.set(label.paragraphId, []);
|
||||
}
|
||||
byParagraph.get(label.paragraphId)!.push({
|
||||
annotatorId: label.annotatorId,
|
||||
contentCategory: label.contentCategory,
|
||||
specificityLevel: label.specificityLevel,
|
||||
notes: label.notes,
|
||||
});
|
||||
}
|
||||
|
||||
const queue: QueueItem[] = [];
|
||||
|
||||
for (const [paragraphId, labels] of byParagraph) {
|
||||
// Only process paragraphs with 3+ labels that aren't already adjudicated
|
||||
if (labels.length < 3 || adjudicatedSet.has(paragraphId)) continue;
|
||||
|
||||
const categories = labels.map((l) => l.contentCategory);
|
||||
const specificities = labels.map((l) => l.specificityLevel);
|
||||
|
||||
const allCategoriesAgree = categories.every((c) => c === categories[0]);
|
||||
const allSpecificitiesAgree = specificities.every(
|
||||
(s) => s === specificities[0],
|
||||
);
|
||||
|
||||
if (allCategoriesAgree && allSpecificitiesAgree) {
|
||||
// Full consensus - auto-resolve
|
||||
await db.insert(adjudications).values({
|
||||
paragraphId,
|
||||
finalCategory: categories[0],
|
||||
finalSpecificity: specificities[0],
|
||||
method: "consensus",
|
||||
adjudicatorId: null,
|
||||
notes: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const majorityCategory = findMajority(categories);
|
||||
const majoritySpecificity = findMajorityNum(specificities);
|
||||
|
||||
if (majorityCategory !== null && majoritySpecificity !== null) {
|
||||
// 2/3 majority on both dims - auto-resolve
|
||||
await db.insert(adjudications).values({
|
||||
paragraphId,
|
||||
finalCategory: majorityCategory,
|
||||
finalSpecificity: majoritySpecificity,
|
||||
method: "majority",
|
||||
adjudicatorId: null,
|
||||
notes: null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Needs manual adjudication
|
||||
const para = paragraphMap.get(paragraphId);
|
||||
if (!para) continue;
|
||||
|
||||
// Severity: 3-way category split is worse than 2-way
|
||||
const categorySplitCount = countDistinct(categories);
|
||||
|
||||
queue.push({
|
||||
paragraphId,
|
||||
paragraphText: para.text,
|
||||
labels: labels.map((l) => ({
|
||||
annotatorId: l.annotatorId,
|
||||
displayName: annotatorMap.get(l.annotatorId) ?? l.annotatorId,
|
||||
category: l.contentCategory,
|
||||
specificity: l.specificityLevel,
|
||||
notes: l.notes,
|
||||
})),
|
||||
stage1Category: para.stage1Category,
|
||||
stage1Specificity: para.stage1Specificity,
|
||||
splitSeverity: categorySplitCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: 3-way splits first, then 2-way
|
||||
queue.sort((a, b) => b.splitSeverity - a.splitSeverity);
|
||||
|
||||
return NextResponse.json({ queue });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.annotatorId !== "admin") {
|
||||
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { paragraphId, finalCategory, finalSpecificity, notes } = body as {
|
||||
paragraphId?: string;
|
||||
finalCategory?: string;
|
||||
finalSpecificity?: number;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
if (!paragraphId || !finalCategory || finalSpecificity === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!VALID_CATEGORIES.includes(
|
||||
finalCategory as (typeof VALID_CATEGORIES)[number],
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid content category" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!VALID_SPECIFICITY.includes(
|
||||
finalSpecificity as (typeof VALID_SPECIFICITY)[number],
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid specificity level" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check paragraph exists
|
||||
const [para] = await db
|
||||
.select({ id: paragraphs.id })
|
||||
.from(paragraphs)
|
||||
.where(eq(paragraphs.id, paragraphId))
|
||||
.limit(1);
|
||||
|
||||
if (!para) {
|
||||
return NextResponse.json(
|
||||
{ error: "Paragraph not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(adjudications)
|
||||
.values({
|
||||
paragraphId,
|
||||
finalCategory,
|
||||
finalSpecificity,
|
||||
method: "discussion",
|
||||
adjudicatorId: session.annotatorId,
|
||||
notes: notes ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: adjudications.paragraphId,
|
||||
set: {
|
||||
finalCategory,
|
||||
finalSpecificity,
|
||||
method: "discussion",
|
||||
adjudicatorId: session.annotatorId,
|
||||
notes: notes ?? null,
|
||||
resolvedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
143
labelapp/app/api/auth/__test__/auth.test.ts
Normal file
143
labelapp/app/api/auth/__test__/auth.test.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { describe, test, expect, beforeAll, afterAll, mock } from "bun:test";
|
||||
import { db } from "@/db";
|
||||
import { annotators } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Mock cookie store that captures set/delete calls
|
||||
const cookieJar = new Map<string, string>();
|
||||
const mockCookieStore = {
|
||||
get(name: string) {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { value } : undefined;
|
||||
},
|
||||
set(name: string, value: string, _opts?: unknown) {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete(name: string) {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
};
|
||||
|
||||
// Mock next/headers before importing route handlers
|
||||
mock.module("next/headers", () => ({
|
||||
cookies: async () => mockCookieStore,
|
||||
}));
|
||||
|
||||
// Import route handlers after mock is set up
|
||||
const { GET, POST, DELETE: DELETE_HANDLER } = await import("../route");
|
||||
|
||||
const TEST_ANNOTATOR = {
|
||||
id: "test-auth-user",
|
||||
displayName: "Test Auth User",
|
||||
password: "testpass",
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await db.delete(annotators).where(eq(annotators.id, TEST_ANNOTATOR.id));
|
||||
await db.insert(annotators).values(TEST_ANNOTATOR);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.delete(annotators).where(eq(annotators.id, TEST_ANNOTATOR.id));
|
||||
});
|
||||
|
||||
describe("GET /api/auth", () => {
|
||||
test("returns annotator list without passwords", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
|
||||
const testAnnotator = data.find(
|
||||
(a: { id: string }) => a.id === TEST_ANNOTATOR.id,
|
||||
);
|
||||
expect(testAnnotator).toBeDefined();
|
||||
expect(testAnnotator.id).toBe(TEST_ANNOTATOR.id);
|
||||
expect(testAnnotator.displayName).toBe(TEST_ANNOTATOR.displayName);
|
||||
expect(testAnnotator.password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth", () => {
|
||||
test("returns 200 and sets session cookie with correct credentials", async () => {
|
||||
cookieJar.clear();
|
||||
|
||||
const req = new Request("http://localhost:3000/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
annotatorId: TEST_ANNOTATOR.id,
|
||||
password: TEST_ANNOTATOR.password,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.annotator.id).toBe(TEST_ANNOTATOR.id);
|
||||
expect(data.annotator.displayName).toBe(TEST_ANNOTATOR.displayName);
|
||||
|
||||
// Verify session cookie was set in the mock store
|
||||
expect(cookieJar.has("session")).toBe(true);
|
||||
const sessionValue = cookieJar.get("session")!;
|
||||
expect(sessionValue).toContain(".");
|
||||
});
|
||||
|
||||
test("returns 401 with wrong password", async () => {
|
||||
const req = new Request("http://localhost:3000/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
annotatorId: TEST_ANNOTATOR.id,
|
||||
password: "wrongpassword",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(data.error).toBe("Invalid credentials");
|
||||
});
|
||||
|
||||
test("returns 401 with nonexistent annotator", async () => {
|
||||
const req = new Request("http://localhost:3000/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
annotatorId: "nonexistent-user",
|
||||
password: "anything",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("returns 401 with missing fields", async () => {
|
||||
const req = new Request("http://localhost:3000/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth", () => {
|
||||
test("clears session cookie", async () => {
|
||||
// Pre-populate a session cookie
|
||||
cookieJar.set("session", "fake-session-value");
|
||||
|
||||
const res = await DELETE_HANDLER();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.ok).toBe(true);
|
||||
expect(cookieJar.has("session")).toBe(false);
|
||||
});
|
||||
});
|
||||
53
labelapp/app/api/auth/route.ts
Normal file
53
labelapp/app/api/auth/route.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { annotators } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createSession, destroySession } from "@/lib/auth";
|
||||
|
||||
export async function GET() {
|
||||
const rows = await db
|
||||
.select({ id: annotators.id, displayName: annotators.displayName })
|
||||
.from(annotators);
|
||||
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const { annotatorId, password } = body as {
|
||||
annotatorId?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
if (!annotatorId || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const [annotator] = await db
|
||||
.select()
|
||||
.from(annotators)
|
||||
.where(eq(annotators.id, annotatorId))
|
||||
.limit(1);
|
||||
|
||||
if (!annotator || annotator.password.toLowerCase() !== password.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
await createSession(annotatorId);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
annotator: { id: annotator.id, displayName: annotator.displayName },
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
await destroySession();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
306
labelapp/app/api/label/__test__/label.test.ts
Normal file
306
labelapp/app/api/label/__test__/label.test.ts
Normal file
@ -0,0 +1,306 @@
|
||||
import { describe, test, expect, beforeAll, afterAll, mock } from "bun:test";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
annotators,
|
||||
paragraphs,
|
||||
assignments,
|
||||
humanLabels,
|
||||
quizSessions,
|
||||
} from "@/db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
const cookieJar = new Map<string, string>();
|
||||
const mockCookieStore = {
|
||||
get(name: string) {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { value } : undefined;
|
||||
},
|
||||
set(name: string, value: string, _opts?: unknown) {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete(name: string) {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
};
|
||||
|
||||
mock.module("next/headers", () => ({
|
||||
cookies: async () => mockCookieStore,
|
||||
}));
|
||||
|
||||
const { createSession } = await import("@/lib/auth");
|
||||
const { GET, POST } = await import("../route");
|
||||
|
||||
const TEST_ANNOTATOR = {
|
||||
id: "test-label-user",
|
||||
displayName: "Test Label User",
|
||||
password: "testpass",
|
||||
};
|
||||
|
||||
const TEST_PARAGRAPHS = [
|
||||
{
|
||||
id: "test-para-1",
|
||||
text: "The board oversees cybersecurity risk.",
|
||||
wordCount: 6,
|
||||
paragraphIndex: 0,
|
||||
companyName: "Test Corp",
|
||||
cik: "0001234567",
|
||||
ticker: "TEST",
|
||||
filingType: "10-K",
|
||||
filingDate: "2024-03-15",
|
||||
fiscalYear: 2024,
|
||||
accessionNumber: "0001234567-24-000001",
|
||||
secItem: "Item 1C",
|
||||
},
|
||||
{
|
||||
id: "test-para-2",
|
||||
text: "Our CISO manages the cybersecurity program.",
|
||||
wordCount: 7,
|
||||
paragraphIndex: 1,
|
||||
companyName: "Test Corp",
|
||||
cik: "0001234567",
|
||||
ticker: "TEST",
|
||||
filingType: "10-K",
|
||||
filingDate: "2024-03-15",
|
||||
fiscalYear: 2024,
|
||||
accessionNumber: "0001234567-24-000001",
|
||||
secItem: "Item 1C",
|
||||
},
|
||||
];
|
||||
|
||||
const QUIZ_SESSION_ID = "test-label-quiz-session";
|
||||
|
||||
beforeAll(async () => {
|
||||
// Clean up in dependency order
|
||||
await db
|
||||
.delete(humanLabels)
|
||||
.where(eq(humanLabels.annotatorId, TEST_ANNOTATOR.id));
|
||||
await db
|
||||
.delete(assignments)
|
||||
.where(eq(assignments.annotatorId, TEST_ANNOTATOR.id));
|
||||
await db
|
||||
.delete(quizSessions)
|
||||
.where(eq(quizSessions.annotatorId, TEST_ANNOTATOR.id));
|
||||
for (const p of TEST_PARAGRAPHS) {
|
||||
await db.delete(paragraphs).where(eq(paragraphs.id, p.id));
|
||||
}
|
||||
await db.delete(annotators).where(eq(annotators.id, TEST_ANNOTATOR.id));
|
||||
|
||||
// Create test data
|
||||
await db.insert(annotators).values(TEST_ANNOTATOR);
|
||||
await db.insert(paragraphs).values(TEST_PARAGRAPHS);
|
||||
await db.insert(assignments).values(
|
||||
TEST_PARAGRAPHS.map((p) => ({
|
||||
paragraphId: p.id,
|
||||
annotatorId: TEST_ANNOTATOR.id,
|
||||
})),
|
||||
);
|
||||
|
||||
// Create a passed quiz session with warmup completed
|
||||
await db.insert(quizSessions).values({
|
||||
id: QUIZ_SESSION_ID,
|
||||
annotatorId: TEST_ANNOTATOR.id,
|
||||
totalQuestions: 8,
|
||||
score: 8,
|
||||
passed: true,
|
||||
completedAt: new Date(),
|
||||
answers: JSON.stringify({ quizAnswers: [], warmupCompleted: 5 }),
|
||||
});
|
||||
|
||||
// Set up session cookie
|
||||
cookieJar.clear();
|
||||
await createSession(TEST_ANNOTATOR.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db
|
||||
.delete(humanLabels)
|
||||
.where(eq(humanLabels.annotatorId, TEST_ANNOTATOR.id));
|
||||
await db
|
||||
.delete(assignments)
|
||||
.where(eq(assignments.annotatorId, TEST_ANNOTATOR.id));
|
||||
await db
|
||||
.delete(quizSessions)
|
||||
.where(eq(quizSessions.annotatorId, TEST_ANNOTATOR.id));
|
||||
for (const p of TEST_PARAGRAPHS) {
|
||||
await db.delete(paragraphs).where(eq(paragraphs.id, p.id));
|
||||
}
|
||||
await db.delete(annotators).where(eq(annotators.id, TEST_ANNOTATOR.id));
|
||||
});
|
||||
|
||||
describe("GET /api/label", () => {
|
||||
test("returns first assigned paragraph", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.paragraph).toBeDefined();
|
||||
expect(data.paragraph.id).toBe("test-para-1");
|
||||
expect(data.paragraph.text).toBe(TEST_PARAGRAPHS[0].text);
|
||||
expect(data.paragraph.companyName).toBe("Test Corp");
|
||||
expect(data.paragraph.filingType).toBe("10-K");
|
||||
expect(data.progress).toBeDefined();
|
||||
expect(data.progress.completed).toBe(0);
|
||||
expect(data.progress.total).toBe(2);
|
||||
});
|
||||
|
||||
test("redirects to warmup if warmup not complete", async () => {
|
||||
// Temporarily modify quiz session to remove warmup completion
|
||||
await db
|
||||
.update(quizSessions)
|
||||
.set({ answers: "[]" })
|
||||
.where(eq(quizSessions.id, QUIZ_SESSION_ID));
|
||||
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
expect(data.redirectToWarmup).toBe(true);
|
||||
|
||||
// Restore warmup completion
|
||||
await db
|
||||
.update(quizSessions)
|
||||
.set({
|
||||
answers: JSON.stringify({ quizAnswers: [], warmupCompleted: 5 }),
|
||||
})
|
||||
.where(eq(quizSessions.id, QUIZ_SESSION_ID));
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/label", () => {
|
||||
test("creates a label for assigned paragraph", async () => {
|
||||
const req = new Request("http://localhost:3000/api/label", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
paragraphId: "test-para-1",
|
||||
contentCategory: "Board Governance",
|
||||
specificityLevel: 1,
|
||||
sessionId: "test-session-1",
|
||||
durationMs: 5000,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.progress.completed).toBe(1);
|
||||
expect(data.progress.total).toBe(2);
|
||||
});
|
||||
|
||||
test("GET returns next unlabeled paragraph after labeling", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.paragraph.id).toBe("test-para-2");
|
||||
expect(data.progress.completed).toBe(1);
|
||||
expect(data.progress.total).toBe(2);
|
||||
});
|
||||
|
||||
test("rejects duplicate label", async () => {
|
||||
const req = new Request("http://localhost:3000/api/label", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
paragraphId: "test-para-1",
|
||||
contentCategory: "Board Governance",
|
||||
specificityLevel: 1,
|
||||
sessionId: "test-session-1",
|
||||
durationMs: 3000,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(409);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Already labeled this paragraph");
|
||||
});
|
||||
|
||||
test("rejects invalid category", async () => {
|
||||
const req = new Request("http://localhost:3000/api/label", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
paragraphId: "test-para-2",
|
||||
contentCategory: "Invalid Category",
|
||||
specificityLevel: 1,
|
||||
sessionId: "test-session-1",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Invalid content category");
|
||||
});
|
||||
|
||||
test("rejects invalid specificity", async () => {
|
||||
const req = new Request("http://localhost:3000/api/label", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
paragraphId: "test-para-2",
|
||||
contentCategory: "Board Governance",
|
||||
specificityLevel: 5,
|
||||
sessionId: "test-session-1",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Invalid specificity level");
|
||||
});
|
||||
|
||||
test("rejects unassigned paragraph", async () => {
|
||||
const req = new Request("http://localhost:3000/api/label", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
paragraphId: "nonexistent-para",
|
||||
contentCategory: "Board Governance",
|
||||
specificityLevel: 1,
|
||||
sessionId: "test-session-1",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test("labels second paragraph and GET returns done", async () => {
|
||||
const req = new Request("http://localhost:3000/api/label", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
paragraphId: "test-para-2",
|
||||
contentCategory: "Management Role",
|
||||
specificityLevel: 2,
|
||||
notes: "Test note",
|
||||
sessionId: "test-session-1",
|
||||
durationMs: 4000,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const data = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.ok).toBe(true);
|
||||
expect(data.progress.completed).toBe(2);
|
||||
expect(data.progress.total).toBe(2);
|
||||
|
||||
// GET should now return done
|
||||
const getRes = await GET();
|
||||
const getData = await getRes.json();
|
||||
expect(getData.done).toBe(true);
|
||||
expect(getData.progress.completed).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/label - unauthorized", () => {
|
||||
test("returns 401 without session", async () => {
|
||||
cookieJar.clear();
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
226
labelapp/app/api/label/route.ts
Normal file
226
labelapp/app/api/label/route.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
assignments,
|
||||
humanLabels,
|
||||
paragraphs,
|
||||
quizSessions,
|
||||
} from "@/db/schema";
|
||||
import { eq, and, desc, isNull, sql } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { WARMUP_PARAGRAPHS } from "@/lib/warmup-paragraphs";
|
||||
|
||||
const VALID_CATEGORIES = [
|
||||
"Board Governance",
|
||||
"Management Role",
|
||||
"Risk Management Process",
|
||||
"Third-Party Risk",
|
||||
"Incident Disclosure",
|
||||
"Strategy Integration",
|
||||
"None/Other",
|
||||
] as const;
|
||||
|
||||
const VALID_SPECIFICITY = [1, 2, 3, 4] as const;
|
||||
|
||||
async function checkWarmupComplete(annotatorId: string): Promise<boolean> {
|
||||
const [quizSession] = await db
|
||||
.select()
|
||||
.from(quizSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(quizSessions.annotatorId, annotatorId),
|
||||
eq(quizSessions.passed, true),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(quizSessions.startedAt))
|
||||
.limit(1);
|
||||
|
||||
if (!quizSession) return false;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(quizSession.answers);
|
||||
const warmupCompleted =
|
||||
typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed.warmupCompleted ?? 0
|
||||
: 0;
|
||||
return warmupCompleted >= WARMUP_PARAGRAPHS.length;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const warmupDone = await checkWarmupComplete(session.annotatorId);
|
||||
if (!warmupDone) {
|
||||
return NextResponse.json({ redirectToWarmup: true });
|
||||
}
|
||||
|
||||
// Find first assigned paragraph that doesn't have a label from this annotator
|
||||
const result = await db
|
||||
.select({
|
||||
paragraphId: assignments.paragraphId,
|
||||
assignedAt: assignments.assignedAt,
|
||||
text: paragraphs.text,
|
||||
wordCount: paragraphs.wordCount,
|
||||
paragraphIndex: paragraphs.paragraphIndex,
|
||||
companyName: paragraphs.companyName,
|
||||
ticker: paragraphs.ticker,
|
||||
filingType: paragraphs.filingType,
|
||||
filingDate: paragraphs.filingDate,
|
||||
secItem: paragraphs.secItem,
|
||||
labelId: humanLabels.id,
|
||||
})
|
||||
.from(assignments)
|
||||
.innerJoin(paragraphs, eq(assignments.paragraphId, paragraphs.id))
|
||||
.leftJoin(
|
||||
humanLabels,
|
||||
and(
|
||||
eq(humanLabels.paragraphId, assignments.paragraphId),
|
||||
eq(humanLabels.annotatorId, session.annotatorId),
|
||||
),
|
||||
)
|
||||
.where(eq(assignments.annotatorId, session.annotatorId))
|
||||
.orderBy(assignments.assignedAt)
|
||||
.limit(1000);
|
||||
|
||||
const total = result.length;
|
||||
const completed = result.filter((r) => r.labelId !== null).length;
|
||||
const next = result.find((r) => r.labelId === null);
|
||||
|
||||
if (!next) {
|
||||
return NextResponse.json({
|
||||
done: true,
|
||||
progress: { completed, total },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
paragraph: {
|
||||
id: next.paragraphId,
|
||||
text: next.text,
|
||||
wordCount: next.wordCount,
|
||||
paragraphIndex: next.paragraphIndex,
|
||||
companyName: next.companyName,
|
||||
ticker: next.ticker,
|
||||
filingType: next.filingType,
|
||||
filingDate: next.filingDate,
|
||||
secItem: next.secItem,
|
||||
},
|
||||
progress: { completed, total },
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { paragraphId, contentCategory, specificityLevel, notes, sessionId, durationMs } =
|
||||
body as {
|
||||
paragraphId?: string;
|
||||
contentCategory?: string;
|
||||
specificityLevel?: number;
|
||||
notes?: string;
|
||||
sessionId?: string;
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
if (!paragraphId || !contentCategory || !specificityLevel || !sessionId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!VALID_CATEGORIES.includes(contentCategory as (typeof VALID_CATEGORIES)[number])) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid content category" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!VALID_SPECIFICITY.includes(specificityLevel as (typeof VALID_SPECIFICITY)[number])) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid specificity level" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify assignment exists
|
||||
const [assignment] = await db
|
||||
.select()
|
||||
.from(assignments)
|
||||
.where(
|
||||
and(
|
||||
eq(assignments.paragraphId, paragraphId),
|
||||
eq(assignments.annotatorId, session.annotatorId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!assignment) {
|
||||
return NextResponse.json(
|
||||
{ error: "No assignment found for this paragraph" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const [existing] = await db
|
||||
.select({ id: humanLabels.id })
|
||||
.from(humanLabels)
|
||||
.where(
|
||||
and(
|
||||
eq(humanLabels.paragraphId, paragraphId),
|
||||
eq(humanLabels.annotatorId, session.annotatorId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Already labeled this paragraph" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
await db.insert(humanLabels).values({
|
||||
paragraphId,
|
||||
annotatorId: session.annotatorId,
|
||||
contentCategory,
|
||||
specificityLevel,
|
||||
notes: notes || null,
|
||||
sessionId,
|
||||
durationMs: durationMs ?? null,
|
||||
});
|
||||
|
||||
// Get updated progress
|
||||
const countResult = await db
|
||||
.select({
|
||||
total: sql<number>`count(*)`,
|
||||
completed: sql<number>`count(${humanLabels.id})`,
|
||||
})
|
||||
.from(assignments)
|
||||
.leftJoin(
|
||||
humanLabels,
|
||||
and(
|
||||
eq(humanLabels.paragraphId, assignments.paragraphId),
|
||||
eq(humanLabels.annotatorId, session.annotatorId),
|
||||
),
|
||||
)
|
||||
.where(eq(assignments.annotatorId, session.annotatorId));
|
||||
|
||||
const progress = {
|
||||
completed: Number(countResult[0]?.completed ?? 0),
|
||||
total: Number(countResult[0]?.total ?? 0),
|
||||
};
|
||||
|
||||
return NextResponse.json({ ok: true, progress });
|
||||
}
|
||||
269
labelapp/app/api/metrics/__test__/metrics.test.ts
Normal file
269
labelapp/app/api/metrics/__test__/metrics.test.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import { describe, test, expect, beforeAll, afterAll, mock } from "bun:test";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
annotators,
|
||||
paragraphs,
|
||||
assignments,
|
||||
humanLabels,
|
||||
adjudications,
|
||||
} from "@/db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
const cookieJar = new Map<string, string>();
|
||||
const mockCookieStore = {
|
||||
get(name: string) {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { value } : undefined;
|
||||
},
|
||||
set(name: string, value: string, _opts?: unknown) {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete(name: string) {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
};
|
||||
|
||||
mock.module("next/headers", () => ({
|
||||
cookies: async () => mockCookieStore,
|
||||
}));
|
||||
|
||||
const { createSession } = await import("@/lib/auth");
|
||||
const { GET } = await import("../route");
|
||||
|
||||
const ADMIN_USER = {
|
||||
id: "admin",
|
||||
displayName: "Admin",
|
||||
password: "adminpass",
|
||||
};
|
||||
|
||||
const TEST_ANNOTATORS = [
|
||||
{ id: "met-ann-1", displayName: "Metric Annotator 1", password: "pass1" },
|
||||
{ id: "met-ann-2", displayName: "Metric Annotator 2", password: "pass2" },
|
||||
{ id: "met-ann-3", displayName: "Metric Annotator 3", password: "pass3" },
|
||||
];
|
||||
|
||||
const TEST_PARAGRAPHS = [
|
||||
{
|
||||
id: "met-para-1",
|
||||
text: "Board oversight of cybersecurity risk.",
|
||||
wordCount: 6,
|
||||
paragraphIndex: 0,
|
||||
companyName: "Metrics Corp",
|
||||
cik: "0008888888",
|
||||
ticker: "METT",
|
||||
filingType: "10-K",
|
||||
filingDate: "2024-03-15",
|
||||
fiscalYear: 2024,
|
||||
accessionNumber: "0008888888-24-000001",
|
||||
secItem: "Item 1C",
|
||||
},
|
||||
{
|
||||
id: "met-para-2",
|
||||
text: "CISO manages program.",
|
||||
wordCount: 3,
|
||||
paragraphIndex: 1,
|
||||
companyName: "Metrics Corp",
|
||||
cik: "0008888888",
|
||||
ticker: "METT",
|
||||
filingType: "10-K",
|
||||
filingDate: "2024-03-15",
|
||||
fiscalYear: 2024,
|
||||
accessionNumber: "0008888888-24-000001",
|
||||
secItem: "Item 1C",
|
||||
},
|
||||
{
|
||||
id: "met-para-3",
|
||||
text: "Third party vendor management policies.",
|
||||
wordCount: 5,
|
||||
paragraphIndex: 2,
|
||||
companyName: "Metrics Corp",
|
||||
cik: "0008888888",
|
||||
ticker: "METT",
|
||||
filingType: "10-K",
|
||||
filingDate: "2024-03-15",
|
||||
fiscalYear: 2024,
|
||||
accessionNumber: "0008888888-24-000001",
|
||||
secItem: "Item 1C",
|
||||
},
|
||||
];
|
||||
|
||||
const PARAGRAPH_IDS = TEST_PARAGRAPHS.map((p) => p.id);
|
||||
const ALL_ANNOTATOR_IDS = [
|
||||
ADMIN_USER.id,
|
||||
...TEST_ANNOTATORS.map((a) => a.id),
|
||||
];
|
||||
|
||||
async function cleanup() {
|
||||
await db
|
||||
.delete(adjudications)
|
||||
.where(inArray(adjudications.paragraphId, PARAGRAPH_IDS));
|
||||
await db
|
||||
.delete(humanLabels)
|
||||
.where(inArray(humanLabels.paragraphId, PARAGRAPH_IDS));
|
||||
await db
|
||||
.delete(assignments)
|
||||
.where(inArray(assignments.paragraphId, PARAGRAPH_IDS));
|
||||
await db.delete(paragraphs).where(inArray(paragraphs.id, PARAGRAPH_IDS));
|
||||
for (const a of TEST_ANNOTATORS) {
|
||||
await db.delete(annotators).where(eq(annotators.id, a.id));
|
||||
}
|
||||
await db.delete(annotators).where(eq(annotators.id, ADMIN_USER.id));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
|
||||
await db
|
||||
.insert(annotators)
|
||||
.values([ADMIN_USER, ...TEST_ANNOTATORS])
|
||||
.onConflictDoNothing();
|
||||
await db.insert(paragraphs).values(TEST_PARAGRAPHS);
|
||||
|
||||
// Assign all paragraphs to all 3 annotators
|
||||
const assignmentRows = TEST_PARAGRAPHS.flatMap((p) =>
|
||||
TEST_ANNOTATORS.map((a) => ({
|
||||
paragraphId: p.id,
|
||||
annotatorId: a.id,
|
||||
})),
|
||||
);
|
||||
await db.insert(assignments).values(assignmentRows);
|
||||
|
||||
// Create labels with known patterns:
|
||||
// Para 1: all 3 agree → Board Governance, specificity 3
|
||||
// Para 2: 2 agree, 1 differs → Management Role (2/3), specificity 2 (2/3)
|
||||
// Para 3: all 3 agree → Third-Party Risk, specificity 2
|
||||
await db.insert(humanLabels).values([
|
||||
// Para 1 - full agreement
|
||||
{ paragraphId: "met-para-1", annotatorId: "met-ann-1", contentCategory: "Board Governance", specificityLevel: 3, sessionId: "met-session" },
|
||||
{ paragraphId: "met-para-1", annotatorId: "met-ann-2", contentCategory: "Board Governance", specificityLevel: 3, sessionId: "met-session" },
|
||||
{ paragraphId: "met-para-1", annotatorId: "met-ann-3", contentCategory: "Board Governance", specificityLevel: 3, sessionId: "met-session" },
|
||||
// Para 2 - 2/3 agreement
|
||||
{ paragraphId: "met-para-2", annotatorId: "met-ann-1", contentCategory: "Management Role", specificityLevel: 2, sessionId: "met-session" },
|
||||
{ paragraphId: "met-para-2", annotatorId: "met-ann-2", contentCategory: "Management Role", specificityLevel: 2, sessionId: "met-session" },
|
||||
{ paragraphId: "met-para-2", annotatorId: "met-ann-3", contentCategory: "Board Governance", specificityLevel: 1, sessionId: "met-session" },
|
||||
// Para 3 - full agreement
|
||||
{ paragraphId: "met-para-3", annotatorId: "met-ann-1", contentCategory: "Third-Party Risk", specificityLevel: 2, sessionId: "met-session" },
|
||||
{ paragraphId: "met-para-3", annotatorId: "met-ann-2", contentCategory: "Third-Party Risk", specificityLevel: 2, sessionId: "met-session" },
|
||||
{ paragraphId: "met-para-3", annotatorId: "met-ann-3", contentCategory: "Third-Party Risk", specificityLevel: 2, sessionId: "met-session" },
|
||||
]);
|
||||
|
||||
// Create one adjudication
|
||||
await db.insert(adjudications).values({
|
||||
paragraphId: "met-para-1",
|
||||
finalCategory: "Board Governance",
|
||||
finalSpecificity: 3,
|
||||
method: "consensus",
|
||||
});
|
||||
|
||||
cookieJar.clear();
|
||||
await createSession(ADMIN_USER.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
describe("GET /api/metrics", () => {
|
||||
test("returns expected progress numbers", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// 3 test paragraphs all have 3 labels → all fully labeled
|
||||
expect(data.progress.fullyLabeled).toBe(3);
|
||||
expect(data.progress.adjudicated).toBe(1);
|
||||
|
||||
// Per-annotator: each should have 3 completed (one label per paragraph)
|
||||
for (const ann of data.progress.perAnnotator) {
|
||||
if (TEST_ANNOTATORS.some((a) => a.id === ann.id)) {
|
||||
expect(ann.completed).toBe(3);
|
||||
expect(ann.total).toBe(3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("returns valid kappa value", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// With high agreement (2/3 paragraphs fully agree, 1 has 2/3 majority), kappa should be high
|
||||
expect(data.agreement.avgKappa).toBeGreaterThan(0);
|
||||
expect(data.agreement.avgKappa).toBeLessThanOrEqual(1);
|
||||
|
||||
// Kappa matrix should have our 3 annotators
|
||||
expect(data.agreement.kappaMatrix.annotators.length).toBeGreaterThanOrEqual(3);
|
||||
expect(data.agreement.kappaMatrix.values.length).toBe(
|
||||
data.agreement.kappaMatrix.annotators.length,
|
||||
);
|
||||
});
|
||||
|
||||
test("returns valid alpha value", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Krippendorff's alpha should be defined and within valid range
|
||||
expect(typeof data.agreement.krippendorffsAlpha).toBe("number");
|
||||
expect(data.agreement.krippendorffsAlpha).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("returns consensus rate", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// 2 out of 3 paragraphs have full category agreement
|
||||
// Consensus rate should be ~0.667
|
||||
expect(data.agreement.consensusRate).toBeGreaterThan(0.5);
|
||||
expect(data.agreement.consensusRate).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("returns confusion matrix with correct dimensions", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.confusionMatrix.labels).toHaveLength(7);
|
||||
expect(data.confusionMatrix.matrix).toHaveLength(7);
|
||||
for (const row of data.confusionMatrix.matrix) {
|
||||
expect(row).toHaveLength(7);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns per-category agreement", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.agreement.perCategory).toBeDefined();
|
||||
|
||||
// Board Governance appears in all 3 paragraphs' labels but only fully agreed in para 1
|
||||
expect(typeof data.agreement.perCategory["Board Governance"]).toBe(
|
||||
"number",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/metrics - unauthorized", () => {
|
||||
test("returns 401 without session", async () => {
|
||||
cookieJar.clear();
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("returns 403 for non-admin user", async () => {
|
||||
cookieJar.clear();
|
||||
await createSession(TEST_ANNOTATORS[0].id);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// Restore admin session
|
||||
cookieJar.clear();
|
||||
await createSession(ADMIN_USER.id);
|
||||
});
|
||||
});
|
||||
264
labelapp/app/api/metrics/route.ts
Normal file
264
labelapp/app/api/metrics/route.ts
Normal file
@ -0,0 +1,264 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
adjudications,
|
||||
annotators,
|
||||
assignments,
|
||||
humanLabels,
|
||||
paragraphs,
|
||||
} from "@/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import {
|
||||
cohensKappa,
|
||||
krippendorffsAlpha,
|
||||
confusionMatrix as buildConfusionMatrix,
|
||||
agreementRate,
|
||||
perCategoryAgreement,
|
||||
} from "@/lib/metrics";
|
||||
|
||||
const CATEGORIES = [
|
||||
"Board Governance",
|
||||
"Management Role",
|
||||
"Risk Management Process",
|
||||
"Third-Party Risk",
|
||||
"Incident Disclosure",
|
||||
"Strategy Integration",
|
||||
"None/Other",
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (session.annotatorId !== "admin") {
|
||||
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Load all data
|
||||
const [allLabels, allAnnotators, allAdjudications, paragraphCount] =
|
||||
await Promise.all([
|
||||
db.select().from(humanLabels),
|
||||
db.select().from(annotators),
|
||||
db.select().from(adjudications),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(paragraphs),
|
||||
]);
|
||||
|
||||
const totalParagraphs = Number(paragraphCount[0]?.count ?? 0);
|
||||
|
||||
// Per-annotator assignment counts
|
||||
const assignmentCounts = await db
|
||||
.select({
|
||||
annotatorId: assignments.annotatorId,
|
||||
total: sql<number>`count(*)`,
|
||||
})
|
||||
.from(assignments)
|
||||
.groupBy(assignments.annotatorId);
|
||||
|
||||
const assignmentMap = new Map(
|
||||
assignmentCounts.map((a) => [a.annotatorId, Number(a.total)]),
|
||||
);
|
||||
|
||||
// Group labels by paragraph
|
||||
const byParagraph = new Map<
|
||||
string,
|
||||
{ annotatorId: string; contentCategory: string; specificityLevel: number }[]
|
||||
>();
|
||||
for (const label of allLabels) {
|
||||
if (!byParagraph.has(label.paragraphId)) {
|
||||
byParagraph.set(label.paragraphId, []);
|
||||
}
|
||||
byParagraph.get(label.paragraphId)!.push({
|
||||
annotatorId: label.annotatorId,
|
||||
contentCategory: label.contentCategory,
|
||||
specificityLevel: label.specificityLevel,
|
||||
});
|
||||
}
|
||||
|
||||
// Count fully labeled (3+ labels)
|
||||
let fullyLabeled = 0;
|
||||
for (const labels of byParagraph.values()) {
|
||||
if (labels.length >= 3) fullyLabeled++;
|
||||
}
|
||||
|
||||
// Per-annotator completed counts
|
||||
const annotatorCompletedMap = new Map<string, number>();
|
||||
for (const label of allLabels) {
|
||||
annotatorCompletedMap.set(
|
||||
label.annotatorId,
|
||||
(annotatorCompletedMap.get(label.annotatorId) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
const annotatorMap = new Map(
|
||||
allAnnotators.map((a) => [a.id, a.displayName]),
|
||||
);
|
||||
|
||||
// Filter to non-admin annotators for per-annotator stats
|
||||
const perAnnotator = allAnnotators
|
||||
.filter((a) => a.id !== "admin")
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
displayName: a.displayName,
|
||||
completed: annotatorCompletedMap.get(a.id) ?? 0,
|
||||
total: assignmentMap.get(a.id) ?? 0,
|
||||
}));
|
||||
|
||||
// === Agreement metrics ===
|
||||
|
||||
// Get paragraphs with 2+ labels for pairwise analysis
|
||||
const multiLabeledParagraphs: {
|
||||
paragraphId: string;
|
||||
labels: { annotatorId: string; category: string; specificity: number }[];
|
||||
}[] = [];
|
||||
|
||||
for (const [paragraphId, labels] of byParagraph) {
|
||||
if (labels.length >= 2) {
|
||||
multiLabeledParagraphs.push({
|
||||
paragraphId,
|
||||
labels: labels.map((l) => ({
|
||||
annotatorId: l.annotatorId,
|
||||
category: l.contentCategory,
|
||||
specificity: l.specificityLevel,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all annotator IDs that have labels (excluding admin)
|
||||
const annotatorIds = [
|
||||
...new Set(allLabels.map((l) => l.annotatorId)),
|
||||
].filter((id) => id !== "admin");
|
||||
annotatorIds.sort();
|
||||
|
||||
// For each annotator pair, collect shared paragraph ratings
|
||||
const kappaValues: number[][] = Array.from(
|
||||
{ length: annotatorIds.length },
|
||||
() => new Array(annotatorIds.length).fill(0),
|
||||
);
|
||||
|
||||
let kappaSum = 0;
|
||||
let kappaPairCount = 0;
|
||||
|
||||
for (let i = 0; i < annotatorIds.length; i++) {
|
||||
kappaValues[i][i] = 1; // Self-agreement is perfect
|
||||
for (let j = i + 1; j < annotatorIds.length; j++) {
|
||||
const a1 = annotatorIds[i];
|
||||
const a2 = annotatorIds[j];
|
||||
|
||||
const shared1: string[] = [];
|
||||
const shared2: string[] = [];
|
||||
|
||||
for (const para of multiLabeledParagraphs) {
|
||||
const l1 = para.labels.find((l) => l.annotatorId === a1);
|
||||
const l2 = para.labels.find((l) => l.annotatorId === a2);
|
||||
if (l1 && l2) {
|
||||
shared1.push(l1.category);
|
||||
shared2.push(l2.category);
|
||||
}
|
||||
}
|
||||
|
||||
if (shared1.length >= 2) {
|
||||
const kappa = cohensKappa(shared1, shared2);
|
||||
kappaValues[i][j] = kappa;
|
||||
kappaValues[j][i] = kappa;
|
||||
kappaSum += kappa;
|
||||
kappaPairCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const avgKappa = kappaPairCount > 0 ? kappaSum / kappaPairCount : 0;
|
||||
|
||||
// Consensus rate: proportion of 3+ labeled paragraphs where all agree on category
|
||||
const categoryArrays: string[][] = [];
|
||||
for (const [, labels] of byParagraph) {
|
||||
if (labels.length >= 3) {
|
||||
categoryArrays.push(labels.map((l) => l.contentCategory));
|
||||
}
|
||||
}
|
||||
const consensusRate = agreementRate(categoryArrays);
|
||||
|
||||
// Krippendorff's Alpha for specificity (ordinal)
|
||||
// Build raters x items matrix
|
||||
// Get unique paragraph IDs that have 2+ labels
|
||||
const multiLabeledParaIds = multiLabeledParagraphs.map((p) => p.paragraphId);
|
||||
const ratingsMatrix: (number | null)[][] = annotatorIds.map((annotatorId) =>
|
||||
multiLabeledParaIds.map((paraId) => {
|
||||
const para = multiLabeledParagraphs.find(
|
||||
(p) => p.paragraphId === paraId,
|
||||
);
|
||||
const label = para?.labels.find((l) => l.annotatorId === annotatorId);
|
||||
return label?.specificity ?? null;
|
||||
}),
|
||||
);
|
||||
|
||||
let alpha = 0;
|
||||
if (annotatorIds.length >= 2 && multiLabeledParaIds.length > 0) {
|
||||
alpha = krippendorffsAlpha(ratingsMatrix);
|
||||
}
|
||||
|
||||
// Per-category agreement
|
||||
const perCategory = perCategoryAgreement(
|
||||
allLabels.map((l) => ({
|
||||
category: l.contentCategory,
|
||||
annotatorId: l.annotatorId,
|
||||
paragraphId: l.paragraphId,
|
||||
})),
|
||||
CATEGORIES,
|
||||
);
|
||||
|
||||
// Aggregated confusion matrix across all annotator pairs
|
||||
const allActual: string[] = [];
|
||||
const allPredicted: string[] = [];
|
||||
|
||||
for (let i = 0; i < annotatorIds.length; i++) {
|
||||
for (let j = i + 1; j < annotatorIds.length; j++) {
|
||||
const a1 = annotatorIds[i];
|
||||
const a2 = annotatorIds[j];
|
||||
|
||||
for (const para of multiLabeledParagraphs) {
|
||||
const l1 = para.labels.find((l) => l.annotatorId === a1);
|
||||
const l2 = para.labels.find((l) => l.annotatorId === a2);
|
||||
if (l1 && l2) {
|
||||
allActual.push(l1.category);
|
||||
allPredicted.push(l2.category);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const confMatrix =
|
||||
allActual.length > 0
|
||||
? buildConfusionMatrix(allActual, allPredicted, CATEGORIES)
|
||||
: CATEGORIES.map(() => new Array(CATEGORIES.length).fill(0));
|
||||
|
||||
return NextResponse.json({
|
||||
progress: {
|
||||
totalParagraphs,
|
||||
fullyLabeled,
|
||||
adjudicated: allAdjudications.length,
|
||||
perAnnotator,
|
||||
},
|
||||
agreement: {
|
||||
consensusRate,
|
||||
avgKappa,
|
||||
kappaMatrix: {
|
||||
annotators: annotatorIds.map(
|
||||
(id) => annotatorMap.get(id) ?? id,
|
||||
),
|
||||
values: kappaValues,
|
||||
},
|
||||
krippendorffsAlpha: alpha,
|
||||
perCategory,
|
||||
},
|
||||
confusionMatrix: {
|
||||
labels: CATEGORIES,
|
||||
matrix: confMatrix,
|
||||
},
|
||||
});
|
||||
}
|
||||
256
labelapp/app/api/quiz/__test__/quiz.test.ts
Normal file
256
labelapp/app/api/quiz/__test__/quiz.test.ts
Normal file
@ -0,0 +1,256 @@
|
||||
import { describe, test, expect, beforeAll, afterAll, mock } from "bun:test";
|
||||
import { db } from "@/db";
|
||||
import { annotators, quizSessions } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Mock cookie store that captures set/delete calls
|
||||
const cookieJar = new Map<string, string>();
|
||||
const mockCookieStore = {
|
||||
get(name: string) {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { value } : undefined;
|
||||
},
|
||||
set(name: string, value: string, _opts?: unknown) {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete(name: string) {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
};
|
||||
|
||||
// Mock next/headers before importing route handlers
|
||||
mock.module("next/headers", () => ({
|
||||
cookies: async () => mockCookieStore,
|
||||
}));
|
||||
|
||||
// Set up a valid session using the same auth module (it writes to mocked cookies)
|
||||
import { createSession } from "@/lib/auth";
|
||||
import { QUIZ_QUESTIONS } from "@/lib/quiz-questions";
|
||||
|
||||
// Import route handlers after mock is set up
|
||||
const { GET, POST } = await import("../route");
|
||||
|
||||
const TEST_USER = {
|
||||
id: "test-quiz-user",
|
||||
displayName: "Quiz Tester",
|
||||
password: "test",
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await db
|
||||
.delete(quizSessions)
|
||||
.where(eq(quizSessions.annotatorId, TEST_USER.id));
|
||||
await db.delete(annotators).where(eq(annotators.id, TEST_USER.id));
|
||||
await db.insert(annotators).values(TEST_USER);
|
||||
await createSession(TEST_USER.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db
|
||||
.delete(quizSessions)
|
||||
.where(eq(quizSessions.annotatorId, TEST_USER.id));
|
||||
await db.delete(annotators).where(eq(annotators.id, TEST_USER.id));
|
||||
});
|
||||
|
||||
function makeRequest(body: unknown): Request {
|
||||
return new Request("http://localhost:3000/api/quiz", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/quiz {action: 'start'}", () => {
|
||||
test("returns quizSessionId and 8 questions", async () => {
|
||||
const res = await POST(makeRequest({ action: "start" }));
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.quizSessionId).toBeDefined();
|
||||
expect(typeof data.quizSessionId).toBe("string");
|
||||
expect(data.questions).toHaveLength(8);
|
||||
expect(data.questions[0]).toHaveProperty("id");
|
||||
expect(data.questions[0]).toHaveProperty("paragraphText");
|
||||
expect(data.questions[0]).toHaveProperty("options");
|
||||
expect(data.questions[0]).toHaveProperty("correctAnswer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/quiz {action: 'answer'}", () => {
|
||||
let quizSessionId: string;
|
||||
let questions: Array<{
|
||||
id: string;
|
||||
correctAnswer: string;
|
||||
explanation: string;
|
||||
}>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await POST(makeRequest({ action: "start" }));
|
||||
const data = await res.json();
|
||||
quizSessionId = data.quizSessionId;
|
||||
questions = data.questions;
|
||||
});
|
||||
|
||||
test("returns correct: true for a correct answer", async () => {
|
||||
const q = questions[0];
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
action: "answer",
|
||||
quizSessionId,
|
||||
questionId: q.id,
|
||||
answer: q.correctAnswer,
|
||||
}),
|
||||
);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.correct).toBe(true);
|
||||
expect(data.explanation).toBe(q.explanation);
|
||||
expect(data.completed).toBe(false);
|
||||
});
|
||||
|
||||
test("returns correct: false for a wrong answer", async () => {
|
||||
const q = questions[1];
|
||||
// Pick an answer that is NOT the correct one
|
||||
const wrongAnswer =
|
||||
q.correctAnswer === q.correctAnswer
|
||||
? QUIZ_QUESTIONS.find((qq) => qq.id === q.id)!.options.find(
|
||||
(o) => o.value !== q.correctAnswer,
|
||||
)!.value
|
||||
: q.correctAnswer;
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
action: "answer",
|
||||
quizSessionId,
|
||||
questionId: q.id,
|
||||
answer: wrongAnswer,
|
||||
}),
|
||||
);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.correct).toBe(false);
|
||||
expect(data.correctAnswer).toBe(q.correctAnswer);
|
||||
expect(data.explanation).toBeDefined();
|
||||
expect(data.completed).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects duplicate answer for same question", async () => {
|
||||
const q = questions[0]; // already answered above
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
action: "answer",
|
||||
quizSessionId,
|
||||
questionId: q.id,
|
||||
answer: q.correctAnswer,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Question already answered");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Full quiz flow - all correct", () => {
|
||||
let quizSessionId: string;
|
||||
let questions: Array<{
|
||||
id: string;
|
||||
correctAnswer: string;
|
||||
}>;
|
||||
|
||||
test("answering all 8 correctly returns passed: true", async () => {
|
||||
const startRes = await POST(makeRequest({ action: "start" }));
|
||||
const startData = await startRes.json();
|
||||
quizSessionId = startData.quizSessionId;
|
||||
questions = startData.questions;
|
||||
|
||||
let lastData: { score?: number; passed?: boolean; completed: boolean } = {
|
||||
completed: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
action: "answer",
|
||||
quizSessionId,
|
||||
questionId: q.id,
|
||||
answer: q.correctAnswer,
|
||||
}),
|
||||
);
|
||||
lastData = await res.json();
|
||||
}
|
||||
|
||||
expect(lastData.completed).toBe(true);
|
||||
expect(lastData.score).toBe(8);
|
||||
expect(lastData.passed).toBe(true);
|
||||
});
|
||||
|
||||
test("GET returns hasPassedQuiz: true after passing", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.hasPassedQuiz).toBe(true);
|
||||
expect(data.quizSessionId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Full quiz flow - failing score", () => {
|
||||
test("answering with 5 wrong returns passed: false", async () => {
|
||||
// Clean up passed quizzes so GET doesn't short-circuit
|
||||
await db
|
||||
.delete(quizSessions)
|
||||
.where(eq(quizSessions.annotatorId, TEST_USER.id));
|
||||
|
||||
const startRes = await POST(makeRequest({ action: "start" }));
|
||||
const startData = await startRes.json();
|
||||
const quizSessionId = startData.quizSessionId;
|
||||
const questions: Array<{ id: string; correctAnswer: string }> =
|
||||
startData.questions;
|
||||
|
||||
let lastData: { score?: number; passed?: boolean; completed: boolean } = {
|
||||
completed: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const fullQ = QUIZ_QUESTIONS.find((qq) => qq.id === q.id)!;
|
||||
|
||||
// Get wrong answer for first 5, correct for last 3
|
||||
let answer: string;
|
||||
if (i < 5) {
|
||||
const wrongOption = fullQ.options.find(
|
||||
(o) => o.value !== q.correctAnswer,
|
||||
);
|
||||
answer = wrongOption ? wrongOption.value : q.correctAnswer;
|
||||
} else {
|
||||
answer = q.correctAnswer;
|
||||
}
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
action: "answer",
|
||||
quizSessionId,
|
||||
questionId: q.id,
|
||||
answer,
|
||||
}),
|
||||
);
|
||||
lastData = await res.json();
|
||||
}
|
||||
|
||||
expect(lastData.completed).toBe(true);
|
||||
expect(lastData.score).toBe(3);
|
||||
expect(lastData.passed).toBe(false);
|
||||
});
|
||||
|
||||
test("GET returns hasPassedQuiz: false after failing", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.hasPassedQuiz).toBe(false);
|
||||
});
|
||||
});
|
||||
202
labelapp/app/api/quiz/route.ts
Normal file
202
labelapp/app/api/quiz/route.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { quizSessions } from "@/db/schema";
|
||||
import { eq, and, gte } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import {
|
||||
drawQuizQuestions,
|
||||
QUIZ_QUESTIONS,
|
||||
type QuizQuestion,
|
||||
} from "@/lib/quiz-questions";
|
||||
|
||||
const QUIZ_EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
|
||||
|
||||
interface StoredAnswer {
|
||||
questionId: string;
|
||||
answer: string;
|
||||
correct: boolean;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const cutoff = new Date(Date.now() - QUIZ_EXPIRY_MS);
|
||||
|
||||
const [passedQuiz] = await db
|
||||
.select()
|
||||
.from(quizSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(quizSessions.annotatorId, session.annotatorId),
|
||||
eq(quizSessions.passed, true),
|
||||
gte(quizSessions.startedAt, cutoff),
|
||||
),
|
||||
)
|
||||
.orderBy(quizSessions.startedAt)
|
||||
.limit(1);
|
||||
|
||||
if (passedQuiz) {
|
||||
return NextResponse.json({
|
||||
hasPassedQuiz: true,
|
||||
quizSessionId: passedQuiz.id,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ hasPassedQuiz: false });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action } = body as { action: string };
|
||||
|
||||
if (action === "start") {
|
||||
return handleStart(session.annotatorId);
|
||||
}
|
||||
|
||||
if (action === "answer") {
|
||||
const { quizSessionId, questionId, answer } = body as {
|
||||
quizSessionId: string;
|
||||
questionId: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
if (!quizSessionId || !questionId || !answer) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return handleAnswer(
|
||||
session.annotatorId,
|
||||
quizSessionId,
|
||||
questionId,
|
||||
answer,
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
async function handleStart(annotatorId: string) {
|
||||
const questions = drawQuizQuestions(8);
|
||||
const quizSessionId = crypto.randomUUID();
|
||||
|
||||
await db.insert(quizSessions).values({
|
||||
id: quizSessionId,
|
||||
annotatorId,
|
||||
startedAt: new Date(),
|
||||
totalQuestions: 8,
|
||||
answers: "[]",
|
||||
});
|
||||
|
||||
return NextResponse.json({ quizSessionId, questions });
|
||||
}
|
||||
|
||||
async function handleAnswer(
|
||||
annotatorId: string,
|
||||
quizSessionId: string,
|
||||
questionId: string,
|
||||
answer: string,
|
||||
) {
|
||||
const [quizSession] = await db
|
||||
.select()
|
||||
.from(quizSessions)
|
||||
.where(eq(quizSessions.id, quizSessionId))
|
||||
.limit(1);
|
||||
|
||||
if (!quizSession) {
|
||||
return NextResponse.json(
|
||||
{ error: "Quiz session not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (quizSession.annotatorId !== annotatorId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (quizSession.completedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: "Quiz already completed" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - quizSession.startedAt.getTime();
|
||||
if (elapsed > QUIZ_EXPIRY_MS) {
|
||||
return NextResponse.json({ error: "Quiz session expired" }, { status: 400 });
|
||||
}
|
||||
|
||||
const question = QUIZ_QUESTIONS.find(
|
||||
(q: QuizQuestion) => q.id === questionId,
|
||||
);
|
||||
if (!question) {
|
||||
return NextResponse.json({ error: "Question not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const existingAnswers: StoredAnswer[] = JSON.parse(quizSession.answers);
|
||||
|
||||
const alreadyAnswered = existingAnswers.some(
|
||||
(a) => a.questionId === questionId,
|
||||
);
|
||||
if (alreadyAnswered) {
|
||||
return NextResponse.json(
|
||||
{ error: "Question already answered" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const correct = answer === question.correctAnswer;
|
||||
|
||||
const updatedAnswers: StoredAnswer[] = [
|
||||
...existingAnswers,
|
||||
{ questionId, answer, correct },
|
||||
];
|
||||
|
||||
const allAnswered = updatedAnswers.length >= quizSession.totalQuestions;
|
||||
|
||||
if (allAnswered) {
|
||||
const score = updatedAnswers.filter((a) => a.correct).length;
|
||||
const passed = score >= 7;
|
||||
|
||||
await db
|
||||
.update(quizSessions)
|
||||
.set({
|
||||
answers: JSON.stringify(updatedAnswers),
|
||||
completedAt: new Date(),
|
||||
score,
|
||||
passed,
|
||||
})
|
||||
.where(eq(quizSessions.id, quizSessionId));
|
||||
|
||||
return NextResponse.json({
|
||||
correct,
|
||||
correctAnswer: question.correctAnswer,
|
||||
explanation: question.explanation,
|
||||
score,
|
||||
passed,
|
||||
completed: true,
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(quizSessions)
|
||||
.set({ answers: JSON.stringify(updatedAnswers) })
|
||||
.where(eq(quizSessions.id, quizSessionId));
|
||||
|
||||
return NextResponse.json({
|
||||
correct,
|
||||
correctAnswer: question.correctAnswer,
|
||||
explanation: question.explanation,
|
||||
completed: false,
|
||||
});
|
||||
}
|
||||
178
labelapp/app/api/warmup/__test__/warmup.test.ts
Normal file
178
labelapp/app/api/warmup/__test__/warmup.test.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import { describe, test, expect, beforeAll, afterAll, mock } from "bun:test";
|
||||
import { db } from "@/db";
|
||||
import { annotators, quizSessions } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const cookieJar = new Map<string, string>();
|
||||
const mockCookieStore = {
|
||||
get(name: string) {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { value } : undefined;
|
||||
},
|
||||
set(name: string, value: string, _opts?: unknown) {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete(name: string) {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
};
|
||||
|
||||
mock.module("next/headers", () => ({
|
||||
cookies: async () => mockCookieStore,
|
||||
}));
|
||||
|
||||
const { createSession } = await import("@/lib/auth");
|
||||
const { GET, POST } = await import("../route");
|
||||
|
||||
const TEST_ANNOTATOR = {
|
||||
id: "test-warmup-user",
|
||||
displayName: "Test Warmup User",
|
||||
password: "testpass",
|
||||
};
|
||||
|
||||
const QUIZ_SESSION_ID = "test-warmup-quiz-session";
|
||||
|
||||
beforeAll(async () => {
|
||||
// Clean up any leftover data
|
||||
await db
|
||||
.delete(quizSessions)
|
||||
.where(eq(quizSessions.annotatorId, TEST_ANNOTATOR.id));
|
||||
await db.delete(annotators).where(eq(annotators.id, TEST_ANNOTATOR.id));
|
||||
|
||||
// Create test annotator
|
||||
await db.insert(annotators).values(TEST_ANNOTATOR);
|
||||
|
||||
// Create a passed quiz session
|
||||
await db.insert(quizSessions).values({
|
||||
id: QUIZ_SESSION_ID,
|
||||
annotatorId: TEST_ANNOTATOR.id,
|
||||
totalQuestions: 8,
|
||||
score: 8,
|
||||
passed: true,
|
||||
completedAt: new Date(),
|
||||
answers: "[]",
|
||||
});
|
||||
|
||||
// Set up session cookie
|
||||
cookieJar.clear();
|
||||
await createSession(TEST_ANNOTATOR.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db
|
||||
.delete(quizSessions)
|
||||
.where(eq(quizSessions.annotatorId, TEST_ANNOTATOR.id));
|
||||
await db.delete(annotators).where(eq(annotators.id, TEST_ANNOTATOR.id));
|
||||
});
|
||||
|
||||
describe("GET /api/warmup", () => {
|
||||
test("returns first warmup paragraph", async () => {
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.done).toBe(false);
|
||||
expect(data.warmupCompleted).toBe(0);
|
||||
expect(data.paragraph).toBeDefined();
|
||||
expect(data.paragraph.id).toBe("warmup-1");
|
||||
expect(data.paragraph.text).toBeTruthy();
|
||||
expect(data.paragraph.index).toBe(0);
|
||||
expect(data.paragraph.total).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/warmup", () => {
|
||||
test("returns gold answer and explanation for correct answer", async () => {
|
||||
const req = new Request("http://localhost:3000/api/warmup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
category: "Board Governance",
|
||||
specificity: 3,
|
||||
warmupIndex: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.categoryCorrect).toBe(true);
|
||||
expect(data.specificityCorrect).toBe(true);
|
||||
expect(data.goldCategory).toBe("Board Governance");
|
||||
expect(data.goldSpecificity).toBe(3);
|
||||
expect(data.explanation).toBeTruthy();
|
||||
expect(data.warmupCompleted).toBe(1);
|
||||
expect(data.done).toBe(false);
|
||||
});
|
||||
|
||||
test("returns feedback for incorrect answer", async () => {
|
||||
const req = new Request("http://localhost:3000/api/warmup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
category: "Management Role",
|
||||
specificity: 1,
|
||||
warmupIndex: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
const data = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.categoryCorrect).toBe(false);
|
||||
expect(data.specificityCorrect).toBe(false);
|
||||
expect(data.goldCategory).toBe("Incident Disclosure");
|
||||
expect(data.goldSpecificity).toBe(4);
|
||||
expect(data.warmupCompleted).toBe(2);
|
||||
});
|
||||
|
||||
test("rejects mismatched warmup index", async () => {
|
||||
const req = new Request("http://localhost:3000/api/warmup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
category: "Risk Management Process",
|
||||
specificity: 1,
|
||||
warmupIndex: 0, // should be 2 at this point
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBe("Warmup index mismatch");
|
||||
});
|
||||
|
||||
test("completes remaining warmups and returns done", async () => {
|
||||
// Complete warmups 2, 3, 4
|
||||
for (let i = 2; i < 5; i++) {
|
||||
const req = new Request("http://localhost:3000/api/warmup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
category: "None/Other",
|
||||
specificity: 1,
|
||||
warmupIndex: i,
|
||||
}),
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
// GET should now return done
|
||||
const res = await GET();
|
||||
const data = await res.json();
|
||||
expect(data.done).toBe(true);
|
||||
expect(data.warmupCompleted).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/warmup - unauthorized", () => {
|
||||
test("returns 401 without session", async () => {
|
||||
cookieJar.clear();
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
155
labelapp/app/api/warmup/route.ts
Normal file
155
labelapp/app/api/warmup/route.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { quizSessions } from "@/db/schema";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { WARMUP_PARAGRAPHS } from "@/lib/warmup-paragraphs";
|
||||
|
||||
interface WarmupProgress {
|
||||
warmupCompleted: number;
|
||||
}
|
||||
|
||||
function parseWarmupProgress(answersJson: string): WarmupProgress {
|
||||
try {
|
||||
const parsed = JSON.parse(answersJson);
|
||||
if (parsed && typeof parsed === "object" && "warmupCompleted" in parsed) {
|
||||
return { warmupCompleted: parsed.warmupCompleted ?? 0 };
|
||||
}
|
||||
// Legacy format: answers is an array, no warmup tracking yet
|
||||
return { warmupCompleted: 0 };
|
||||
} catch {
|
||||
return { warmupCompleted: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function getPassedQuizSession(annotatorId: string) {
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(quizSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(quizSessions.annotatorId, annotatorId),
|
||||
eq(quizSessions.passed, true),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(quizSessions.startedAt))
|
||||
.limit(1);
|
||||
return session ?? null;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const quizSession = await getPassedQuizSession(session.annotatorId);
|
||||
if (!quizSession) {
|
||||
return NextResponse.json(
|
||||
{ error: "Quiz not passed" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const progress = parseWarmupProgress(quizSession.answers);
|
||||
|
||||
if (progress.warmupCompleted >= WARMUP_PARAGRAPHS.length) {
|
||||
return NextResponse.json({ done: true, warmupCompleted: progress.warmupCompleted });
|
||||
}
|
||||
|
||||
const next = WARMUP_PARAGRAPHS[progress.warmupCompleted];
|
||||
return NextResponse.json({
|
||||
done: false,
|
||||
warmupCompleted: progress.warmupCompleted,
|
||||
paragraph: {
|
||||
id: next.id,
|
||||
text: next.text,
|
||||
index: progress.warmupCompleted,
|
||||
total: WARMUP_PARAGRAPHS.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const quizSession = await getPassedQuizSession(session.annotatorId);
|
||||
if (!quizSession) {
|
||||
return NextResponse.json(
|
||||
{ error: "Quiz not passed" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { category, specificity, warmupIndex } = body as {
|
||||
category?: string;
|
||||
specificity?: number;
|
||||
warmupIndex?: number;
|
||||
};
|
||||
|
||||
if (!category || !specificity || warmupIndex === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields: category, specificity, warmupIndex" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const progress = parseWarmupProgress(quizSession.answers);
|
||||
|
||||
if (warmupIndex !== progress.warmupCompleted) {
|
||||
return NextResponse.json(
|
||||
{ error: "Warmup index mismatch" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (warmupIndex >= WARMUP_PARAGRAPHS.length) {
|
||||
return NextResponse.json(
|
||||
{ error: "Warmup already complete" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const gold = WARMUP_PARAGRAPHS[warmupIndex];
|
||||
const newCompleted = warmupIndex + 1;
|
||||
const categoryCorrect = category === gold.goldCategory;
|
||||
const specificityCorrect = specificity === gold.goldSpecificity;
|
||||
|
||||
// Store warmup progress in the quiz session answers field
|
||||
// Preserve existing quiz answers array if present, add warmup tracking
|
||||
let existingData: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(quizSession.answers);
|
||||
if (Array.isArray(parsed)) {
|
||||
existingData = { quizAnswers: parsed };
|
||||
} else {
|
||||
existingData = parsed;
|
||||
}
|
||||
} catch {
|
||||
existingData = {};
|
||||
}
|
||||
|
||||
await db
|
||||
.update(quizSessions)
|
||||
.set({
|
||||
answers: JSON.stringify({
|
||||
...existingData,
|
||||
warmupCompleted: newCompleted,
|
||||
}),
|
||||
})
|
||||
.where(eq(quizSessions.id, quizSession.id));
|
||||
|
||||
return NextResponse.json({
|
||||
categoryCorrect,
|
||||
specificityCorrect,
|
||||
goldCategory: gold.goldCategory,
|
||||
goldSpecificity: gold.goldSpecificity,
|
||||
explanation: gold.explanation,
|
||||
warmupCompleted: newCompleted,
|
||||
done: newCompleted >= WARMUP_PARAGRAPHS.length,
|
||||
});
|
||||
}
|
||||
19
labelapp/app/dashboard/logout-button.tsx
Normal file
19
labelapp/app/dashboard/logout-button.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth", { method: "DELETE" });
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" className="w-full" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
54
labelapp/app/dashboard/page.tsx
Normal file
54
labelapp/app/dashboard/page.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { db } from "@/db";
|
||||
import { annotators } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LogoutButton } from "./logout-button";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/");
|
||||
|
||||
const [annotator] = await db
|
||||
.select({ displayName: annotators.displayName })
|
||||
.from(annotators)
|
||||
.where(eq(annotators.id, session.annotatorId))
|
||||
.limit(1);
|
||||
|
||||
if (!annotator) redirect("/");
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">
|
||||
Welcome, {annotator.displayName}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
SEC cyBERT Labeling Dashboard
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Link href="/quiz" className="block">
|
||||
<Button className="w-full">Start Labeling Session</Button>
|
||||
</Link>
|
||||
{session.annotatorId === "admin" && (
|
||||
<Link href="/admin" className="block">
|
||||
<Button variant="outline" className="w-full">Admin Panel</Button>
|
||||
</Link>
|
||||
)}
|
||||
<LogoutButton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,11 @@
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant dark {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
@ -83,38 +87,40 @@
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
581
labelapp/app/label/page.tsx
Normal file
581
labelapp/app/label/page.tsx
Normal file
@ -0,0 +1,581 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BookOpen, CheckCircle2 } from "lucide-react";
|
||||
|
||||
const CATEGORIES = [
|
||||
"Board Governance",
|
||||
"Management Role",
|
||||
"Risk Management Process",
|
||||
"Third-Party Risk",
|
||||
"Incident Disclosure",
|
||||
"Strategy Integration",
|
||||
"None/Other",
|
||||
] as const;
|
||||
|
||||
const SPECIFICITY_LABELS = [
|
||||
"Generic Boilerplate",
|
||||
"Sector-Adapted",
|
||||
"Firm-Specific",
|
||||
"Quantified-Verifiable",
|
||||
] as const;
|
||||
|
||||
interface ParagraphData {
|
||||
id: string;
|
||||
text: string;
|
||||
wordCount: number;
|
||||
paragraphIndex: number;
|
||||
companyName: string;
|
||||
ticker: string | null;
|
||||
filingType: string;
|
||||
filingDate: string;
|
||||
secItem: string;
|
||||
}
|
||||
|
||||
interface ProgressData {
|
||||
completed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export default function LabelPage() {
|
||||
const router = useRouter();
|
||||
const [paragraph, setParagraph] = useState<ParagraphData | null>(null);
|
||||
const [progress, setProgress] = useState<ProgressData>({ completed: 0, total: 0 });
|
||||
const [category, setCategory] = useState<string>("");
|
||||
const [specificity, setSpecificity] = useState<number>(0);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const timerStart = useRef<number>(Date.now());
|
||||
const sessionId = useRef<string>(crypto.randomUUID());
|
||||
const notesRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const fetchNext = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/label");
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed to load");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.redirectToWarmup) {
|
||||
router.push("/label/warmup");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.done) {
|
||||
setDone(true);
|
||||
setProgress(data.progress);
|
||||
return;
|
||||
}
|
||||
|
||||
setParagraph(data.paragraph);
|
||||
setProgress(data.progress);
|
||||
setCategory("");
|
||||
setSpecificity(0);
|
||||
setNotes("");
|
||||
timerStart.current = Date.now();
|
||||
} catch {
|
||||
setError("Network error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNext();
|
||||
}, [fetchNext]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!category || !specificity || !paragraph || submitting) return;
|
||||
|
||||
const durationMs = Date.now() - timerStart.current;
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/label", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
paragraphId: paragraph.id,
|
||||
contentCategory: category,
|
||||
specificityLevel: specificity,
|
||||
notes: notes.trim() || undefined,
|
||||
sessionId: sessionId.current,
|
||||
durationMs,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed to submit");
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(data.progress);
|
||||
// Immediately fetch next paragraph
|
||||
await fetchNext();
|
||||
} catch {
|
||||
setError("Network error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [category, specificity, paragraph, notes, submitting, fetchNext]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLTextAreaElement) return;
|
||||
if (done || loading) return;
|
||||
|
||||
if (e.shiftKey && e.key >= "1" && e.key <= "4") {
|
||||
e.preventDefault();
|
||||
setSpecificity(parseInt(e.key));
|
||||
} else if (!e.shiftKey && e.key >= "1" && e.key <= "7") {
|
||||
e.preventDefault();
|
||||
setCategory(CATEGORIES[parseInt(e.key) - 1]);
|
||||
} else if (e.key === "Enter" && category && specificity) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === "n" || e.key === "N") {
|
||||
// Focus notes field
|
||||
e.preventDefault();
|
||||
notesRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [category, specificity, done, loading, handleSubmit]);
|
||||
|
||||
if (loading && !paragraph) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-background">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-background">
|
||||
<Card className="w-full max-w-md text-center">
|
||||
<CardHeader>
|
||||
<CheckCircle2 className="mx-auto mb-2 size-12 text-green-600" />
|
||||
<CardTitle>All Done!</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
You have labeled all {progress.total} assigned paragraphs. Thank you!
|
||||
</p>
|
||||
<Button
|
||||
className="mt-6"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!paragraph) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col bg-background">
|
||||
{/* Header bar */}
|
||||
<header className="border-b bg-card px-6 py-3">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<span className="truncate font-medium">
|
||||
{paragraph.companyName}
|
||||
{paragraph.ticker ? ` (${paragraph.ticker})` : ""}
|
||||
</span>
|
||||
<Badge variant="outline">{paragraph.filingType}</Badge>
|
||||
<Badge variant="outline">{paragraph.filingDate}</Badge>
|
||||
<Badge variant="outline">{paragraph.secItem}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Progress
|
||||
value={progress.completed}
|
||||
max={progress.total}
|
||||
className="w-40"
|
||||
>
|
||||
<ProgressLabel>Progress</ProgressLabel>
|
||||
<ProgressValue>
|
||||
{() => `${progress.completed} / ${progress.total}`}
|
||||
</ProgressValue>
|
||||
</Progress>
|
||||
<CodebookSidebar />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 p-6">
|
||||
{/* Paragraph display */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="rounded-lg bg-amber-50 p-6 text-base leading-relaxed dark:bg-amber-950/30">
|
||||
{paragraph.text}
|
||||
</div>
|
||||
<p className="mt-2 text-right text-xs text-muted-foreground">
|
||||
{paragraph.wordCount} words
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Labeling form */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Content Category */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Content Category</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup value={category} onValueChange={setCategory}>
|
||||
{CATEGORIES.map((cat, i) => (
|
||||
<div key={cat} className="flex items-center gap-3">
|
||||
<RadioGroupItem value={cat} id={`cat-${i}`} />
|
||||
<Label
|
||||
htmlFor={`cat-${i}`}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
{cat}{" "}
|
||||
<kbd className="ml-1 rounded border bg-muted px-1 py-0.5 text-xs text-muted-foreground">
|
||||
{i + 1}
|
||||
</kbd>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Specificity Level */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Specificity Level</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup
|
||||
value={specificity ? String(specificity) : ""}
|
||||
onValueChange={(v: string) => setSpecificity(parseInt(v))}
|
||||
>
|
||||
{SPECIFICITY_LABELS.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-3">
|
||||
<RadioGroupItem
|
||||
value={String(i + 1)}
|
||||
id={`spec-${i}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`spec-${i}`}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
{i + 1}. {label}{" "}
|
||||
<kbd className="ml-1 rounded border bg-muted px-1 py-0.5 text-xs text-muted-foreground">
|
||||
Shift+{i + 1}
|
||||
</kbd>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<Label htmlFor="notes" className="mb-2 text-sm">
|
||||
Notes (optional){" "}
|
||||
<kbd className="ml-1 rounded border bg-muted px-1 py-0.5 text-xs text-muted-foreground">
|
||||
N
|
||||
</kbd>
|
||||
</Label>
|
||||
<Textarea
|
||||
ref={notesRef}
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Any observations, edge cases, or uncertainty..."
|
||||
rows={2}
|
||||
className="mt-2 resize-none"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!category || !specificity || submitting}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{submitting ? "Submitting..." : "Submit"}{" "}
|
||||
<kbd className="ml-2 rounded border bg-primary-foreground/20 px-1.5 py-0.5 text-xs">
|
||||
Enter
|
||||
</kbd>
|
||||
</Button>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodebookSidebar() {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger>
|
||||
<Button variant="outline" size="sm">
|
||||
<BookOpen className="mr-1.5 size-4" />
|
||||
Codebook
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[420px] sm:max-w-[420px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Labeling Codebook</SheetTitle>
|
||||
<SheetDescription>Quick reference for categories and specificity levels</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 px-4 pb-4">
|
||||
<div className="space-y-6">
|
||||
{/* Categories */}
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Content Categories
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<CategoryDef
|
||||
num={1}
|
||||
name="Board Governance"
|
||||
desc="Board/committee oversight of cyber risk. The board or a board committee is the grammatical subject performing the primary action."
|
||||
examples="Board receives reports, Audit Committee oversees, directors review"
|
||||
notList="CISO reports TO board (Management Role), board mentioned only in passing"
|
||||
/>
|
||||
<CategoryDef
|
||||
num={2}
|
||||
name="Management Role"
|
||||
desc="Named officers or management teams responsible for cybersecurity. A specific person or management function is the grammatical subject."
|
||||
examples="CISO leads program, VP of Security manages, management team implements"
|
||||
notList="Board delegates to CISO (Board Governance if board is subject)"
|
||||
/>
|
||||
<CategoryDef
|
||||
num={3}
|
||||
name="Risk Management Process"
|
||||
desc="Internal cybersecurity program mechanics: frameworks, assessments, controls, training, monitoring."
|
||||
examples="We maintain a program, NIST framework, penetration testing, employee training"
|
||||
notList="Vendor assessments (Third-Party Risk), incident response actions (Incident Disclosure)"
|
||||
/>
|
||||
<CategoryDef
|
||||
num={4}
|
||||
name="Third-Party Risk"
|
||||
desc="Oversight of external parties' cybersecurity: vendors, suppliers, service providers, contractors."
|
||||
examples="Vendor assessments, third-party audits, supply chain monitoring, SOC 2 requirements"
|
||||
notList="Internal program using third-party tools (Risk Management Process)"
|
||||
/>
|
||||
<CategoryDef
|
||||
num={5}
|
||||
name="Incident Disclosure"
|
||||
desc="Description of what happened in a cybersecurity incident: timeline, impact, response, remediation."
|
||||
examples="We detected unauthorized access, breach affected N records, incident response activated"
|
||||
notList="Generic incident response plans (Risk Management Process), hypothetical incidents"
|
||||
/>
|
||||
<CategoryDef
|
||||
num={6}
|
||||
name="Strategy Integration"
|
||||
desc="Business/financial consequences of cyber risk: budget, insurance, M&A due diligence, competitive impact, regulatory strategy."
|
||||
examples="Cyber budget increased, insurance coverage, risk factors, financial impact"
|
||||
notList="Technical program costs (Risk Management Process)"
|
||||
/>
|
||||
<CategoryDef
|
||||
num={7}
|
||||
name="None/Other"
|
||||
desc="Does not fit any of the above categories. Generic corporate language, non-cyber content, or ambiguous."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Specificity */}
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Specificity Levels
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<SpecDef
|
||||
level={1}
|
||||
name="Generic Boilerplate"
|
||||
desc="Could appear in any company's filing unchanged. No named frameworks, tools, people, dates, or quantities."
|
||||
/>
|
||||
<SpecDef
|
||||
level={2}
|
||||
name="Sector-Adapted"
|
||||
desc="Names a recognized standard or sector practice (e.g., NIST, SOC 2, PCI DSS) but nothing firm-specific."
|
||||
/>
|
||||
<SpecDef
|
||||
level={3}
|
||||
name="Firm-Specific"
|
||||
desc="Contains details unique to this company: named personnel, specific org structure, named tools/vendors, described processes."
|
||||
/>
|
||||
<SpecDef
|
||||
level={4}
|
||||
name="Quantified-Verifiable"
|
||||
desc="Contains 2+ hard verifiable facts: specific dates, dollar amounts, percentages, headcounts, named third parties with specifics."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Decision rules */}
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Key Decision Rules
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<Rule title="Board vs Management">
|
||||
Who is the grammatical subject? Board/committee = Board
|
||||
Governance. Named officer/team = Management Role.
|
||||
</Rule>
|
||||
<Rule title="Person vs Function">
|
||||
"Our CISO, Jane Smith" = named person (Firm-Specific). "Our
|
||||
CISO" alone = function reference (could be Generic).
|
||||
</Rule>
|
||||
<Rule title="QV threshold">
|
||||
Need 2+ independently verifiable facts (dates, dollar amounts,
|
||||
named firms, headcounts) for Specificity 4.
|
||||
</Rule>
|
||||
<Rule title="Materiality disclaimers">
|
||||
Boilerplate "no material impact" language = typically
|
||||
None/Other or Specificity 1 unless containing specific
|
||||
incident details.
|
||||
</Rule>
|
||||
<Rule title="Dual-topic paragraphs">
|
||||
Choose the category whose content occupies the majority of the
|
||||
paragraph. If truly 50/50, prefer the more specific category.
|
||||
</Rule>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryDef({
|
||||
num,
|
||||
name,
|
||||
desc,
|
||||
examples,
|
||||
notList,
|
||||
}: {
|
||||
num: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
examples?: string;
|
||||
notList?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{num}
|
||||
</Badge>
|
||||
<span className="font-medium text-sm">{name}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground leading-relaxed">{desc}</p>
|
||||
{examples && (
|
||||
<p className="mt-1 text-xs">
|
||||
<span className="font-medium text-green-700 dark:text-green-400">IS: </span>
|
||||
{examples}
|
||||
</p>
|
||||
)}
|
||||
{notList && (
|
||||
<p className="mt-0.5 text-xs">
|
||||
<span className="font-medium text-red-700 dark:text-red-400">NOT: </span>
|
||||
{notList}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecDef({
|
||||
level,
|
||||
name,
|
||||
desc,
|
||||
}: {
|
||||
level: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{level}
|
||||
</Badge>
|
||||
<span className="font-medium text-sm">{name}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground leading-relaxed">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Rule({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/50 p-2.5">
|
||||
<span className="font-medium">{title}: </span>
|
||||
<span className="text-muted-foreground">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
375
labelapp/app/label/warmup/page.tsx
Normal file
375
labelapp/app/label/warmup/page.tsx
Normal file
@ -0,0 +1,375 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
const CATEGORIES = [
|
||||
"Board Governance",
|
||||
"Management Role",
|
||||
"Risk Management Process",
|
||||
"Third-Party Risk",
|
||||
"Incident Disclosure",
|
||||
"Strategy Integration",
|
||||
"None/Other",
|
||||
] as const;
|
||||
|
||||
const SPECIFICITY_LABELS = [
|
||||
"Generic Boilerplate",
|
||||
"Sector-Adapted",
|
||||
"Firm-Specific",
|
||||
"Quantified-Verifiable",
|
||||
] as const;
|
||||
|
||||
interface WarmupParagraph {
|
||||
id: string;
|
||||
text: string;
|
||||
index: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface FeedbackData {
|
||||
categoryCorrect: boolean;
|
||||
specificityCorrect: boolean;
|
||||
goldCategory: string;
|
||||
goldSpecificity: number;
|
||||
explanation: string;
|
||||
warmupCompleted: number;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export default function WarmupPage() {
|
||||
const router = useRouter();
|
||||
const [paragraph, setParagraph] = useState<WarmupParagraph | null>(null);
|
||||
const [warmupCompleted, setWarmupCompleted] = useState(0);
|
||||
const [category, setCategory] = useState<string>("");
|
||||
const [specificity, setSpecificity] = useState<number>(0);
|
||||
const [feedback, setFeedback] = useState<FeedbackData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchNext = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/warmup");
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed to load warmup");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.done) {
|
||||
router.push("/label");
|
||||
return;
|
||||
}
|
||||
|
||||
setParagraph(data.paragraph);
|
||||
setWarmupCompleted(data.warmupCompleted);
|
||||
setCategory("");
|
||||
setSpecificity(0);
|
||||
setFeedback(null);
|
||||
} catch {
|
||||
setError("Network error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNext();
|
||||
}, [fetchNext]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!category || !specificity || !paragraph || feedback || submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/warmup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
category,
|
||||
specificity,
|
||||
warmupIndex: paragraph.index,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed to submit");
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(data);
|
||||
setWarmupCompleted(data.warmupCompleted);
|
||||
} catch {
|
||||
setError("Network error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [category, specificity, paragraph, feedback, submitting]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLTextAreaElement) return;
|
||||
|
||||
if (feedback) {
|
||||
// After feedback, Enter or Space advances to next
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (feedback.done) {
|
||||
router.push("/label");
|
||||
} else {
|
||||
fetchNext();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey && e.key >= "1" && e.key <= "4") {
|
||||
e.preventDefault();
|
||||
setSpecificity(parseInt(e.key));
|
||||
} else if (!e.shiftKey && e.key >= "1" && e.key <= "7") {
|
||||
e.preventDefault();
|
||||
setCategory(CATEGORIES[parseInt(e.key) - 1]);
|
||||
} else if (e.key === "Enter" && category && specificity) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [category, specificity, feedback, handleSubmit, fetchNext, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">Loading warmup...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-background">
|
||||
<Alert variant="destructive" className="max-w-md">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!paragraph) return null;
|
||||
|
||||
const allCorrect = feedback?.categoryCorrect && feedback?.specificityCorrect;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-white px-6 py-3 dark:bg-zinc-950">
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Warm-up Practice</h1>
|
||||
<Progress
|
||||
value={warmupCompleted}
|
||||
max={paragraph.total}
|
||||
className="w-48"
|
||||
>
|
||||
<ProgressLabel>Progress</ProgressLabel>
|
||||
<ProgressValue>
|
||||
{() => `${warmupCompleted} / ${paragraph.total}`}
|
||||
</ProgressValue>
|
||||
</Progress>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 p-6">
|
||||
{/* Paragraph display */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-muted-foreground">
|
||||
Warm-up {warmupCompleted + (feedback ? 0 : 1)} of{" "}
|
||||
{paragraph.total}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg bg-amber-50 p-6 text-base leading-relaxed dark:bg-amber-950/30">
|
||||
{paragraph.text}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Labeling form */}
|
||||
<div ref={formRef} className="grid gap-6 md:grid-cols-2">
|
||||
{/* Content Category */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Content Category</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup
|
||||
value={category}
|
||||
onValueChange={feedback ? undefined : setCategory}
|
||||
>
|
||||
{CATEGORIES.map((cat, i) => (
|
||||
<div key={cat} className="flex items-center gap-3">
|
||||
<RadioGroupItem
|
||||
value={cat}
|
||||
id={`cat-${i}`}
|
||||
disabled={!!feedback}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`cat-${i}`}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
{cat}{" "}
|
||||
<kbd className="ml-1 rounded border bg-muted px-1 py-0.5 text-xs text-muted-foreground">
|
||||
{i + 1}
|
||||
</kbd>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Specificity Level */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Specificity Level</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup
|
||||
value={specificity ? String(specificity) : ""}
|
||||
onValueChange={
|
||||
feedback
|
||||
? undefined
|
||||
: (v: string) => setSpecificity(parseInt(v))
|
||||
}
|
||||
>
|
||||
{SPECIFICITY_LABELS.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-3">
|
||||
<RadioGroupItem
|
||||
value={String(i + 1)}
|
||||
id={`spec-${i}`}
|
||||
disabled={!!feedback}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`spec-${i}`}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
{i + 1}. {label}{" "}
|
||||
<kbd className="ml-1 rounded border bg-muted px-1 py-0.5 text-xs text-muted-foreground">
|
||||
Shift+{i + 1}
|
||||
</kbd>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Submit or feedback */}
|
||||
{!feedback ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!category || !specificity || submitting}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{submitting ? "Submitting..." : "Submit"}{" "}
|
||||
<kbd className="ml-2 rounded border bg-primary-foreground/20 px-1.5 py-0.5 text-xs">
|
||||
Enter
|
||||
</kbd>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Alert
|
||||
className={
|
||||
allCorrect
|
||||
? "border-green-300 bg-green-50 dark:border-green-800 dark:bg-green-950/30"
|
||||
: "border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950/30"
|
||||
}
|
||||
>
|
||||
{allCorrect ? (
|
||||
<CheckCircle2 className="text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<XCircle className="text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
<AlertTitle>
|
||||
{allCorrect
|
||||
? "Correct!"
|
||||
: feedback.categoryCorrect
|
||||
? "Specificity incorrect"
|
||||
: feedback.specificityCorrect
|
||||
? "Category incorrect"
|
||||
: "Both incorrect"}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-2 space-y-2">
|
||||
<p>
|
||||
<span className="font-medium">Gold category:</span>{" "}
|
||||
{feedback.goldCategory}
|
||||
{!feedback.categoryCorrect && (
|
||||
<span className="ml-2 text-red-600 dark:text-red-400">
|
||||
(you chose: {category})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Gold specificity:</span>{" "}
|
||||
{feedback.goldSpecificity} -{" "}
|
||||
{SPECIFICITY_LABELS[feedback.goldSpecificity - 1]}
|
||||
{!feedback.specificityCorrect && (
|
||||
<span className="ml-2 text-red-600 dark:text-red-400">
|
||||
(you chose: {specificity} -{" "}
|
||||
{SPECIFICITY_LABELS[specificity - 1]})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-relaxed">
|
||||
{feedback.explanation}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (feedback.done) {
|
||||
router.push("/label");
|
||||
} else {
|
||||
fetchNext();
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{feedback.done ? "Begin Labeling" : "Next"}{" "}
|
||||
<kbd className="ml-2 rounded border bg-primary-foreground/20 px-1.5 py-0.5 text-xs">
|
||||
Enter
|
||||
</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "SEC cyBERT Labeling Tool",
|
||||
description: "Human annotation tool for SEC cybersecurity disclosure quality",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@ -1,65 +1,116 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface Annotator {
|
||||
id: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [annotators, setAnnotators] = useState<Annotator[]>([]);
|
||||
const [selectedAnnotator, setSelectedAnnotator] = useState<string>("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth")
|
||||
.then((res) => res.json())
|
||||
.then((data: Annotator[]) => setAnnotators(data))
|
||||
.catch(() => setError("Failed to load annotators"));
|
||||
}, []);
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!selectedAnnotator) {
|
||||
setError("Please select an annotator");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
annotatorId: selectedAnnotator,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error || "Login failed");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
} catch {
|
||||
setError("Network error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">SEC cyBERT</CardTitle>
|
||||
<CardDescription>Labeling Tool</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleLogin}>
|
||||
<CardContent className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="annotator">Annotator</Label>
|
||||
<select
|
||||
id="annotator"
|
||||
value={selectedAnnotator}
|
||||
onChange={(e) => setSelectedAnnotator(e.target.value)}
|
||||
className="flex h-8 w-full items-center rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
<option value="">Select your name</option>
|
||||
{annotators.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
436
labelapp/app/quiz/page.tsx
Normal file
436
labelapp/app/quiz/page.tsx
Normal file
@ -0,0 +1,436 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface QuizOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface QuizQuestion {
|
||||
id: string;
|
||||
type: string;
|
||||
paragraphText: string;
|
||||
question: string;
|
||||
options: QuizOption[];
|
||||
correctAnswer: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
interface AnswerFeedback {
|
||||
correct: boolean;
|
||||
correctAnswer: string;
|
||||
explanation: string;
|
||||
score?: number;
|
||||
passed?: boolean;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
interface AnswerRecord {
|
||||
questionId: string;
|
||||
correct: boolean;
|
||||
selectedAnswer: string;
|
||||
correctAnswer: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
type QuizPhase = "loading" | "ready" | "active" | "feedback" | "results";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
"person-vs-function": "Person vs. Function",
|
||||
"materiality-disclaimer": "Materiality Disclaimer",
|
||||
"qv-counting": "QV Fact Counting",
|
||||
"spac-exception": "SPAC Exception",
|
||||
};
|
||||
|
||||
export default function QuizPage() {
|
||||
const router = useRouter();
|
||||
const [phase, setPhase] = useState<QuizPhase>("loading");
|
||||
const [questions, setQuestions] = useState<QuizQuestion[]>([]);
|
||||
const [quizSessionId, setQuizSessionId] = useState<string>("");
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [selectedAnswer, setSelectedAnswer] = useState<string>("");
|
||||
const [feedback, setFeedback] = useState<AnswerFeedback | null>(null);
|
||||
const [answers, setAnswers] = useState<AnswerRecord[]>([]);
|
||||
const [finalScore, setFinalScore] = useState<number>(0);
|
||||
const [finalPassed, setFinalPassed] = useState<boolean>(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function checkStatus() {
|
||||
const res = await fetch("/api/quiz");
|
||||
if (!res.ok) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.hasPassedQuiz) {
|
||||
router.push("/label");
|
||||
return;
|
||||
}
|
||||
setPhase("ready");
|
||||
}
|
||||
checkStatus();
|
||||
}, [router]);
|
||||
|
||||
const startQuiz = useCallback(async () => {
|
||||
const res = await fetch("/api/quiz", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "start" }),
|
||||
});
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
setQuizSessionId(data.quizSessionId);
|
||||
setQuestions(data.questions);
|
||||
setCurrentIndex(0);
|
||||
setSelectedAnswer("");
|
||||
setFeedback(null);
|
||||
setAnswers([]);
|
||||
setPhase("active");
|
||||
}, []);
|
||||
|
||||
const submitAnswer = useCallback(async () => {
|
||||
if (!selectedAnswer || submitting) return;
|
||||
setSubmitting(true);
|
||||
|
||||
const question = questions[currentIndex];
|
||||
|
||||
const res = await fetch("/api/quiz", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "answer",
|
||||
quizSessionId,
|
||||
questionId: question.id,
|
||||
answer: selectedAnswer,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: AnswerFeedback = await res.json();
|
||||
setFeedback(data);
|
||||
|
||||
setAnswers((prev) => [
|
||||
...prev,
|
||||
{
|
||||
questionId: question.id,
|
||||
correct: data.correct,
|
||||
selectedAnswer,
|
||||
correctAnswer: data.correctAnswer,
|
||||
explanation: data.explanation,
|
||||
},
|
||||
]);
|
||||
|
||||
if (data.completed) {
|
||||
setFinalScore(data.score!);
|
||||
setFinalPassed(data.passed!);
|
||||
}
|
||||
|
||||
setPhase("feedback");
|
||||
setSubmitting(false);
|
||||
}, [selectedAnswer, submitting, questions, currentIndex, quizSessionId]);
|
||||
|
||||
const nextQuestion = useCallback(() => {
|
||||
if (feedback?.completed) {
|
||||
setPhase("results");
|
||||
return;
|
||||
}
|
||||
setCurrentIndex((prev) => prev + 1);
|
||||
setSelectedAnswer("");
|
||||
setFeedback(null);
|
||||
setPhase("active");
|
||||
}, [feedback]);
|
||||
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">Checking quiz status...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "ready") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Labeling Quiz</CardTitle>
|
||||
<CardDescription>
|
||||
Before you begin labeling, you must pass a short quiz testing your
|
||||
knowledge of the labeling codebook. You need at least 7 out of 8
|
||||
correct answers to pass.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>The quiz covers four question types:</p>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>
|
||||
<span className="font-medium text-foreground">
|
||||
Person vs. Function
|
||||
</span>{" "}
|
||||
— Distinguishing management role descriptions from risk
|
||||
management process descriptions
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">
|
||||
Materiality Disclaimers
|
||||
</span>{" "}
|
||||
— Identifying materiality assessments vs. cross-references
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">
|
||||
QV Fact Counting
|
||||
</span>{" "}
|
||||
— Determining specificity levels based on verifiable facts
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">
|
||||
SPAC Exceptions
|
||||
</span>{" "}
|
||||
— Recognizing shell company / SPAC disclosures
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button onClick={startQuiz} className="w-full" size="lg">
|
||||
Start Quiz
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "active" || phase === "feedback") {
|
||||
const question = questions[currentIndex];
|
||||
const progress = ((currentIndex + (phase === "feedback" ? 1 : 0)) / questions.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">
|
||||
Question {currentIndex + 1} of {questions.length}
|
||||
</CardTitle>
|
||||
<Badge variant="outline">
|
||||
{TYPE_LABELS[question.type] ?? question.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress value={progress} className="mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="rounded-md border bg-muted/50 p-4">
|
||||
<p className="text-sm leading-relaxed font-serif">
|
||||
{question.paragraphText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="font-medium">{question.question}</p>
|
||||
|
||||
<RadioGroup
|
||||
value={selectedAnswer}
|
||||
onValueChange={phase === "active" ? setSelectedAnswer : undefined}
|
||||
disabled={phase === "feedback"}
|
||||
className="space-y-3"
|
||||
>
|
||||
{question.options.map((option) => {
|
||||
let optionClass = "";
|
||||
if (phase === "feedback" && feedback) {
|
||||
if (option.value === feedback.correctAnswer) {
|
||||
optionClass =
|
||||
"border-green-500 bg-green-50 dark:bg-green-950/30";
|
||||
} else if (
|
||||
option.value === selectedAnswer &&
|
||||
!feedback.correct
|
||||
) {
|
||||
optionClass = "border-red-500 bg-red-50 dark:bg-red-950/30";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`flex items-center space-x-3 rounded-md border p-3 transition-colors ${optionClass}`}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.value}
|
||||
id={`option-${option.value}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`option-${option.value}`}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
|
||||
{phase === "feedback" && feedback && (
|
||||
<Alert variant={feedback.correct ? "default" : "destructive"}>
|
||||
<AlertTitle>
|
||||
{feedback.correct ? "Correct!" : "Incorrect"}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-2 text-sm leading-relaxed">
|
||||
{feedback.explanation}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{phase === "active" && (
|
||||
<Button
|
||||
onClick={submitAnswer}
|
||||
disabled={!selectedAnswer || submitting}
|
||||
className="w-full"
|
||||
>
|
||||
{submitting ? "Submitting..." : "Submit Answer"}
|
||||
</Button>
|
||||
)}
|
||||
{phase === "feedback" && (
|
||||
<Button onClick={nextQuestion} className="w-full">
|
||||
{feedback?.completed ? "See Results" : "Next Question"}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "results") {
|
||||
const incorrectAnswers = answers.filter((a) => !a.correct);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Quiz Results</CardTitle>
|
||||
<CardDescription>
|
||||
You scored{" "}
|
||||
<span className="font-bold text-foreground">
|
||||
{finalScore}/{questions.length}
|
||||
</span>{" "}
|
||||
—{" "}
|
||||
<span
|
||||
className={
|
||||
finalPassed
|
||||
? "font-bold text-green-600 dark:text-green-400"
|
||||
: "font-bold text-red-600 dark:text-red-400"
|
||||
}
|
||||
>
|
||||
{finalPassed ? "Passed!" : "Failed"}
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{finalPassed ? (
|
||||
<Alert>
|
||||
<AlertTitle>Great work!</AlertTitle>
|
||||
<AlertDescription>
|
||||
You demonstrated strong knowledge of the labeling codebook.
|
||||
You may now proceed to labeling.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Not quite there</AlertTitle>
|
||||
<AlertDescription>
|
||||
You need at least 7 out of 8 correct to pass. Review the
|
||||
explanations below and try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{incorrectAnswers.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium text-sm">
|
||||
Questions you got wrong:
|
||||
</p>
|
||||
{incorrectAnswers.map((a) => {
|
||||
const question = questions.find(
|
||||
(q) => q.id === a.questionId,
|
||||
);
|
||||
if (!question) return null;
|
||||
return (
|
||||
<div
|
||||
key={a.questionId}
|
||||
className="rounded-md border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{TYPE_LABELS[question.type] ?? question.type}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-serif">
|
||||
{question.paragraphText.slice(0, 150)}
|
||||
{question.paragraphText.length > 150 ? "..." : ""}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
Your answer: {a.selectedAnswer}
|
||||
</span>{" "}
|
||||
—{" "}
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
Correct: {a.correctAnswer}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{a.explanation}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{finalPassed ? (
|
||||
<Button
|
||||
onClick={() => router.push("/label")}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
Continue to Labeling
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={startQuiz} className="w-full" size="lg">
|
||||
Review & Retry
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
3
labelapp/bunfig.toml
Normal file
3
labelapp/bunfig.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[test]
|
||||
# Exclude Playwright E2E tests (they use their own runner)
|
||||
exclude = ["tests/**", "node_modules/**"]
|
||||
@ -5,7 +5,7 @@
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
|
||||
76
labelapp/components/ui/alert.tsx
Normal file
76
labelapp/components/ui/alert.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
52
labelapp/components/ui/badge.tsx
Normal file
52
labelapp/components/ui/badge.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
103
labelapp/components/ui/card.tsx
Normal file
103
labelapp/components/ui/card.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
21
labelapp/components/ui/collapsible.tsx
Normal file
21
labelapp/components/ui/collapsible.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
160
labelapp/components/ui/dialog.tsx
Normal file
160
labelapp/components/ui/dialog.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
20
labelapp/components/ui/input.tsx
Normal file
20
labelapp/components/ui/input.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
20
labelapp/components/ui/label.tsx
Normal file
20
labelapp/components/ui/label.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
83
labelapp/components/ui/progress.tsx
Normal file
83
labelapp/components/ui/progress.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
children,
|
||||
value,
|
||||
...props
|
||||
}: ProgressPrimitive.Root.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Track
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-track"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressIndicator({
|
||||
className,
|
||||
...props
|
||||
}: ProgressPrimitive.Indicator.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn("h-full bg-primary transition-all", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Label
|
||||
className={cn("text-sm font-medium", className)}
|
||||
data-slot="progress-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Value
|
||||
className={cn(
|
||||
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-value"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Progress,
|
||||
ProgressTrack,
|
||||
ProgressIndicator,
|
||||
ProgressLabel,
|
||||
ProgressValue,
|
||||
}
|
||||
38
labelapp/components/ui/radio-group.tsx
Normal file
38
labelapp/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
|
||||
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
|
||||
return (
|
||||
<RadioGroupPrimitive
|
||||
data-slot="radio-group"
|
||||
className={cn("grid w-full gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
|
||||
return (
|
||||
<RadioPrimitive.Root
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="flex size-4 items-center justify-center"
|
||||
>
|
||||
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
|
||||
</RadioPrimitive.Indicator>
|
||||
</RadioPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
55
labelapp/components/ui/scroll-area.tsx
Normal file
55
labelapp/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
201
labelapp/components/ui/select.tsx
Normal file
201
labelapp/components/ui/select.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
25
labelapp/components/ui/separator.tsx
Normal file
25
labelapp/components/ui/separator.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
138
labelapp/components/ui/sheet.tsx
Normal file
138
labelapp/components/ui/sheet.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
723
labelapp/components/ui/sidebar.tsx
Normal file
723
labelapp/components/ui/sidebar.tsx
Normal file
@ -0,0 +1,723 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-label",
|
||||
sidebar: "group-label",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-action",
|
||||
sidebar: "group-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
render,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const { isMobile, state } = useSidebar()
|
||||
const comp = useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
||||
state: {
|
||||
slot: "sidebar-menu-button",
|
||||
sidebar: "menu-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
|
||||
if (!tooltip) {
|
||||
return comp
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
render,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-action",
|
||||
sidebar: "menu-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
render,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"a"> &
|
||||
React.ComponentProps<"a"> & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-sub-button",
|
||||
sidebar: "menu-sub-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
13
labelapp/components/ui/skeleton.tsx
Normal file
13
labelapp/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
116
labelapp/components/ui/table.tsx
Normal file
116
labelapp/components/ui/table.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
82
labelapp/components/ui/tabs.tsx
Normal file
82
labelapp/components/ui/tabs.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
labelapp/components/ui/textarea.tsx
Normal file
18
labelapp/components/ui/textarea.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
66
labelapp/components/ui/tooltip.tsx
Normal file
66
labelapp/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
19
labelapp/hooks/use-mobile.ts
Normal file
19
labelapp/hooks/use-mobile.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
173
labelapp/lib/__test__/metrics.test.ts
Normal file
173
labelapp/lib/__test__/metrics.test.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
cohensKappa,
|
||||
krippendorffsAlpha,
|
||||
agreementRate,
|
||||
confusionMatrix,
|
||||
perCategoryAgreement,
|
||||
} from "../metrics";
|
||||
|
||||
describe("cohensKappa", () => {
|
||||
test("perfect agreement returns 1", () => {
|
||||
const r1 = ["A", "B", "A", "B"];
|
||||
const r2 = ["A", "B", "A", "B"];
|
||||
expect(cohensKappa(r1, r2)).toBeCloseTo(1.0);
|
||||
});
|
||||
|
||||
test("known example", () => {
|
||||
// From Wikipedia Cohen's kappa example
|
||||
const r1 = [
|
||||
"Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "Yes",
|
||||
"No", "No", "No", "No", "No", "No", "No", "No", "No", "No",
|
||||
];
|
||||
const r2 = [
|
||||
"Yes", "Yes", "Yes", "Yes", "Yes", "Yes", "No", "No", "No", "No",
|
||||
"Yes", "Yes", "No", "No", "No", "No", "No", "No", "No", "No",
|
||||
];
|
||||
// p_o = 14/20 = 0.7
|
||||
// p_e = (10/20 * 8/20) + (10/20 * 12/20) = 0.2 + 0.3 = 0.5
|
||||
// kappa = (0.7 - 0.5) / (1 - 0.5) = 0.4
|
||||
expect(cohensKappa(r1, r2)).toBeCloseTo(0.4);
|
||||
});
|
||||
|
||||
test("complete disagreement returns negative kappa", () => {
|
||||
const r1 = ["A", "A", "B", "B"];
|
||||
const r2 = ["B", "B", "A", "A"];
|
||||
// p_o = 0, p_e = (2/4 * 2/4) + (2/4 * 2/4) = 0.5
|
||||
// kappa = (0 - 0.5) / (1 - 0.5) = -1
|
||||
expect(cohensKappa(r1, r2)).toBeCloseTo(-1.0);
|
||||
});
|
||||
|
||||
test("throws on mismatched lengths", () => {
|
||||
expect(() => cohensKappa(["A"], ["A", "B"])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("krippendorffsAlpha", () => {
|
||||
test("perfect agreement returns 1", () => {
|
||||
const ratings = [
|
||||
[1, 2, 3, 4],
|
||||
[1, 2, 3, 4],
|
||||
[1, 2, 3, 4],
|
||||
];
|
||||
expect(krippendorffsAlpha(ratings)).toBeCloseTo(1.0);
|
||||
});
|
||||
|
||||
test("handles missing data", () => {
|
||||
// 3 raters, 4 items, some missing
|
||||
const ratings = [
|
||||
[1, 2, null, 4],
|
||||
[1, 2, 3, 4],
|
||||
[1, null, 3, 4],
|
||||
];
|
||||
// Should still compute with available pairs
|
||||
const alpha = krippendorffsAlpha(ratings);
|
||||
expect(alpha).toBeGreaterThan(0.5);
|
||||
expect(alpha).toBeLessThanOrEqual(1.0);
|
||||
});
|
||||
|
||||
test("random ratings return alpha near 0", () => {
|
||||
// Create ratings that are essentially random
|
||||
const ratings = [
|
||||
[1, 2, 3, 4, 1, 2, 3, 4, 1, 2],
|
||||
[4, 3, 2, 1, 4, 3, 2, 1, 4, 3],
|
||||
[2, 4, 1, 3, 2, 4, 1, 3, 2, 4],
|
||||
];
|
||||
const alpha = krippendorffsAlpha(ratings);
|
||||
// Random ratings should produce alpha close to 0 or negative
|
||||
expect(alpha).toBeLessThan(0.3);
|
||||
});
|
||||
|
||||
test("all same value returns 1", () => {
|
||||
const ratings = [
|
||||
[3, 3, 3, 3],
|
||||
[3, 3, 3, 3],
|
||||
];
|
||||
expect(krippendorffsAlpha(ratings)).toBeCloseTo(1.0);
|
||||
});
|
||||
|
||||
test("throws with fewer than 2 raters", () => {
|
||||
expect(() => krippendorffsAlpha([[1, 2, 3]])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("confusionMatrix", () => {
|
||||
test("produces correct counts", () => {
|
||||
const actual = ["A", "A", "B", "B", "C"];
|
||||
const predicted = ["A", "B", "B", "C", "C"];
|
||||
const labels = ["A", "B", "C"];
|
||||
const matrix = confusionMatrix(actual, predicted, labels);
|
||||
|
||||
// Row=actual, Col=predicted
|
||||
// A: predicted A=1, predicted B=1, predicted C=0
|
||||
// B: predicted A=0, predicted B=1, predicted C=1
|
||||
// C: predicted A=0, predicted B=0, predicted C=1
|
||||
expect(matrix).toEqual([
|
||||
[1, 1, 0],
|
||||
[0, 1, 1],
|
||||
[0, 0, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
test("perfect prediction has diagonal only", () => {
|
||||
const labels = ["X", "Y"];
|
||||
const vals = ["X", "Y", "X", "Y"];
|
||||
const matrix = confusionMatrix(vals, vals, labels);
|
||||
expect(matrix).toEqual([
|
||||
[2, 0],
|
||||
[0, 2],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agreementRate", () => {
|
||||
test("all agree", () => {
|
||||
expect(
|
||||
agreementRate([
|
||||
["A", "A", "A"],
|
||||
["B", "B", "B"],
|
||||
]),
|
||||
).toBeCloseTo(1.0);
|
||||
});
|
||||
|
||||
test("none agree", () => {
|
||||
expect(agreementRate([["A", "B", "C"]])).toBeCloseTo(0.0);
|
||||
});
|
||||
|
||||
test("partial agreement", () => {
|
||||
expect(
|
||||
agreementRate([
|
||||
["A", "A", "A"],
|
||||
["A", "B", "A"],
|
||||
]),
|
||||
).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
test("empty input", () => {
|
||||
expect(agreementRate([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("perCategoryAgreement", () => {
|
||||
test("computes per-category rates", () => {
|
||||
const labels = [
|
||||
{ category: "A", annotatorId: "r1", paragraphId: "p1" },
|
||||
{ category: "A", annotatorId: "r2", paragraphId: "p1" },
|
||||
{ category: "A", annotatorId: "r1", paragraphId: "p2" },
|
||||
{ category: "B", annotatorId: "r2", paragraphId: "p2" },
|
||||
{ category: "B", annotatorId: "r1", paragraphId: "p3" },
|
||||
{ category: "B", annotatorId: "r2", paragraphId: "p3" },
|
||||
];
|
||||
const result = perCategoryAgreement(labels, ["A", "B"]);
|
||||
|
||||
// Category A: p1 (both A = agree), p2 (A vs B = disagree) => 1/2 = 0.5
|
||||
// Category B: p2 (A vs B = disagree), p3 (both B = agree) => 1/2 = 0.5
|
||||
expect(result["A"]).toBeCloseTo(0.5);
|
||||
expect(result["B"]).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
test("category with no ratings returns 0", () => {
|
||||
const result = perCategoryAgreement([], ["A"]);
|
||||
expect(result["A"]).toBe(0);
|
||||
});
|
||||
});
|
||||
125
labelapp/lib/assignment.ts
Normal file
125
labelapp/lib/assignment.ts
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Generate all C(n, k) combinations of elements from `arr`.
|
||||
*/
|
||||
function combinations<T>(arr: T[], k: number): T[][] {
|
||||
if (k === 0) return [[]];
|
||||
if (k > arr.length) return [];
|
||||
const results: T[][] = [];
|
||||
for (let i = 0; i <= arr.length - k; i++) {
|
||||
const rest = combinations(arr.slice(i + 1), k - 1);
|
||||
for (const combo of rest) {
|
||||
results.push([arr[i], ...combo]);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle an array in place using Fisher-Yates.
|
||||
*/
|
||||
function shuffle<T>(arr: T[]): T[] {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
export interface Assignment {
|
||||
paragraphId: string;
|
||||
annotatorId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate BIBD assignments: each paragraph gets exactly `perParagraph` annotators,
|
||||
* distributed evenly across all C(n, perParagraph) annotator triples.
|
||||
*
|
||||
* With 6 annotators and perParagraph=3:
|
||||
* - C(6,3) = 20 unique triples
|
||||
* - Each triple gets floor(1200/20) = 60 paragraphs
|
||||
* - Each annotator appears in C(5,2) = 10 triples -> 600 paragraphs each
|
||||
*/
|
||||
export function generateAssignments(
|
||||
paragraphIds: string[],
|
||||
annotatorIds: string[],
|
||||
perParagraph: number,
|
||||
): Assignment[] {
|
||||
const triples = combinations(annotatorIds, perParagraph);
|
||||
const shuffled = shuffle([...paragraphIds]);
|
||||
const perTriple = Math.floor(shuffled.length / triples.length);
|
||||
const remainder = shuffled.length % triples.length;
|
||||
|
||||
const assignments: Assignment[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (let t = 0; t < triples.length; t++) {
|
||||
// Distribute remainder paragraphs to the first `remainder` triples
|
||||
const count = perTriple + (t < remainder ? 1 : 0);
|
||||
const triple = triples[t];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const paragraphId = shuffled[offset + i];
|
||||
for (const annotatorId of triple) {
|
||||
assignments.push({ paragraphId, annotatorId });
|
||||
}
|
||||
}
|
||||
|
||||
offset += count;
|
||||
}
|
||||
|
||||
return assignments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print summary statistics for assignments.
|
||||
*/
|
||||
export function printAssignmentStats(
|
||||
assignments: Assignment[],
|
||||
annotatorIds: string[],
|
||||
): void {
|
||||
// Per-annotator counts
|
||||
const perAnnotator = new Map<string, number>();
|
||||
for (const a of assignments) {
|
||||
perAnnotator.set(a.annotatorId, (perAnnotator.get(a.annotatorId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
console.log("\nPer-annotator assignment counts:");
|
||||
for (const id of annotatorIds) {
|
||||
console.log(` ${id}: ${perAnnotator.get(id) ?? 0}`);
|
||||
}
|
||||
|
||||
// Pairwise overlap: how many paragraphs each pair shares
|
||||
const paragraphAnnotators = new Map<string, Set<string>>();
|
||||
for (const a of assignments) {
|
||||
const s = paragraphAnnotators.get(a.paragraphId);
|
||||
if (s) {
|
||||
s.add(a.annotatorId);
|
||||
} else {
|
||||
paragraphAnnotators.set(a.paragraphId, new Set([a.annotatorId]));
|
||||
}
|
||||
}
|
||||
|
||||
const pairCounts = new Map<string, number>();
|
||||
for (const annotators of paragraphAnnotators.values()) {
|
||||
const arr = [...annotators];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
for (let j = i + 1; j < arr.length; j++) {
|
||||
const key = [arr[i], arr[j]].sort().join("|");
|
||||
pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nPairwise overlap (paragraphs shared):");
|
||||
const pairs = [...pairCounts.entries()].sort((a, b) =>
|
||||
a[0].localeCompare(b[0]),
|
||||
);
|
||||
for (const [pair, count] of pairs) {
|
||||
const [a, b] = pair.split("|");
|
||||
console.log(` ${a} & ${b}: ${count}`);
|
||||
}
|
||||
|
||||
// Unique paragraphs
|
||||
console.log(`\nTotal unique paragraphs: ${paragraphAnnotators.size}`);
|
||||
console.log(`Total assignment rows: ${assignments.length}`);
|
||||
}
|
||||
74
labelapp/lib/auth.ts
Normal file
74
labelapp/lib/auth.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { createHmac } from "crypto";
|
||||
|
||||
const SECRET = "sec-cybert-label-session-key";
|
||||
const SESSION_COOKIE = "session";
|
||||
const SESSION_MAX_AGE_MS = 8 * 60 * 60 * 1000; // 8 hours
|
||||
|
||||
interface SessionPayload {
|
||||
annotatorId: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function sign(payload: string): string {
|
||||
return createHmac("sha256", SECRET).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
function encodeSession(payload: SessionPayload): string {
|
||||
const json = JSON.stringify(payload);
|
||||
const b64 = Buffer.from(json).toString("base64");
|
||||
const signature = sign(b64);
|
||||
return `${b64}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyAndDecode(raw: string): SessionPayload | null {
|
||||
const dotIndex = raw.lastIndexOf(".");
|
||||
if (dotIndex === -1) return null;
|
||||
|
||||
const b64 = raw.slice(0, dotIndex);
|
||||
const signature = raw.slice(dotIndex + 1);
|
||||
|
||||
const expected = sign(b64);
|
||||
if (signature !== expected) return null;
|
||||
|
||||
try {
|
||||
const json = Buffer.from(b64, "base64").toString("utf-8");
|
||||
const payload = JSON.parse(json) as SessionPayload;
|
||||
|
||||
if (!payload.annotatorId || !payload.createdAt) return null;
|
||||
|
||||
const age = Date.now() - payload.createdAt;
|
||||
if (age > SESSION_MAX_AGE_MS) return null;
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSession(annotatorId: string): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const value = encodeSession({ annotatorId, createdAt: Date.now() });
|
||||
cookieStore.set(SESSION_COOKIE, value, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: SESSION_MAX_AGE_MS / 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<{ annotatorId: string } | null> {
|
||||
const cookieStore = await cookies();
|
||||
const raw = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
if (!raw) return null;
|
||||
|
||||
const payload = verifyAndDecode(raw);
|
||||
if (!payload) return null;
|
||||
|
||||
return { annotatorId: payload.annotatorId };
|
||||
}
|
||||
|
||||
export async function destroySession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
}
|
||||
274
labelapp/lib/metrics.ts
Normal file
274
labelapp/lib/metrics.ts
Normal file
@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Statistical metrics for inter-rater reliability analysis.
|
||||
*
|
||||
* - cohensKappa: nominal agreement between two raters
|
||||
* - krippendorffsAlpha: ordinal agreement with multiple raters (handles missing data)
|
||||
* - confusionMatrix: contingency table for two sets of ratings
|
||||
* - agreementRate: raw proportion of items where all raters agree
|
||||
* - perCategoryAgreement: per-category agreement rates
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cohen's Kappa for two raters on nominal data.
|
||||
*
|
||||
* κ = (p_o - p_e) / (1 - p_e)
|
||||
* where p_o = observed agreement, p_e = expected agreement by chance.
|
||||
*/
|
||||
export function cohensKappa(ratings1: string[], ratings2: string[]): number {
|
||||
if (ratings1.length !== ratings2.length) {
|
||||
throw new Error("Rating arrays must have the same length");
|
||||
}
|
||||
|
||||
const n = ratings1.length;
|
||||
if (n === 0) return 0;
|
||||
|
||||
// Collect all unique categories
|
||||
const categories = new Set<string>();
|
||||
for (let i = 0; i < n; i++) {
|
||||
categories.add(ratings1[i]);
|
||||
categories.add(ratings2[i]);
|
||||
}
|
||||
|
||||
// Count agreements and marginal frequencies
|
||||
let agreements = 0;
|
||||
const count1 = new Map<string, number>();
|
||||
const count2 = new Map<string, number>();
|
||||
|
||||
for (const c of categories) {
|
||||
count1.set(c, 0);
|
||||
count2.set(c, 0);
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (ratings1[i] === ratings2[i]) agreements++;
|
||||
count1.set(ratings1[i], (count1.get(ratings1[i]) ?? 0) + 1);
|
||||
count2.set(ratings2[i], (count2.get(ratings2[i]) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const po = agreements / n;
|
||||
|
||||
// Expected agreement by chance
|
||||
let pe = 0;
|
||||
for (const c of categories) {
|
||||
pe += (count1.get(c)! / n) * (count2.get(c)! / n);
|
||||
}
|
||||
|
||||
if (pe === 1) return 1; // Both raters used the same single category
|
||||
return (po - pe) / (1 - pe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Krippendorff's Alpha for ordinal data with multiple raters.
|
||||
*
|
||||
* Uses the coincidence matrix approach with ordinal distance function d(c,k) = (c-k)^2.
|
||||
* Handles missing data (null values).
|
||||
*
|
||||
* @param ratings - raters x items matrix, null = missing
|
||||
* @returns alpha coefficient (-inf to 1, where 1 = perfect agreement)
|
||||
*/
|
||||
export function krippendorffsAlpha(ratings: (number | null)[][]): number {
|
||||
const nRaters = ratings.length;
|
||||
if (nRaters < 2) throw new Error("Need at least 2 raters");
|
||||
|
||||
const nItems = ratings[0].length;
|
||||
if (nItems === 0) return 0;
|
||||
|
||||
// Collect all unique values across all ratings
|
||||
const valueSet = new Set<number>();
|
||||
for (let r = 0; r < nRaters; r++) {
|
||||
for (let i = 0; i < nItems; i++) {
|
||||
const v = ratings[r][i];
|
||||
if (v !== null) valueSet.add(v);
|
||||
}
|
||||
}
|
||||
const values = [...valueSet].sort((a, b) => a - b);
|
||||
const valueIndex = new Map<number, number>();
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
valueIndex.set(values[i], i);
|
||||
}
|
||||
const nValues = values.length;
|
||||
|
||||
if (nValues < 2) return 1; // All non-null ratings are the same value
|
||||
|
||||
// Build coincidence matrix
|
||||
// o[c][k] = number of coincidences between values c and k
|
||||
const o: number[][] = Array.from({ length: nValues }, () =>
|
||||
new Array(nValues).fill(0),
|
||||
);
|
||||
|
||||
let totalPairable = 0;
|
||||
|
||||
for (let i = 0; i < nItems; i++) {
|
||||
// Collect non-null values for this item
|
||||
const itemValues: number[] = [];
|
||||
for (let r = 0; r < nRaters; r++) {
|
||||
const v = ratings[r][i];
|
||||
if (v !== null) itemValues.push(v);
|
||||
}
|
||||
|
||||
const mi = itemValues.length;
|
||||
if (mi < 2) continue; // Need at least 2 raters on this item
|
||||
|
||||
// Each pair of coders contributes 1/(m_i - 1) to the coincidence matrix
|
||||
const weight = 1 / (mi - 1);
|
||||
|
||||
for (let a = 0; a < mi; a++) {
|
||||
for (let b = 0; b < mi; b++) {
|
||||
if (a === b) continue;
|
||||
const ci = valueIndex.get(itemValues[a])!;
|
||||
const ki = valueIndex.get(itemValues[b])!;
|
||||
o[ci][ki] += weight;
|
||||
}
|
||||
}
|
||||
|
||||
totalPairable += mi;
|
||||
}
|
||||
|
||||
if (totalPairable === 0) return 0;
|
||||
|
||||
// Marginal frequencies from coincidence matrix: n_c = sum of row c
|
||||
const nc: number[] = new Array(nValues).fill(0);
|
||||
for (let c = 0; c < nValues; c++) {
|
||||
for (let k = 0; k < nValues; k++) {
|
||||
nc[c] += o[c][k];
|
||||
}
|
||||
}
|
||||
|
||||
const nTotal = nc.reduce((sum, v) => sum + v, 0);
|
||||
if (nTotal === 0) return 0;
|
||||
|
||||
// Ordinal distance function: d(c, k) = (c - k)^2
|
||||
const dist = (c: number, k: number): number => {
|
||||
return (values[c] - values[k]) ** 2;
|
||||
};
|
||||
|
||||
// Observed disagreement: D_o = (1/n) * sum_c sum_k o[c][k] * d(c,k)
|
||||
let dObserved = 0;
|
||||
for (let c = 0; c < nValues; c++) {
|
||||
for (let k = 0; k < nValues; k++) {
|
||||
if (c !== k) {
|
||||
dObserved += o[c][k] * dist(c, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
dObserved /= nTotal;
|
||||
|
||||
// Expected disagreement: D_e = (1/(n*(n-1))) * sum_c sum_k n_c * n_k * d(c,k)
|
||||
let dExpected = 0;
|
||||
for (let c = 0; c < nValues; c++) {
|
||||
for (let k = 0; k < nValues; k++) {
|
||||
if (c !== k) {
|
||||
dExpected += nc[c] * nc[k] * dist(c, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
dExpected /= nTotal * (nTotal - 1);
|
||||
|
||||
if (dExpected === 0) return 1; // No expected disagreement possible
|
||||
return 1 - dObserved / dExpected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a confusion matrix for two sets of ratings.
|
||||
*
|
||||
* @param actual - ground truth labels
|
||||
* @param predicted - predicted labels
|
||||
* @param labels - ordered list of label values (defines row/column order)
|
||||
* @returns 2D array where result[i][j] = count of (actual=labels[i], predicted=labels[j])
|
||||
*/
|
||||
export function confusionMatrix(
|
||||
actual: string[],
|
||||
predicted: string[],
|
||||
labels: string[],
|
||||
): number[][] {
|
||||
if (actual.length !== predicted.length) {
|
||||
throw new Error("Arrays must have the same length");
|
||||
}
|
||||
|
||||
const labelIndex = new Map<string, number>();
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
labelIndex.set(labels[i], i);
|
||||
}
|
||||
|
||||
const matrix: number[][] = Array.from({ length: labels.length }, () =>
|
||||
new Array(labels.length).fill(0),
|
||||
);
|
||||
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
const ai = labelIndex.get(actual[i]);
|
||||
const pi = labelIndex.get(predicted[i]);
|
||||
if (ai !== undefined && pi !== undefined) {
|
||||
matrix[ai][pi]++;
|
||||
}
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw agreement rate: proportion of items where ALL raters agree.
|
||||
*
|
||||
* @param labels - items x raters matrix (each inner array is the ratings for one item)
|
||||
* @returns proportion of items with complete agreement (0 to 1)
|
||||
*/
|
||||
export function agreementRate(labels: string[][]): number {
|
||||
if (labels.length === 0) return 0;
|
||||
|
||||
let agreements = 0;
|
||||
|
||||
for (const itemRatings of labels) {
|
||||
if (itemRatings.length === 0) continue;
|
||||
const allSame = itemRatings.every((r) => r === itemRatings[0]);
|
||||
if (allSame) agreements++;
|
||||
}
|
||||
|
||||
return agreements / labels.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-category agreement: for each category, what proportion of items
|
||||
* assigned that category by at least one rater have full agreement?
|
||||
*
|
||||
* @param labels - flat array of label records with category, annotatorId, paragraphId
|
||||
* @param categories - list of categories to compute agreement for
|
||||
* @returns record mapping each category to its agreement rate (0 to 1)
|
||||
*/
|
||||
export function perCategoryAgreement(
|
||||
labels: {
|
||||
category: string;
|
||||
annotatorId: string;
|
||||
paragraphId: string;
|
||||
}[],
|
||||
categories: string[],
|
||||
): Record<string, number> {
|
||||
// Group labels by paragraph
|
||||
const byParagraph = new Map<string, string[]>();
|
||||
for (const label of labels) {
|
||||
if (!byParagraph.has(label.paragraphId)) {
|
||||
byParagraph.set(label.paragraphId, []);
|
||||
}
|
||||
byParagraph.get(label.paragraphId)!.push(label.category);
|
||||
}
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
for (const category of categories) {
|
||||
let relevant = 0;
|
||||
let agreed = 0;
|
||||
|
||||
for (const ratings of byParagraph.values()) {
|
||||
// Check if this category appears in any rating for this paragraph
|
||||
if (!ratings.includes(category)) continue;
|
||||
|
||||
relevant++;
|
||||
// Check if ALL raters assigned this category
|
||||
if (ratings.every((r) => r === category)) {
|
||||
agreed++;
|
||||
}
|
||||
}
|
||||
|
||||
result[category] = relevant > 0 ? agreed / relevant : 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
435
labelapp/lib/quiz-questions.ts
Normal file
435
labelapp/lib/quiz-questions.ts
Normal file
@ -0,0 +1,435 @@
|
||||
export interface QuizQuestion {
|
||||
id: string;
|
||||
type:
|
||||
| "person-vs-function"
|
||||
| "materiality-disclaimer"
|
||||
| "qv-counting"
|
||||
| "spac-exception";
|
||||
paragraphText: string;
|
||||
question: string;
|
||||
options: { value: string; label: string }[];
|
||||
correctAnswer: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
const PERSON_VS_FUNCTION_OPTIONS = [
|
||||
{ value: "Management Role", label: "Management Role" },
|
||||
{ value: "Risk Management Process", label: "Risk Management Process" },
|
||||
];
|
||||
|
||||
const MATERIALITY_OPTIONS = [
|
||||
{ value: "Strategy Integration", label: "Strategy Integration" },
|
||||
{ value: "None/Other", label: "None/Other" },
|
||||
];
|
||||
|
||||
const QV_OPTIONS = [
|
||||
{ value: "2", label: "Specificity 2 — Sector-Adapted" },
|
||||
{ value: "3", label: "Specificity 3 — Firm-Specific" },
|
||||
{ value: "4", label: "Specificity 4 — Quantified-Verifiable" },
|
||||
];
|
||||
|
||||
const SPAC_OPTIONS = [
|
||||
{ value: "None/Other", label: "None/Other" },
|
||||
{ value: "Board Governance", label: "Board Governance" },
|
||||
{ value: "Risk Management Process", label: "Risk Management Process" },
|
||||
{ value: "Management Role", label: "Management Role" },
|
||||
];
|
||||
|
||||
const PERSON_VS_FUNCTION_QUESTION =
|
||||
"What content category best describes this paragraph?";
|
||||
const MATERIALITY_QUESTION =
|
||||
"What content category best describes this paragraph?";
|
||||
const QV_QUESTION = "What specificity level best describes this paragraph?";
|
||||
const SPAC_QUESTION = "What content category best describes this paragraph?";
|
||||
|
||||
export const QUIZ_QUESTIONS: QuizQuestion[] = [
|
||||
// ============================================================
|
||||
// PERSON-VS-FUNCTION (10 questions)
|
||||
// ============================================================
|
||||
{
|
||||
id: "pvf-1",
|
||||
type: "person-vs-function",
|
||||
paragraphText:
|
||||
"Our Vice President of Information Security, who holds CISSP and CISM certifications and has over 20 years of experience in cybersecurity and information technology, reports directly to our Chief Information Officer.",
|
||||
question: PERSON_VS_FUNCTION_QUESTION,
|
||||
options: PERSON_VS_FUNCTION_OPTIONS,
|
||||
correctAnswer: "Management Role",
|
||||
explanation:
|
||||
'This paragraph is about the PERSON: their certifications (CISSP, CISM), experience (20 years), and reporting line (to the CIO). The person-vs-function test: if you remove the credentials and reporting line, there is no remaining content about cybersecurity processes or activities. The paragraph tells you WHO the person is, not WHAT the program does.',
|
||||
},
|
||||
{
|
||||
id: "pvf-2",
|
||||
type: "person-vs-function",
|
||||
paragraphText:
|
||||
"Our CISO oversees the Company's comprehensive cybersecurity program, which includes regular risk assessments, vulnerability scanning, penetration testing, and incident response planning aligned with the NIST Cybersecurity Framework.",
|
||||
question: PERSON_VS_FUNCTION_QUESTION,
|
||||
options: PERSON_VS_FUNCTION_OPTIONS,
|
||||
correctAnswer: "Risk Management Process",
|
||||
explanation:
|
||||
'The CISO is mentioned once as attribution ("Our CISO oversees"), but the paragraph\'s substantive content describes the program: risk assessments, vulnerability scanning, penetration testing, incident response planning, NIST CSF alignment. Remove "Our CISO oversees" and the paragraph still describes a complete cybersecurity program. The person-vs-function test clearly points to Risk Management Process.',
|
||||
},
|
||||
{
|
||||
id: "pvf-3",
|
||||
type: "person-vs-function",
|
||||
paragraphText:
|
||||
"Our Chief Information Security Officer, Jane Smith, has served in this role since 2020. Prior to joining the Company, Ms. Smith spent 15 years in cybersecurity leadership positions at major financial institutions, including serving as the Deputy CISO at JPMorgan Chase.",
|
||||
question: PERSON_VS_FUNCTION_QUESTION,
|
||||
options: PERSON_VS_FUNCTION_OPTIONS,
|
||||
correctAnswer: "Management Role",
|
||||
explanation:
|
||||
"The entire paragraph is about the person: her name (Jane Smith), tenure (since 2020), career background (15 years at financial institutions), and prior role (Deputy CISO at JPMorgan Chase). There is no description of cybersecurity processes or activities. This is clearly about WHO the person is.",
|
||||
},
|
||||
{
|
||||
id: "pvf-4",
|
||||
type: "person-vs-function",
|
||||
paragraphText:
|
||||
"The Company's cybersecurity team, led by our CISO, conducts quarterly vulnerability assessments and annual penetration testing of all customer-facing systems, and maintains 24/7 monitoring through our Security Operations Center.",
|
||||
question: PERSON_VS_FUNCTION_QUESTION,
|
||||
options: PERSON_VS_FUNCTION_OPTIONS,
|
||||
correctAnswer: "Risk Management Process",
|
||||
explanation:
|
||||
'The CISO appears as brief attribution ("led by our CISO"), but the paragraph describes program activities: vulnerability assessments, penetration testing, 24/7 monitoring, and the SOC. Remove the CISO reference and you still have a complete description of cybersecurity operations. The person-vs-function test clearly points to Risk Management Process.',
|
||||
},
|
||||
{
|
||||
id: "pvf-5",
|
||||
type: "person-vs-function",
|
||||
paragraphText:
|
||||
"Mr. David Reyes serves as our Chief Information Security Officer and has held this position since March 2021. Mr. Reyes holds a Master of Science in Cybersecurity from Carnegie Mellon University and maintains CISSP, CISM, and CRISC certifications. He has over 25 years of experience in information security, including senior roles at Lockheed Martin and Northrop Grumman.",
|
||||
question: PERSON_VS_FUNCTION_QUESTION,
|
||||
options: PERSON_VS_FUNCTION_OPTIONS,
|
||||
correctAnswer: "Management Role",
|
||||
explanation:
|
||||
"Every sentence is about the person: his name, tenure, education (Carnegie Mellon), certifications (CISSP, CISM, CRISC), years of experience, and career history (Lockheed Martin, Northrop Grumman). There are zero descriptions of cybersecurity processes or program activities. This is unambiguously Management Role.",
|
||||
},
|
||||
{
|
||||
id: "pvf-6",
|
||||
type: "person-vs-function",
|
||||
paragraphText:
|
||||
"Under the leadership of our CISO, we have implemented a multi-layered defense strategy that includes network segmentation, endpoint detection and response, data loss prevention, and security information and event management. Our security team monitors all critical systems on a continuous basis and conducts incident response tabletop exercises on a quarterly basis.",
|
||||
question: PERSON_VS_FUNCTION_QUESTION,
|
||||
options: PERSON_VS_FUNCTION_OPTIONS,
|
||||
correctAnswer: "Risk Management Process",
|
||||
explanation:
|
||||
'The CISO is mentioned only as brief attribution ("Under the leadership of our CISO"). The paragraph\'s content describes program elements: network segmentation, EDR, DLP, SIEM, continuous monitoring, and tabletop exercises. Remove the CISO attribution and the paragraph is entirely about what the cybersecurity program does. This is Risk Management Process.',
|
||||
},
|
||||
{
|
||||
id: "pvf-7",
|
||||
type: "person-vs-function",
|
||||
paragraphText:
|
||||
"Our information security program is managed by our Vice President of Cybersecurity, who reports to our Chief Technology Officer. The VP of Cybersecurity is responsible for the day-to-day management of the Company's cybersecurity risk management program and leads a team of security professionals responsible for identifying, assessing, and mitigating cybersecurity threats.",
|
||||
question: PERSON_VS_FUNCTION_QUESTION,
|
||||
options: PERSON_VS_FUNCTION_OPTIONS,
|
||||
correctAnswer: "Risk Management Process",
|
||||
explanation:
|
||||
"While this paragraph names the VP of Cybersecurity and their reporting line, the dominant content describes the function: day-to-day management of the cybersecurity risk management program, and a team responsible for identifying, assessing, and mitigating threats. The person-vs-function test: remove the title and reporting line, and the paragraph still describes a cybersecurity program. The brief reporting structure is subordinate to the process description.",
|
||||
},
|
||||
{
|
||||
id: "pvf-8",
|
||||
type: "person-vs-function",
|
||||
paragraphText:
|
||||
"Ms. Angela Torres, our Senior Vice President and Chief Information Security Officer, joined the Company in 2018. Ms. Torres previously served as the Global Head of Cybersecurity at Citigroup for seven years. She is a member of the Board of Directors of the Center for Internet Security (CIS) and serves on the advisory board of the Financial Services Information Sharing and Analysis Center (FS-ISAC).",
|
||||
question: PERSON_VS_FUNCTION_QUESTION,
|
||||
options: PERSON_VS_FUNCTION_OPTIONS,
|
||||
correctAnswer: "Management Role",
|
||||
explanation:
|
||||
"The paragraph is entirely about the person: her name, title, tenure (since 2018), prior employer (Citigroup), duration of prior role (seven years), and external board memberships (CIS, FS-ISAC). There are no descriptions of the company's cybersecurity processes or activities. This is clearly Management Role.",
|
||||
},
|
||||
{
|
||||
id: "pvf-9",
|
||||
type: "person-vs-function",
|
||||
paragraphText:
|
||||
"Our CISO and dedicated cybersecurity team maintain and regularly update the Company's incident response plan, which establishes protocols for detecting, containing, eradicating, and recovering from cybersecurity incidents. The plan is tested through annual tabletop exercises involving cross-functional participants from Legal, Compliance, Finance, and Communications.",
|
||||
question: PERSON_VS_FUNCTION_QUESTION,
|
||||
options: PERSON_VS_FUNCTION_OPTIONS,
|
||||
correctAnswer: "Risk Management Process",
|
||||
explanation:
|
||||
'The CISO is mentioned alongside "dedicated cybersecurity team" as attribution, but the content describes the incident response plan and its elements: detection, containment, eradication, recovery protocols, annual testing, and cross-functional participation. The person-vs-function test: remove the CISO reference and the paragraph fully describes a cybersecurity process. This is Risk Management Process.',
|
||||
},
|
||||
{
|
||||
id: "pvf-10",
|
||||
type: "person-vs-function",
|
||||
paragraphText:
|
||||
"Management is responsible for assessing and managing cybersecurity risks within the organization.",
|
||||
question: PERSON_VS_FUNCTION_QUESTION,
|
||||
options: PERSON_VS_FUNCTION_OPTIONS,
|
||||
correctAnswer: "Management Role",
|
||||
explanation:
|
||||
'Although this paragraph is extremely generic, its subject is "Management" and its content is about role responsibility rather than describing any specific process, tool, or methodology. There is no description of HOW cybersecurity risks are assessed or managed — only THAT management is responsible. Per the codebook, this is Management Role at Specificity 1 (generic, no named roles or structure).',
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// MATERIALITY DISCLAIMERS (8 questions)
|
||||
// ============================================================
|
||||
{
|
||||
id: "mat-1",
|
||||
type: "materiality-disclaimer",
|
||||
paragraphText:
|
||||
"Cybersecurity risks have not materially affected our business strategy, results of operations, or financial condition.",
|
||||
question: MATERIALITY_QUESTION,
|
||||
options: MATERIALITY_OPTIONS,
|
||||
correctAnswer: "Strategy Integration",
|
||||
explanation:
|
||||
'This is an explicit materiality assessment: the company states that cybersecurity risks have not "materially affected" its business. Per the codebook, any paragraph that explicitly assesses whether cybersecurity risks have or could materially affect the company is Strategy Integration, even when the language is boilerplate.',
|
||||
},
|
||||
{
|
||||
id: "mat-2",
|
||||
type: "materiality-disclaimer",
|
||||
paragraphText:
|
||||
"For additional information about risks related to our information technology systems, see Part I, Item 1A, 'Risk Factors.'",
|
||||
question: MATERIALITY_QUESTION,
|
||||
options: MATERIALITY_OPTIONS,
|
||||
correctAnswer: "None/Other",
|
||||
explanation:
|
||||
"This is a pure cross-reference that points the reader to another section of the filing. There is no materiality assessment — no statement about whether cybersecurity risks have or could materially affect the business. Per the codebook, a pure cross-reference with no materiality conclusion is None/Other.",
|
||||
},
|
||||
{
|
||||
id: "mat-3",
|
||||
type: "materiality-disclaimer",
|
||||
paragraphText:
|
||||
"We have not identified any cybersecurity incidents that have materially affected us. For more information, see Item 1A, Risk Factors.",
|
||||
question: MATERIALITY_QUESTION,
|
||||
options: MATERIALITY_OPTIONS,
|
||||
correctAnswer: "Strategy Integration",
|
||||
explanation:
|
||||
'This paragraph contains both a materiality assessment ("have not... materially affected us") and a cross-reference. Per the codebook, the materiality assessment is the substantive content and the cross-reference is noise. A cross-reference appended to a materiality assessment does not change the classification. This is Strategy Integration.',
|
||||
},
|
||||
{
|
||||
id: "mat-4",
|
||||
type: "materiality-disclaimer",
|
||||
paragraphText:
|
||||
"Cybersecurity risks, including those described above, have not materially affected, and are not reasonably likely to materially affect, our business strategy, results of operations, or financial condition. However, like other companies, we have experienced threats from time to time. For more information about cybersecurity risks, see Part I, Item 1A, 'Risk Factors' in this Annual Report.",
|
||||
question: MATERIALITY_QUESTION,
|
||||
options: MATERIALITY_OPTIONS,
|
||||
correctAnswer: "Strategy Integration",
|
||||
explanation:
|
||||
'Despite the generic threat mention ("we have experienced threats") and the cross-reference, this paragraph contains an explicit materiality assessment: risks "have not materially affected, and are not reasonably likely to materially affect" the company\'s business. Per the codebook, the materiality assessment governs the classification. The cross-reference and generic threat language are noise.',
|
||||
},
|
||||
{
|
||||
id: "mat-5",
|
||||
type: "materiality-disclaimer",
|
||||
paragraphText:
|
||||
"For a discussion of cybersecurity risks that may materially affect our business, see 'Risk Factors — Risks Related to Information Technology and Data Privacy' in Part I, Item 1A of this Annual Report on Form 10-K.",
|
||||
question: MATERIALITY_QUESTION,
|
||||
options: MATERIALITY_OPTIONS,
|
||||
correctAnswer: "None/Other",
|
||||
explanation:
|
||||
'This is a cross-reference, not a materiality assessment. It mentions "materially affect" as part of a description of what is in another section, but the paragraph itself makes no substantive claim about whether cybersecurity risks have or could materially affect the business. The test: does this paragraph make a judgment about cyber risk impact? No — it only tells you where to find that discussion. This is None/Other.',
|
||||
},
|
||||
{
|
||||
id: "mat-6",
|
||||
type: "materiality-disclaimer",
|
||||
paragraphText:
|
||||
"As of the date of this filing, no cybersecurity threats or incidents have materially affected the Company or are reasonably likely to materially affect the Company, including its business strategy, results of operations, or financial condition. We maintain cybersecurity insurance coverage to help offset potential losses from cyber incidents.",
|
||||
question: MATERIALITY_QUESTION,
|
||||
options: MATERIALITY_OPTIONS,
|
||||
correctAnswer: "Strategy Integration",
|
||||
explanation:
|
||||
'The paragraph opens with a clear materiality assessment ("no cybersecurity threats or incidents have materially affected the Company") and adds a note about insurance coverage. Both the materiality assessment and the insurance mention point to Strategy Integration. The paragraph makes an explicit strategic judgment about cyber risk\'s business impact.',
|
||||
},
|
||||
{
|
||||
id: "mat-7",
|
||||
type: "materiality-disclaimer",
|
||||
paragraphText:
|
||||
"See Part I, Item 1A, Risk Factors, and Part I, Item 1, Business, for additional information about our cybersecurity risk management program and associated risks.",
|
||||
question: MATERIALITY_QUESTION,
|
||||
options: MATERIALITY_OPTIONS,
|
||||
correctAnswer: "None/Other",
|
||||
explanation:
|
||||
"This is a pure cross-reference pointing to two other sections of the filing. There is no materiality assessment, no substantive disclosure about cybersecurity risks or their business impact. Per the codebook, a pure cross-reference with no materiality conclusion is None/Other.",
|
||||
},
|
||||
{
|
||||
id: "mat-8",
|
||||
type: "materiality-disclaimer",
|
||||
paragraphText:
|
||||
"While we have experienced cybersecurity incidents in the past, none of these incidents, individually or in the aggregate, have materially affected, or are reasonably likely to materially affect, our business, results of operations, or financial condition, including our business strategy.",
|
||||
question: MATERIALITY_QUESTION,
|
||||
options: MATERIALITY_OPTIONS,
|
||||
correctAnswer: "Strategy Integration",
|
||||
explanation:
|
||||
'This paragraph makes an explicit materiality assessment: past incidents "have [not] materially affected" the company. The acknowledgment of past incidents does not change the classification — the paragraph\'s purpose is to assess materiality, which is the hallmark of Strategy Integration per the codebook.',
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// QV FACT COUNTING (8 questions)
|
||||
// ============================================================
|
||||
{
|
||||
id: "qv-1",
|
||||
type: "qv-counting",
|
||||
paragraphText:
|
||||
"We maintain cyber liability insurance coverage.",
|
||||
question: QV_QUESTION,
|
||||
options: QV_OPTIONS,
|
||||
correctAnswer: "3",
|
||||
explanation:
|
||||
'This mentions insurance but provides no verifiable details (no dollar amount, no named insurer). "Cyber liability insurance" is a firm-specific fact — it tells you this particular company holds this type of coverage — but there is only one such fact. One firm-specific fact without a named standard = Specificity 3 (Firm-Specific).',
|
||||
},
|
||||
{
|
||||
id: "qv-2",
|
||||
type: "qv-counting",
|
||||
paragraphText:
|
||||
"We maintain cyber liability insurance with $100M aggregate coverage through AIG.",
|
||||
question: QV_QUESTION,
|
||||
options: QV_OPTIONS,
|
||||
correctAnswer: "4",
|
||||
explanation:
|
||||
"This paragraph contains multiple verifiable facts: a specific dollar amount ($100M aggregate coverage) and a named insurer (AIG). Two or more hard verifiable facts = Specificity 4 (Quantified-Verifiable) per the codebook's QV counting rules.",
|
||||
},
|
||||
{
|
||||
id: "qv-3",
|
||||
type: "qv-counting",
|
||||
paragraphText:
|
||||
"Our incident response team conducts quarterly tabletop exercises.",
|
||||
question: QV_QUESTION,
|
||||
options: QV_OPTIONS,
|
||||
correctAnswer: "3",
|
||||
explanation:
|
||||
'Per the codebook, "quarterly" is a generic cadence and does NOT count as a specific fact for QV purposes. However, the mention of an "incident response team" and "tabletop exercises" indicates firm-specific activities. This has one firm-specific element but no hard verifiable facts (no named vendors, no dollar amounts, no exact dates). Specificity 3 (Firm-Specific).',
|
||||
},
|
||||
{
|
||||
id: "qv-4",
|
||||
type: "qv-counting",
|
||||
paragraphText:
|
||||
"Our cybersecurity program is aligned with the NIST Cybersecurity Framework and incorporates elements of ISO 27001. We conduct regular risk assessments and vulnerability scanning as part of our continuous monitoring approach.",
|
||||
question: QV_QUESTION,
|
||||
options: QV_OPTIONS,
|
||||
correctAnswer: "2",
|
||||
explanation:
|
||||
'This paragraph names two recognized standards (NIST CSF and ISO 27001), which places it at Specificity 2. However, naming standards is NOT a firm-specific fact per the codebook — it only makes a paragraph Sector-Adapted. The activities described (risk assessments, vulnerability scanning, continuous monitoring) are generic practices. There are no firm-specific facts (no named tools, no named personnel, no dates, no dollar amounts). Specificity 2 (Sector-Adapted).',
|
||||
},
|
||||
{
|
||||
id: "qv-5",
|
||||
type: "qv-counting",
|
||||
paragraphText:
|
||||
"We operate a 24/7 Security Operations Center staffed by a team of 18 cybersecurity professionals. Our SOC uses CrowdStrike Falcon for endpoint detection and response and Splunk Enterprise Security as our SIEM platform. In fiscal 2024, our SOC processed over 2.3 billion security events and investigated 847 potential incidents.",
|
||||
question: QV_QUESTION,
|
||||
options: QV_OPTIONS,
|
||||
correctAnswer: "4",
|
||||
explanation:
|
||||
"This paragraph is rich in verifiable facts: team size (18 professionals), named tools (CrowdStrike Falcon, Splunk Enterprise Security), specific time period (fiscal 2024), event volume (2.3 billion), and incident count (847). With far more than two hard verifiable facts, this is clearly Specificity 4 (Quantified-Verifiable).",
|
||||
},
|
||||
{
|
||||
id: "qv-6",
|
||||
type: "qv-counting",
|
||||
paragraphText:
|
||||
"Our CISO leads the Company's cybersecurity program, which includes risk assessments, vulnerability management, and incident response planning.",
|
||||
question: QV_QUESTION,
|
||||
options: QV_OPTIONS,
|
||||
correctAnswer: "3",
|
||||
explanation:
|
||||
'The CISO title is a cybersecurity-specific role per the codebook\'s IS list, making this at least Firm-Specific. However, there is only one firm-specific fact (the CISO title). The activities listed (risk assessments, vulnerability management, incident response planning) are generic and do not count as verifiable facts. One firm-specific fact = Specificity 3 (Firm-Specific), not QV.',
|
||||
},
|
||||
{
|
||||
id: "qv-7",
|
||||
type: "qv-counting",
|
||||
paragraphText:
|
||||
"We engaged Deloitte to conduct an independent assessment of our cybersecurity program in fiscal 2024. The assessment identified no critical vulnerabilities and resulted in 12 recommendations for improvement, all of which have been addressed or are being remediated.",
|
||||
question: QV_QUESTION,
|
||||
options: QV_OPTIONS,
|
||||
correctAnswer: "4",
|
||||
explanation:
|
||||
"Multiple verifiable facts: named third-party firm (Deloitte), specific time period (fiscal 2024), specific finding count (12 recommendations). Three or more hard verifiable facts easily qualifies for Specificity 4 (Quantified-Verifiable).",
|
||||
},
|
||||
{
|
||||
id: "qv-8",
|
||||
type: "qv-counting",
|
||||
paragraphText:
|
||||
"Our cybersecurity team conducts regular penetration testing and vulnerability assessments of our information technology infrastructure. We also engage external cybersecurity consultants to periodically evaluate our security posture.",
|
||||
question: QV_QUESTION,
|
||||
options: QV_OPTIONS,
|
||||
correctAnswer: "3",
|
||||
explanation:
|
||||
'The mention of a "cybersecurity team" is a firm-specific fact (this company has a dedicated team), but there is only one such fact. The "external cybersecurity consultants" are unnamed and therefore do not count per the codebook\'s NOT list. "Regular" and "periodically" are generic cadences. One firm-specific fact = Specificity 3 (Firm-Specific).',
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// SPAC EXCEPTION (4 questions)
|
||||
// ============================================================
|
||||
{
|
||||
id: "spac-1",
|
||||
type: "spac-exception",
|
||||
paragraphText:
|
||||
"We are a special purpose acquisition company with no business operations. We have not adopted any cybersecurity risk management program. Our board of directors is generally responsible for oversight of cybersecurity risks, if any.",
|
||||
question: SPAC_QUESTION,
|
||||
options: SPAC_OPTIONS,
|
||||
correctAnswer: "None/Other",
|
||||
explanation:
|
||||
'Per the codebook\'s SPAC exception: companies that explicitly state they have no operations and no cybersecurity program receive None/Other regardless of incidental board mentions. The board reference ("generally responsible... if any") is perfunctory, not substantive governance disclosure. The absence of a program is not a description of a program.',
|
||||
},
|
||||
{
|
||||
id: "spac-2",
|
||||
type: "spac-exception",
|
||||
paragraphText:
|
||||
"We do not consider that we face significant cybersecurity risk and have not adopted any formal processes for assessing cybersecurity risk.",
|
||||
question: SPAC_QUESTION,
|
||||
options: SPAC_OPTIONS,
|
||||
correctAnswer: "None/Other",
|
||||
explanation:
|
||||
"The company explicitly states it has not adopted formal cybersecurity processes. Per the codebook, the absence of a program is not a program description. Even though the paragraph mentions cybersecurity risk, there is no substantive disclosure content. This is None/Other.",
|
||||
},
|
||||
{
|
||||
id: "spac-3",
|
||||
type: "spac-exception",
|
||||
paragraphText:
|
||||
"As a blank check company, we have no operations and therefore have limited exposure to cybersecurity risk. We have not implemented a formal cybersecurity risk management program. Our sponsor and management team are generally aware of cybersecurity risks and will seek to implement appropriate measures following the completion of our initial business combination.",
|
||||
question: SPAC_QUESTION,
|
||||
options: SPAC_OPTIONS,
|
||||
correctAnswer: "None/Other",
|
||||
explanation:
|
||||
"This is a blank check company (SPAC) with no operations and no formal cybersecurity program. The mention of the sponsor and management team being \"generally aware\" is perfunctory — it does not describe any substantive management role, process, or governance structure. The forward-looking statement about implementing measures post-combination is aspirational, not disclosure. None/Other.",
|
||||
},
|
||||
{
|
||||
id: "spac-4",
|
||||
type: "spac-exception",
|
||||
paragraphText:
|
||||
"We are a newly formed company with limited operations. We have not yet established a formal cybersecurity risk management program. Our Chief Executive Officer and Chief Financial Officer are responsible for identifying and managing cybersecurity risks as they arise, although no specific cybersecurity policies or procedures have been adopted as of the date of this filing.",
|
||||
question: SPAC_QUESTION,
|
||||
options: SPAC_OPTIONS,
|
||||
correctAnswer: "None/Other",
|
||||
explanation:
|
||||
"Despite naming the CEO and CFO as responsible for cybersecurity risks, the company explicitly states it has no formal program and no specific policies or procedures. Per the codebook, CEO and CFO are generic C-suite titles (NOT cybersecurity-specific), and the mention of them is perfunctory. The company has limited operations and no substantive cybersecurity disclosure. This is the SPAC/shell company exception: None/Other.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Draw a balanced set of quiz questions: `count` / 4 per type (rounded),
|
||||
* shuffled within each type and across the final set.
|
||||
*/
|
||||
export function drawQuizQuestions(count: number): QuizQuestion[] {
|
||||
const types = [
|
||||
"person-vs-function",
|
||||
"materiality-disclaimer",
|
||||
"qv-counting",
|
||||
"spac-exception",
|
||||
] as const;
|
||||
|
||||
const perType = Math.max(1, Math.floor(count / types.length));
|
||||
const byType = new Map<string, QuizQuestion[]>();
|
||||
|
||||
for (const t of types) {
|
||||
byType.set(
|
||||
t,
|
||||
QUIZ_QUESTIONS.filter((q) => q.type === t),
|
||||
);
|
||||
}
|
||||
|
||||
const selected: QuizQuestion[] = [];
|
||||
|
||||
for (const t of types) {
|
||||
const pool = byType.get(t)!;
|
||||
// Fisher-Yates shuffle on a copy
|
||||
const shuffled = [...pool];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
selected.push(...shuffled.slice(0, perType));
|
||||
}
|
||||
|
||||
// Shuffle the final selection
|
||||
for (let i = selected.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[selected[i], selected[j]] = [selected[j], selected[i]];
|
||||
}
|
||||
|
||||
return selected.slice(0, count);
|
||||
}
|
||||
277
labelapp/lib/sampling.ts
Normal file
277
labelapp/lib/sampling.ts
Normal file
@ -0,0 +1,277 @@
|
||||
export interface ParagraphWithVotes {
|
||||
id: string;
|
||||
stage1Category: string | null;
|
||||
stage1Specificity: number | null;
|
||||
/** Raw category votes from stage1 annotations */
|
||||
categoryVotes: string[];
|
||||
/** Raw specificity votes from stage1 annotations */
|
||||
specificityVotes: number[];
|
||||
}
|
||||
|
||||
export interface StratumConfig {
|
||||
name: string;
|
||||
count: number;
|
||||
filter: (p: ParagraphWithVotes) => boolean;
|
||||
}
|
||||
|
||||
export interface SamplingConfig {
|
||||
total: number;
|
||||
strata: StratumConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle an array in place using Fisher-Yates.
|
||||
*/
|
||||
function shuffle<T>(arr: T[]): T[] {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a paragraph's annotations have a split between two specific categories.
|
||||
* A "split" means at least one vote for each of the two categories.
|
||||
*/
|
||||
function hasCategorySplit(
|
||||
p: ParagraphWithVotes,
|
||||
catA: string,
|
||||
catB: string,
|
||||
): boolean {
|
||||
return (
|
||||
p.categoryVotes.includes(catA) && p.categoryVotes.includes(catB)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a paragraph's specificity votes span between two specific values.
|
||||
*/
|
||||
function hasSpecificitySplit(
|
||||
p: ParagraphWithVotes,
|
||||
specA: number,
|
||||
specB: number,
|
||||
): boolean {
|
||||
return (
|
||||
p.specificityVotes.includes(specA) &&
|
||||
p.specificityVotes.includes(specB)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proportional stratified random sampling from category x specificity cells.
|
||||
* Fills the remaining `count` slots proportionally based on cell sizes.
|
||||
*/
|
||||
function proportionalSample(
|
||||
eligible: ParagraphWithVotes[],
|
||||
count: number,
|
||||
): string[] {
|
||||
// Group by category x specificity
|
||||
const cells = new Map<string, ParagraphWithVotes[]>();
|
||||
for (const p of eligible) {
|
||||
const key = `${p.stage1Category ?? "unknown"}|${p.stage1Specificity ?? 0}`;
|
||||
const cell = cells.get(key);
|
||||
if (cell) {
|
||||
cell.push(p);
|
||||
} else {
|
||||
cells.set(key, [p]);
|
||||
}
|
||||
}
|
||||
|
||||
const total = eligible.length;
|
||||
const selected: string[] = [];
|
||||
|
||||
// First pass: allocate floor proportions
|
||||
const cellAllocations: { key: string; allocated: number; remainder: number }[] = [];
|
||||
let allocated = 0;
|
||||
|
||||
for (const [key, members] of cells) {
|
||||
const exact = (members.length / total) * count;
|
||||
const floor = Math.floor(exact);
|
||||
cellAllocations.push({ key, allocated: floor, remainder: exact - floor });
|
||||
allocated += floor;
|
||||
}
|
||||
|
||||
// Second pass: distribute remainder by largest remainders
|
||||
let remaining = count - allocated;
|
||||
cellAllocations.sort((a, b) => b.remainder - a.remainder);
|
||||
for (const cell of cellAllocations) {
|
||||
if (remaining <= 0) break;
|
||||
cell.allocated++;
|
||||
remaining--;
|
||||
}
|
||||
|
||||
// Sample from each cell
|
||||
for (const { key, allocated: cellCount } of cellAllocations) {
|
||||
const members = cells.get(key)!;
|
||||
shuffle(members);
|
||||
const take = Math.min(cellCount, members.length);
|
||||
for (let i = 0; i < take; i++) {
|
||||
selected.push(members[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the default sampling config for 1,200 paragraphs.
|
||||
*/
|
||||
export function defaultSamplingConfig(): SamplingConfig {
|
||||
return {
|
||||
total: 1200,
|
||||
strata: [
|
||||
{
|
||||
name: "Mgmt↔RMP split votes",
|
||||
count: 120,
|
||||
filter: (p) =>
|
||||
hasCategorySplit(p, "Management Role", "Risk Management Process"),
|
||||
},
|
||||
{
|
||||
name: "None/Other↔Strategy splits",
|
||||
count: 80,
|
||||
filter: (p) =>
|
||||
hasCategorySplit(p, "None/Other", "Strategy Integration"),
|
||||
},
|
||||
{
|
||||
name: "Spec [3,4] splits",
|
||||
count: 80,
|
||||
filter: (p) => hasSpecificitySplit(p, 3, 4),
|
||||
},
|
||||
{
|
||||
name: "Board↔Mgmt splits",
|
||||
count: 80,
|
||||
filter: (p) =>
|
||||
hasCategorySplit(p, "Board Governance", "Management Role"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run stratified sampling. Returns selected paragraph IDs.
|
||||
*
|
||||
* Process:
|
||||
* 1. For each stratum, filter eligible paragraphs, randomly select `count`
|
||||
* 2. Already-selected paragraphs are excluded from later strata
|
||||
* 3. "Rare category guarantee": ensure >= 15 per category, extra for Incident Disclosure
|
||||
* 4. Final fill: proportional stratified random from category x specificity cells
|
||||
*/
|
||||
export function stratifiedSample(
|
||||
paragraphs: ParagraphWithVotes[],
|
||||
config: SamplingConfig,
|
||||
): string[] {
|
||||
const selected = new Set<string>();
|
||||
|
||||
// Phase 1: Named strata (split-vote strata)
|
||||
for (const stratum of config.strata) {
|
||||
const eligible = paragraphs.filter(
|
||||
(p) => !selected.has(p.id) && stratum.filter(p),
|
||||
);
|
||||
shuffle(eligible);
|
||||
const take = Math.min(stratum.count, eligible.length);
|
||||
for (let i = 0; i < take; i++) {
|
||||
selected.add(eligible[i].id);
|
||||
}
|
||||
console.log(
|
||||
` Stratum "${stratum.name}": wanted ${stratum.count}, eligible ${eligible.length}, selected ${take}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Phase 2: Rare category guarantee (120 slots, >= 15 per category)
|
||||
const RARE_GUARANTEE_TOTAL = 120;
|
||||
const MIN_PER_CATEGORY = 15;
|
||||
const rareStartSize = selected.size;
|
||||
|
||||
// Find all categories
|
||||
const categoryCounts = new Map<string, ParagraphWithVotes[]>();
|
||||
for (const p of paragraphs) {
|
||||
if (selected.has(p.id) || !p.stage1Category) continue;
|
||||
const cat = p.stage1Category;
|
||||
const bucket = categoryCounts.get(cat);
|
||||
if (bucket) {
|
||||
bucket.push(p);
|
||||
} else {
|
||||
categoryCounts.set(cat, [p]);
|
||||
}
|
||||
}
|
||||
|
||||
// Count how many of each category are already selected
|
||||
const selectedByCat = new Map<string, number>();
|
||||
for (const id of selected) {
|
||||
const p = paragraphs.find((pp) => pp.id === id);
|
||||
if (p?.stage1Category) {
|
||||
selectedByCat.set(
|
||||
p.stage1Category,
|
||||
(selectedByCat.get(p.stage1Category) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Top up categories that have fewer than MIN_PER_CATEGORY
|
||||
let rareAdded = 0;
|
||||
const allCategories = new Set<string>();
|
||||
for (const p of paragraphs) {
|
||||
if (p.stage1Category) allCategories.add(p.stage1Category);
|
||||
}
|
||||
|
||||
// Sort categories by current count ascending so rarest get filled first
|
||||
const sortedCats = [...allCategories].sort(
|
||||
(a, b) =>
|
||||
(selectedByCat.get(a) ?? 0) - (selectedByCat.get(b) ?? 0),
|
||||
);
|
||||
|
||||
for (const cat of sortedCats) {
|
||||
if (rareAdded >= RARE_GUARANTEE_TOTAL) break;
|
||||
const current = selectedByCat.get(cat) ?? 0;
|
||||
if (current >= MIN_PER_CATEGORY) continue;
|
||||
const need = MIN_PER_CATEGORY - current;
|
||||
const eligible = (categoryCounts.get(cat) ?? []).filter(
|
||||
(p) => !selected.has(p.id),
|
||||
);
|
||||
shuffle(eligible);
|
||||
const take = Math.min(need, eligible.length, RARE_GUARANTEE_TOTAL - rareAdded);
|
||||
for (let i = 0; i < take; i++) {
|
||||
selected.add(eligible[i].id);
|
||||
rareAdded++;
|
||||
}
|
||||
}
|
||||
|
||||
// Give extra slots to "Incident Disclosure" if budget remains
|
||||
if (rareAdded < RARE_GUARANTEE_TOTAL) {
|
||||
const incidentEligible = (
|
||||
categoryCounts.get("Incident Disclosure") ?? []
|
||||
).filter((p) => !selected.has(p.id));
|
||||
shuffle(incidentEligible);
|
||||
const take = Math.min(
|
||||
RARE_GUARANTEE_TOTAL - rareAdded,
|
||||
incidentEligible.length,
|
||||
);
|
||||
for (let i = 0; i < take; i++) {
|
||||
selected.add(incidentEligible[i].id);
|
||||
rareAdded++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
` Rare category guarantee: added ${selected.size - rareStartSize} (budget ${RARE_GUARANTEE_TOTAL})`,
|
||||
);
|
||||
|
||||
// Phase 3: Proportional stratified random fill
|
||||
const remaining = config.total - selected.size;
|
||||
if (remaining > 0) {
|
||||
const eligible = paragraphs.filter(
|
||||
(p) => !selected.has(p.id) && p.stage1Category != null,
|
||||
);
|
||||
const filled = proportionalSample(eligible, remaining);
|
||||
for (const id of filled) {
|
||||
selected.add(id);
|
||||
}
|
||||
console.log(
|
||||
` Proportional fill: added ${filled.length} (target ${remaining})`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(` Total selected: ${selected.size}`);
|
||||
return [...selected];
|
||||
}
|
||||
50
labelapp/lib/warmup-paragraphs.ts
Normal file
50
labelapp/lib/warmup-paragraphs.ts
Normal file
@ -0,0 +1,50 @@
|
||||
export interface WarmupParagraph {
|
||||
id: string;
|
||||
text: string;
|
||||
goldCategory: string;
|
||||
goldSpecificity: number;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export const WARMUP_PARAGRAPHS: WarmupParagraph[] = [
|
||||
{
|
||||
id: "warmup-1",
|
||||
text: "The Board of Directors oversees the Company's management of cybersecurity risks. The Board has delegated oversight of cybersecurity and data privacy matters to the Audit Committee, which receives quarterly reports from management on the Company's cybersecurity risk management program, recent threats, and any incidents.",
|
||||
goldCategory: "Board Governance",
|
||||
goldSpecificity: 3,
|
||||
explanation:
|
||||
"Board Governance because the Board of Directors and Audit Committee are the grammatical subjects performing the primary actions (overseeing, delegating, receiving reports). Specificity 3 (Firm-Specific) because the paragraph describes a specific delegation structure (to the Audit Committee) with a defined briefing cadence. Note: while 'Audit Committee' alone is generic (per the NOT list), the delegation of cybersecurity oversight to it and the described briefing structure constitute firm-specific organizational choices.",
|
||||
},
|
||||
{
|
||||
id: "warmup-2",
|
||||
text: "On January 15, 2024, we detected unauthorized access to our customer support portal. The threat actor exploited a known vulnerability in a third-party software component. Upon detection, we activated our incident response plan, contained the intrusion within four hours, and engaged Mandiant for forensic investigation. Approximately 12,000 customer records were potentially accessed.",
|
||||
goldCategory: "Incident Disclosure",
|
||||
goldSpecificity: 4,
|
||||
explanation:
|
||||
"Incident Disclosure because the paragraph describes what happened in a cybersecurity incident: the timeline, attack vector, response actions, and scope. Specificity 4 (Quantified-Verifiable) because it contains multiple hard verifiable facts: a specific date (January 15, 2024), a specific containment time (four hours), a named forensic firm (Mandiant), and a quantified impact (12,000 customer records). Four verifiable facts far exceeds the two-fact threshold for QV.",
|
||||
},
|
||||
{
|
||||
id: "warmup-3",
|
||||
text: "We maintain a cybersecurity risk management program that is designed to identify, assess, and manage material cybersecurity risks to our business. Our program is based on recognized industry frameworks and best practices.",
|
||||
goldCategory: "Risk Management Process",
|
||||
goldSpecificity: 1,
|
||||
explanation:
|
||||
"Risk Management Process because the paragraph describes the company's internal cybersecurity program and its purpose (identify, assess, manage risks). Specificity 1 (Generic Boilerplate) because this language could appear in any company's filing unchanged — it names no specific frameworks (just 'recognized industry frameworks'), no named tools, no named personnel, no dates, no quantities. Every phrase is generic boilerplate.",
|
||||
},
|
||||
{
|
||||
id: "warmup-4",
|
||||
text: "We increased our cybersecurity budget by 28% to $38M in fiscal 2024, representing approximately 0.6% of annual revenue. We maintain cyber liability insurance with $75M in aggregate coverage. Management believes these investments appropriately balance the Company's cybersecurity risk profile with its available resources.",
|
||||
goldCategory: "Strategy Integration",
|
||||
goldSpecificity: 4,
|
||||
explanation:
|
||||
"Strategy Integration because the paragraph discusses financial resource allocation (budget increase, insurance) and strategic judgment about cybersecurity investment — business/financial consequences of cyber risk. Specificity 4 (Quantified-Verifiable) because it contains multiple hard verifiable facts: budget percentage (28%), dollar amount ($38M), revenue percentage (0.6%), insurance coverage ($75M), and time period (fiscal 2024). Well above the two-fact QV threshold.",
|
||||
},
|
||||
{
|
||||
id: "warmup-5",
|
||||
text: "Our vendor risk management program requires all third-party service providers with access to sensitive data to meet minimum security standards, including SOC 2 Type II certification or equivalent third-party attestation. We conduct initial security assessments of new vendors and perform annual reassessments of existing relationships.",
|
||||
goldCategory: "Third-Party Risk",
|
||||
goldSpecificity: 2,
|
||||
explanation:
|
||||
"Third-Party Risk because the central topic is oversight of external parties' cybersecurity: vendor requirements, security assessments, and ongoing monitoring of third-party relationships. Specificity 2 (Sector-Adapted) because it names a recognized standard (SOC 2 Type II) but contains no firm-specific details — no specific vendor counts, no named vendors, no dollar amounts. The assessment cadences ('initial' and 'annual') are generic. The SOC 2 mention elevates it above Specificity 1 but there are no firm-specific facts to reach Specificity 3.",
|
||||
},
|
||||
];
|
||||
@ -13,8 +13,8 @@
|
||||
"sample": "bun run scripts/sample.ts",
|
||||
"assign": "bun run scripts/assign.ts",
|
||||
"export": "bun run scripts/export.ts",
|
||||
"test": "bun test && playwright test",
|
||||
"test:api": "bun test",
|
||||
"test": "bun test app/ lib/ && playwright test",
|
||||
"test:api": "bun test app/ lib/",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
@ -36,6 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bun": "^1.3.11",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
31
labelapp/proxy.ts
Normal file
31
labelapp/proxy.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { verifyAndDecode } from "@/lib/auth";
|
||||
|
||||
const protectedPrefixes = ["/dashboard", "/quiz", "/label", "/admin"];
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
const sessionCookie = request.cookies.get("session")?.value;
|
||||
|
||||
const isValid = sessionCookie ? verifyAndDecode(sessionCookie) !== null : false;
|
||||
|
||||
// If on login page with valid session, redirect to dashboard
|
||||
if (path === "/" && isValid) {
|
||||
return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||
}
|
||||
|
||||
// If on protected route without valid session, redirect to login
|
||||
const isProtected = protectedPrefixes.some((prefix) =>
|
||||
path.startsWith(prefix),
|
||||
);
|
||||
if (isProtected && !isValid) {
|
||||
return NextResponse.redirect(new URL("/", request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
57
labelapp/scripts/assign.ts
Normal file
57
labelapp/scripts/assign.ts
Normal file
@ -0,0 +1,57 @@
|
||||
process.env.DATABASE_URL ??=
|
||||
"postgresql://sec_cybert:sec_cybert@localhost:5432/sec_cybert";
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { ne } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import * as schema from "../db/schema";
|
||||
import { generateAssignments, printAssignmentStats } from "../lib/assignment";
|
||||
|
||||
const SAMPLED_IDS_PATH =
|
||||
"/home/joey/Documents/sec-cyBERT/labelapp/.sampled-ids.json";
|
||||
|
||||
async function main() {
|
||||
// 1. Read sampled paragraph IDs
|
||||
console.log("Reading sampled paragraph IDs...");
|
||||
const raw = await readFile(SAMPLED_IDS_PATH, "utf-8");
|
||||
const paragraphIds: string[] = JSON.parse(raw);
|
||||
console.log(` ${paragraphIds.length} paragraph IDs loaded`);
|
||||
|
||||
// 2. Read annotator IDs from DB (exclude admin)
|
||||
console.log("Loading annotators...");
|
||||
const annotators = await db
|
||||
.select({ id: schema.annotators.id })
|
||||
.from(schema.annotators)
|
||||
.where(ne(schema.annotators.id, "admin"));
|
||||
const annotatorIds = annotators.map((a) => a.id).sort();
|
||||
console.log(` ${annotatorIds.length} annotators: ${annotatorIds.join(", ")}`);
|
||||
|
||||
// 3. Generate BIBD assignments
|
||||
console.log("Generating BIBD assignments...");
|
||||
const assignments = generateAssignments(paragraphIds, annotatorIds, 3);
|
||||
|
||||
// 4. Print stats before inserting
|
||||
printAssignmentStats(assignments, annotatorIds);
|
||||
|
||||
// 5. Insert into DB in batches
|
||||
console.log("\nInserting assignments into DB...");
|
||||
const BATCH_SIZE = 1000;
|
||||
for (let i = 0; i < assignments.length; i += BATCH_SIZE) {
|
||||
const batch = assignments.slice(i, i + BATCH_SIZE);
|
||||
await db
|
||||
.insert(schema.assignments)
|
||||
.values(batch)
|
||||
.onConflictDoNothing();
|
||||
|
||||
const progress = Math.min(i + BATCH_SIZE, assignments.length);
|
||||
console.log(` Inserted ${progress}/${assignments.length} assignments`);
|
||||
}
|
||||
|
||||
console.log("Assignment complete.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Assignment failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
99
labelapp/scripts/export.ts
Normal file
99
labelapp/scripts/export.ts
Normal file
@ -0,0 +1,99 @@
|
||||
process.env.DATABASE_URL ??=
|
||||
"postgresql://sec_cybert:sec_cybert@localhost:5432/sec_cybert";
|
||||
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { db } from "../db";
|
||||
import * as schema from "../db/schema";
|
||||
|
||||
const OUTPUT_PATH =
|
||||
"/home/joey/Documents/sec-cyBERT/data/gold/gold-labels.jsonl";
|
||||
|
||||
async function main() {
|
||||
// 1. Load all adjudicated paragraphs
|
||||
console.log("Loading adjudications...");
|
||||
const adjudications = await db.select().from(schema.adjudications);
|
||||
console.log(` ${adjudications.length} adjudicated paragraphs`);
|
||||
|
||||
if (adjudications.length === 0) {
|
||||
console.log("No adjudications found. Nothing to export.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Load all human labels for adjudicated paragraphs
|
||||
console.log("Loading human labels...");
|
||||
const adjudicatedIds = new Set(adjudications.map((a) => a.paragraphId));
|
||||
const allHumanLabels = await db.select().from(schema.humanLabels);
|
||||
const relevantLabels = allHumanLabels.filter((l) =>
|
||||
adjudicatedIds.has(l.paragraphId),
|
||||
);
|
||||
console.log(
|
||||
` ${relevantLabels.length} human labels for adjudicated paragraphs`,
|
||||
);
|
||||
|
||||
// Group human labels by paragraph
|
||||
const labelsByParagraph = new Map<
|
||||
string,
|
||||
(typeof relevantLabels)[number][]
|
||||
>();
|
||||
for (const label of relevantLabels) {
|
||||
const group = labelsByParagraph.get(label.paragraphId);
|
||||
if (group) {
|
||||
group.push(label);
|
||||
} else {
|
||||
labelsByParagraph.set(label.paragraphId, [label]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Build GoldLabel records
|
||||
const goldLabels: object[] = [];
|
||||
|
||||
for (const adj of adjudications) {
|
||||
const humanLabels = (labelsByParagraph.get(adj.paragraphId) ?? []).map(
|
||||
(hl) => ({
|
||||
paragraphId: hl.paragraphId,
|
||||
annotatorId: hl.annotatorId,
|
||||
label: {
|
||||
content_category: hl.contentCategory,
|
||||
specificity_level: hl.specificityLevel,
|
||||
category_confidence: "high",
|
||||
specificity_confidence: "high",
|
||||
reasoning: hl.notes ?? "",
|
||||
},
|
||||
labeledAt: hl.labeledAt?.toISOString() ?? new Date().toISOString(),
|
||||
notes: hl.notes ?? undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const goldLabel = {
|
||||
paragraphId: adj.paragraphId,
|
||||
finalLabel: {
|
||||
content_category: adj.finalCategory,
|
||||
specificity_level: adj.finalSpecificity,
|
||||
category_confidence: "high",
|
||||
specificity_confidence: "high",
|
||||
reasoning: adj.notes ?? "",
|
||||
},
|
||||
adjudicationMethod: adj.method,
|
||||
humanLabels,
|
||||
};
|
||||
|
||||
goldLabels.push(goldLabel);
|
||||
}
|
||||
|
||||
// 4. Write JSONL
|
||||
const dir = dirname(OUTPUT_PATH);
|
||||
if (!existsSync(dir)) await mkdir(dir, { recursive: true });
|
||||
|
||||
const content = goldLabels.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
||||
await writeFile(OUTPUT_PATH, content);
|
||||
|
||||
console.log(`\nExported ${goldLabels.length} gold labels to ${OUTPUT_PATH}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Export failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
87
labelapp/scripts/sample.ts
Normal file
87
labelapp/scripts/sample.ts
Normal file
@ -0,0 +1,87 @@
|
||||
process.env.DATABASE_URL ??=
|
||||
"postgresql://sec_cybert:sec_cybert@localhost:5432/sec_cybert";
|
||||
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { db } from "../db";
|
||||
import * as schema from "../db/schema";
|
||||
import {
|
||||
type ParagraphWithVotes,
|
||||
defaultSamplingConfig,
|
||||
stratifiedSample,
|
||||
} from "../lib/sampling";
|
||||
|
||||
async function readJsonl<T = unknown>(path: string): Promise<T[]> {
|
||||
const text = await readFile(path, "utf-8");
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((l) => l.trim())
|
||||
.map((l) => JSON.parse(l) as T);
|
||||
}
|
||||
|
||||
interface AnnotationRow {
|
||||
paragraphId: string;
|
||||
label: {
|
||||
content_category: string;
|
||||
specificity_level: number;
|
||||
};
|
||||
}
|
||||
|
||||
const OUTPUT_PATH =
|
||||
"/home/joey/Documents/sec-cyBERT/labelapp/.sampled-ids.json";
|
||||
const ANNOTATIONS_PATH =
|
||||
"/home/joey/Documents/sec-cyBERT/data/annotations/stage1.jsonl";
|
||||
|
||||
async function main() {
|
||||
// 1. Load all paragraphs from DB
|
||||
console.log("Loading paragraphs from DB...");
|
||||
const dbParagraphs = await db.select().from(schema.paragraphs);
|
||||
console.log(` ${dbParagraphs.length} paragraphs loaded`);
|
||||
|
||||
// 2. Load raw annotations for split-vote detection
|
||||
console.log("Loading annotations for vote analysis...");
|
||||
const annotations = await readJsonl<AnnotationRow>(ANNOTATIONS_PATH);
|
||||
console.log(` ${annotations.length} annotations loaded`);
|
||||
|
||||
// Group votes by paragraph
|
||||
const votesByParagraph = new Map<
|
||||
string,
|
||||
{ categories: string[]; specificities: number[] }
|
||||
>();
|
||||
for (const a of annotations) {
|
||||
let votes = votesByParagraph.get(a.paragraphId);
|
||||
if (!votes) {
|
||||
votes = { categories: [], specificities: [] };
|
||||
votesByParagraph.set(a.paragraphId, votes);
|
||||
}
|
||||
votes.categories.push(a.label.content_category);
|
||||
votes.specificities.push(a.label.specificity_level);
|
||||
}
|
||||
|
||||
// 3. Build ParagraphWithVotes array
|
||||
const paragraphsWithVotes: ParagraphWithVotes[] = dbParagraphs.map((p) => {
|
||||
const votes = votesByParagraph.get(p.id);
|
||||
return {
|
||||
id: p.id,
|
||||
stage1Category: p.stage1Category,
|
||||
stage1Specificity: p.stage1Specificity,
|
||||
categoryVotes: votes?.categories ?? [],
|
||||
specificityVotes: votes?.specificities ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
// 4. Run stratified sampling
|
||||
console.log("Running stratified sampling...");
|
||||
const config = defaultSamplingConfig();
|
||||
const selectedIds = stratifiedSample(paragraphsWithVotes, config);
|
||||
|
||||
// 5. Write output
|
||||
await writeFile(OUTPUT_PATH, JSON.stringify(selectedIds, null, 2));
|
||||
console.log(`\nWrote ${selectedIds.length} sampled IDs to ${OUTPUT_PATH}`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Sampling failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
199
labelapp/scripts/seed.ts
Normal file
199
labelapp/scripts/seed.ts
Normal file
@ -0,0 +1,199 @@
|
||||
process.env.DATABASE_URL ??=
|
||||
"postgresql://sec_cybert:sec_cybert@localhost:5432/sec_cybert";
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { db } from "../db";
|
||||
import * as schema from "../db/schema";
|
||||
|
||||
async function readJsonl<T = unknown>(path: string): Promise<T[]> {
|
||||
const text = await readFile(path, "utf-8");
|
||||
return text
|
||||
.split("\n")
|
||||
.filter((l) => l.trim())
|
||||
.map((l) => JSON.parse(l) as T);
|
||||
}
|
||||
|
||||
interface ParagraphRow {
|
||||
id: string;
|
||||
text: string;
|
||||
textHash: string;
|
||||
wordCount: number;
|
||||
paragraphIndex: number;
|
||||
filing: {
|
||||
companyName: string;
|
||||
cik: string;
|
||||
ticker: string;
|
||||
filingType: string;
|
||||
filingDate: string;
|
||||
fiscalYear: number;
|
||||
accessionNumber: string;
|
||||
secItem: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AnnotationRow {
|
||||
paragraphId: string;
|
||||
label: {
|
||||
content_category: string;
|
||||
specificity_level: number;
|
||||
category_confidence: string;
|
||||
specificity_confidence: string;
|
||||
reasoning: string;
|
||||
};
|
||||
provenance: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function computeConsensus(annotations: AnnotationRow[]): {
|
||||
category: string;
|
||||
specificity: number;
|
||||
method: string;
|
||||
confidence: number;
|
||||
} {
|
||||
// Majority vote for category
|
||||
const catCounts = new Map<string, number>();
|
||||
for (const a of annotations) {
|
||||
const cat = a.label.content_category;
|
||||
catCounts.set(cat, (catCounts.get(cat) ?? 0) + 1);
|
||||
}
|
||||
let maxCatCount = 0;
|
||||
let majorityCategory = "";
|
||||
for (const [cat, count] of catCounts) {
|
||||
if (count > maxCatCount) {
|
||||
maxCatCount = count;
|
||||
majorityCategory = cat;
|
||||
}
|
||||
}
|
||||
|
||||
// Majority vote for specificity
|
||||
const specCounts = new Map<number, number>();
|
||||
for (const a of annotations) {
|
||||
const spec = a.label.specificity_level;
|
||||
specCounts.set(spec, (specCounts.get(spec) ?? 0) + 1);
|
||||
}
|
||||
let maxSpecCount = 0;
|
||||
let majoritySpecificity = 0;
|
||||
for (const [spec, count] of specCounts) {
|
||||
if (count > maxSpecCount) {
|
||||
maxSpecCount = count;
|
||||
majoritySpecificity = spec;
|
||||
}
|
||||
}
|
||||
|
||||
const total = annotations.length;
|
||||
const allAgreeCategory = maxCatCount === total;
|
||||
const allAgreeSpecificity = maxSpecCount === total;
|
||||
const method =
|
||||
allAgreeCategory && allAgreeSpecificity ? "unanimous" : "majority";
|
||||
// Confidence = fraction of annotators that agreed with majority on both
|
||||
const agreedOnBoth = annotations.filter(
|
||||
(a) =>
|
||||
a.label.content_category === majorityCategory &&
|
||||
a.label.specificity_level === majoritySpecificity,
|
||||
).length;
|
||||
const confidence = agreedOnBoth / total;
|
||||
|
||||
return {
|
||||
category: majorityCategory,
|
||||
specificity: majoritySpecificity,
|
||||
method,
|
||||
confidence,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const PARAGRAPHS_PATH =
|
||||
"/home/joey/Documents/sec-cyBERT/data/paragraphs/paragraphs-clean.jsonl";
|
||||
const ANNOTATIONS_PATH =
|
||||
"/home/joey/Documents/sec-cyBERT/data/annotations/stage1.jsonl";
|
||||
|
||||
// 1. Read annotations and compute consensus per paragraph
|
||||
console.log("Reading annotations...");
|
||||
const annotations = await readJsonl<AnnotationRow>(ANNOTATIONS_PATH);
|
||||
console.log(` ${annotations.length} annotations loaded`);
|
||||
|
||||
const annotationsByParagraph = new Map<string, AnnotationRow[]>();
|
||||
for (const a of annotations) {
|
||||
const group = annotationsByParagraph.get(a.paragraphId);
|
||||
if (group) {
|
||||
group.push(a);
|
||||
} else {
|
||||
annotationsByParagraph.set(a.paragraphId, [a]);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
` ${annotationsByParagraph.size} paragraphs have annotations`,
|
||||
);
|
||||
|
||||
const consensusMap = new Map<
|
||||
string,
|
||||
ReturnType<typeof computeConsensus>
|
||||
>();
|
||||
for (const [pid, anns] of annotationsByParagraph) {
|
||||
consensusMap.set(pid, computeConsensus(anns));
|
||||
}
|
||||
|
||||
// 2. Read paragraphs and insert in batches
|
||||
console.log("Reading paragraphs...");
|
||||
const paragraphs = await readJsonl<ParagraphRow>(PARAGRAPHS_PATH);
|
||||
console.log(` ${paragraphs.length} paragraphs loaded`);
|
||||
|
||||
const BATCH_SIZE = 1000;
|
||||
for (let i = 0; i < paragraphs.length; i += BATCH_SIZE) {
|
||||
const batch = paragraphs.slice(i, i + BATCH_SIZE);
|
||||
const rows = batch.map((p) => {
|
||||
const consensus = consensusMap.get(p.id);
|
||||
return {
|
||||
id: p.id,
|
||||
text: p.text,
|
||||
wordCount: p.wordCount,
|
||||
paragraphIndex: p.paragraphIndex,
|
||||
companyName: p.filing.companyName,
|
||||
cik: p.filing.cik,
|
||||
ticker: p.filing.ticker || null,
|
||||
filingType: p.filing.filingType,
|
||||
filingDate: p.filing.filingDate,
|
||||
fiscalYear: p.filing.fiscalYear,
|
||||
accessionNumber: p.filing.accessionNumber,
|
||||
secItem: p.filing.secItem,
|
||||
stage1Category: consensus?.category ?? null,
|
||||
stage1Specificity: consensus?.specificity ?? null,
|
||||
stage1Method: consensus?.method ?? null,
|
||||
stage1Confidence: consensus?.confidence ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
await db
|
||||
.insert(schema.paragraphs)
|
||||
.values(rows)
|
||||
.onConflictDoNothing();
|
||||
|
||||
const progress = Math.min(i + BATCH_SIZE, paragraphs.length);
|
||||
console.log(` Inserted ${progress}/${paragraphs.length} paragraphs`);
|
||||
}
|
||||
|
||||
// 3. Create annotator accounts
|
||||
console.log("Creating annotator accounts...");
|
||||
const annotatorAccounts = [
|
||||
{ id: "aaryan", displayName: "Aaryan", password: "sec-cybert" },
|
||||
{ id: "anuj", displayName: "Anuj", password: "sec-cybert" },
|
||||
{ id: "meghan", displayName: "Meghan", password: "sec-cybert" },
|
||||
{ id: "xander", displayName: "Xander", password: "sec-cybert" },
|
||||
{ id: "elisabeth", displayName: "Elisabeth", password: "sec-cybert" },
|
||||
{ id: "joey", displayName: "Joey", password: "sec-cybert" },
|
||||
{ id: "admin", displayName: "Admin", password: "sec-cybert" },
|
||||
];
|
||||
|
||||
await db
|
||||
.insert(schema.annotators)
|
||||
.values(annotatorAccounts)
|
||||
.onConflictDoNothing();
|
||||
|
||||
console.log(` Created ${annotatorAccounts.length} annotator accounts`);
|
||||
console.log("Seed complete.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Seed failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
4
labelapp/test-results/.last-run.json
Normal file
4
labelapp/test-results/.last-run.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
54
labelapp/tests/01-auth.spec.ts
Normal file
54
labelapp/tests/01-auth.spec.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Authentication", () => {
|
||||
test("login page loads", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("SEC cyBERT")).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Enter password")).toBeVisible();
|
||||
});
|
||||
|
||||
test("rejects wrong password", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.selectOption("#annotator", "joey");
|
||||
await page.getByPlaceholder("Enter password").fill("wrongpassword");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await expect(page.getByText("Invalid credentials")).toBeVisible();
|
||||
});
|
||||
|
||||
test("logs in with correct password and redirects to dashboard", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.selectOption("#annotator", "joey");
|
||||
await page.getByPlaceholder("Enter password").fill("SEC-cyBERT");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await page.waitForURL("/dashboard");
|
||||
await expect(page.getByText("Welcome, Joey")).toBeVisible();
|
||||
});
|
||||
|
||||
test("redirects unauthenticated users from dashboard to login", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.waitForURL("/");
|
||||
await expect(page.getByText("SEC cyBERT")).toBeVisible();
|
||||
});
|
||||
|
||||
test("logout clears session", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.selectOption("#annotator", "joey");
|
||||
await page.getByPlaceholder("Enter password").fill("SEC-cyBERT");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
await page.waitForURL("/dashboard");
|
||||
|
||||
await page.getByRole("button", { name: "Logout" }).click();
|
||||
await page.waitForURL("/");
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await page.waitForURL("/");
|
||||
});
|
||||
});
|
||||
35
labelapp/tests/02-quiz.spec.ts
Normal file
35
labelapp/tests/02-quiz.spec.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAs } from "./helpers/login";
|
||||
|
||||
test.describe("Quiz System", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, "joey");
|
||||
});
|
||||
|
||||
test("dashboard shows start labeling button", async ({ page }) => {
|
||||
await expect(page.getByText("Start Labeling Session")).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking start session navigates to quiz", async ({ page }) => {
|
||||
await page.getByText("Start Labeling Session").click();
|
||||
await page.waitForURL("/quiz");
|
||||
});
|
||||
|
||||
test("quiz page shows start quiz button", async ({ page }) => {
|
||||
await page.goto("/quiz");
|
||||
await expect(
|
||||
page.getByRole("button", { name: /start quiz/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("quiz shows 8 questions and allows answering", async ({ page }) => {
|
||||
await page.goto("/quiz");
|
||||
await page.getByRole("button", { name: /start quiz/i }).click();
|
||||
|
||||
// Should show question 1 of 8
|
||||
await expect(page.getByText(/question 1 of 8/i)).toBeVisible();
|
||||
|
||||
// Should show paragraph text and radio options
|
||||
await expect(page.locator('[data-slot="radio-group"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
9
labelapp/tests/helpers/login.ts
Normal file
9
labelapp/tests/helpers/login.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
export async function loginAs(page: Page, annotatorId: string) {
|
||||
await page.goto("/");
|
||||
await page.selectOption("#annotator", annotatorId);
|
||||
await page.getByPlaceholder("Enter password").fill("SEC-cyBERT");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
await page.waitForURL("/dashboard");
|
||||
}
|
||||
40
labelapp/tests/helpers/reset-db.ts
Normal file
40
labelapp/tests/helpers/reset-db.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://sec_cybert:sec_cybert@localhost:5432/sec_cybert";
|
||||
|
||||
const client = postgres(connectionString);
|
||||
const db = drizzle(client);
|
||||
|
||||
export async function resetTestData() {
|
||||
await db.execute(sql`DELETE FROM human_labels WHERE annotator_id LIKE 'e2e-%'`);
|
||||
await db.execute(sql`DELETE FROM adjudications WHERE adjudicator_id LIKE 'e2e-%'`);
|
||||
await db.execute(sql`DELETE FROM quiz_sessions WHERE annotator_id LIKE 'e2e-%'`);
|
||||
await db.execute(sql`DELETE FROM assignments WHERE annotator_id LIKE 'e2e-%'`);
|
||||
await db.execute(sql`DELETE FROM annotators WHERE id LIKE 'e2e-%'`);
|
||||
}
|
||||
|
||||
export async function seedE2EData() {
|
||||
// Create E2E test annotator
|
||||
await db.execute(sql`
|
||||
INSERT INTO annotators (id, display_name, password)
|
||||
VALUES ('e2e-tester', 'E2E Tester', 'e2e-tester')
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
|
||||
// Assign 3 paragraphs from the existing data
|
||||
await db.execute(sql`
|
||||
INSERT INTO assignments (paragraph_id, annotator_id)
|
||||
SELECT p.id, 'e2e-tester'
|
||||
FROM paragraphs p
|
||||
LIMIT 3
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
await client.end();
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user