labelapp v1

This commit is contained in:
Joey Eamigh 2026-03-29 00:32:24 -04:00
parent 3260a9c5d9
commit 3505c45cdc
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
63 changed files with 10301 additions and 98 deletions

View File

@ -26,6 +26,7 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bun": "^1.3.11",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

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
View 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&apos;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&apos;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&apos;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 (&ge;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 (&lt;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>
);
}

View 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);
});
});

View 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 });
}

View 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);
});
});

View 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 });
}

View 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);
});
});

View 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 });
}

View 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);
});
});

View 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,
},
});
}

View 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);
});
});

View 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,
});
}

View 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);
});
});

View 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,
});
}

View 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>
);
}

View 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>
);
}

View File

@ -2,7 +2,11 @@
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark {
@media (prefers-color-scheme: dark) {
@slot;
}
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
@ -83,38 +87,40 @@
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
} }
.dark { @media (prefers-color-scheme: dark) {
--background: oklch(0.145 0 0); :root {
--foreground: oklch(0.985 0 0); --background: oklch(0.145 0 0);
--card: oklch(0.205 0 0); --foreground: oklch(0.985 0 0);
--card-foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0);
--popover: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0);
--popover-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0);
--primary: oklch(0.922 0 0); --popover-foreground: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0); --primary: oklch(0.922 0 0);
--secondary: oklch(0.269 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary: oklch(0.269 0 0);
--muted: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted-foreground: oklch(0.708 0 0); --muted: oklch(0.269 0 0);
--accent: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0);
--accent-foreground: oklch(0.985 0 0); --accent: oklch(0.269 0 0);
--destructive: oklch(0.704 0.191 22.216); --accent-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%); --destructive: oklch(0.704 0.191 22.216);
--input: oklch(1 0 0 / 15%); --border: oklch(1 0 0 / 10%);
--ring: oklch(0.556 0 0); --input: oklch(1 0 0 / 15%);
--chart-1: oklch(0.87 0 0); --ring: oklch(0.556 0 0);
--chart-2: oklch(0.556 0 0); --chart-1: oklch(0.87 0 0);
--chart-3: oklch(0.439 0 0); --chart-2: oklch(0.556 0 0);
--chart-4: oklch(0.371 0 0); --chart-3: oklch(0.439 0 0);
--chart-5: oklch(0.269 0 0); --chart-4: oklch(0.371 0 0);
--sidebar: oklch(0.205 0 0); --chart-5: oklch(0.269 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-accent: oklch(0.269 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-ring: oklch(0.556 0 0); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
} }
@layer base { @layer base {

581
labelapp/app/label/page.tsx Normal file
View 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>
);
}

View 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>
);
}

View File

@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "SEC cyBERT Labeling Tool",
description: "Generated by create next app", description: "Human annotation tool for SEC cybersecurity disclosure quality",
}; };
export default function RootLayout({ export default function RootLayout({

View File

@ -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 ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="flex flex-1 items-center justify-center p-4">
<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"> <Card className="w-full max-w-sm">
<Image <CardHeader className="text-center">
className="dark:invert" <CardTitle className="text-2xl">SEC cyBERT</CardTitle>
src="/next.svg" <CardDescription>Labeling Tool</CardDescription>
alt="Next.js logo" </CardHeader>
width={100} <form onSubmit={handleLogin}>
height={20} <CardContent className="flex flex-col gap-5">
priority <div className="flex flex-col gap-2">
/> <Label htmlFor="annotator">Annotator</Label>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> <select
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> id="annotator"
To get started, edit the page.tsx file. value={selectedAnnotator}
</h1> onChange={(e) => setSelectedAnnotator(e.target.value)}
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> 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"
Looking for a starting point or more instructions? Head over to{" "} >
<a <option value="">Select your name</option>
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" {annotators.map((a) => (
className="font-medium text-zinc-950 dark:text-zinc-50" <option key={a.id} value={a.id}>
> {a.displayName}
Templates </option>
</a>{" "} ))}
or the{" "} </select>
<a </div>
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <div className="flex flex-col gap-2">
className="font-medium text-zinc-950 dark:text-zinc-50" <Label htmlFor="password">Password</Label>
> <Input
Learning id="password"
</a>{" "} type="password"
center. value={password}
</p> onChange={(e) => setPassword(e.target.value)}
</div> placeholder="Enter password"
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> />
<a </div>
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]" {error && (
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <p className="text-sm text-destructive">{error}</p>
target="_blank" )}
rel="noopener noreferrer" <Button type="submit" className="w-full" disabled={loading}>
> {loading ? "Signing in..." : "Sign In"}
<Image </Button>
className="dark:invert" </CardContent>
src="/vercel.svg" </form>
alt="Vercel logomark" </Card>
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> </div>
); );
} }

436
labelapp/app/quiz/page.tsx Normal file
View 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>{" "}
&mdash; Distinguishing management role descriptions from risk
management process descriptions
</li>
<li>
<span className="font-medium text-foreground">
Materiality Disclaimers
</span>{" "}
&mdash; Identifying materiality assessments vs. cross-references
</li>
<li>
<span className="font-medium text-foreground">
QV Fact Counting
</span>{" "}
&mdash; Determining specificity levels based on verifiable facts
</li>
<li>
<span className="font-medium text-foreground">
SPAC Exceptions
</span>{" "}
&mdash; 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>{" "}
&mdash;{" "}
<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>{" "}
&mdash;{" "}
<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 &amp; Retry
</Button>
)}
</CardFooter>
</Card>
</div>
);
}
return null;
}

3
labelapp/bunfig.toml Normal file
View File

@ -0,0 +1,3 @@
[test]
# Exclude Playwright E2E tests (they use their own runner)
exclude = ["tests/**", "node_modules/**"]

View File

@ -5,7 +5,7 @@
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "src/app/globals.css", "css": "app/globals.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""

View 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 }

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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,
}

View 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 }

View 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,
}

View 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 }

View 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 }

View 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 }

View 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
}

View 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
View 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
View 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
View 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;
}

View 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
View 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];
}

View 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.",
},
];

View File

@ -13,8 +13,8 @@
"sample": "bun run scripts/sample.ts", "sample": "bun run scripts/sample.ts",
"assign": "bun run scripts/assign.ts", "assign": "bun run scripts/assign.ts",
"export": "bun run scripts/export.ts", "export": "bun run scripts/export.ts",
"test": "bun test && playwright test", "test": "bun test app/ lib/ && playwright test",
"test:api": "bun test", "test:api": "bun test app/ lib/",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
@ -36,6 +36,7 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bun": "^1.3.11",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

31
labelapp/proxy.ts Normal file
View 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).*)"],
};

View 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);
});

View 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);
});

View 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
View 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);
});

View File

@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View 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("/");
});
});

View 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();
});
});

View 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");
}

View 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();
}