620 lines
21 KiB
TypeScript
620 lines
21 KiB
TypeScript
"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, Clock, Pause } from "lucide-react";
|
|
import { useActiveTimer } from "@/hooks/use-active-timer";
|
|
|
|
function formatTime(ms: number): string {
|
|
const totalSeconds = Math.floor(ms / 1000);
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
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",
|
|
"Domain-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 timer = useActiveTimer();
|
|
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("");
|
|
timer.reset();
|
|
} catch {
|
|
setError("Network error");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [router, timer.reset]);
|
|
|
|
useEffect(() => {
|
|
fetchNext();
|
|
}, [fetchNext]);
|
|
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!category || !specificity || !paragraph || submitting) return;
|
|
|
|
const durationMs = timer.totalMs;
|
|
const activeMs = timer.activeMs;
|
|
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,
|
|
activeMs,
|
|
}),
|
|
});
|
|
|
|
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;
|
|
|
|
const specKeys: Record<string, number> = { q: 1, w: 2, e: 3, r: 4 };
|
|
if (specKeys[e.key.toLowerCase()]) {
|
|
e.preventDefault();
|
|
setSpecificity(specKeys[e.key.toLowerCase()]);
|
|
} 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">
|
|
<div className="flex items-center gap-1.5 text-sm tabular-nums">
|
|
{timer.isIdle ? (
|
|
<Pause className="size-3.5 text-amber-500" />
|
|
) : (
|
|
<Clock className="size-3.5 text-muted-foreground" />
|
|
)}
|
|
<span className={timer.isIdle ? "text-amber-500" : "text-muted-foreground"}>
|
|
{formatTime(timer.activeMs)}
|
|
</span>
|
|
{timer.isIdle && (
|
|
<span className="text-xs text-amber-500/70">idle</span>
|
|
)}
|
|
</div>
|
|
<Progress
|
|
value={progress.completed}
|
|
max={progress.total}
|
|
className="w-40"
|
|
>
|
|
<ProgressLabel>Progress</ProgressLabel>
|
|
<ProgressValue>
|
|
{() => `${progress.completed} / ${progress.total}`}
|
|
</ProgressValue>
|
|
</Progress>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Floating codebook button */}
|
|
<CodebookSidebar />
|
|
|
|
{/* 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"
|
|
>
|
|
{label}{" "}
|
|
<kbd className="ml-1 rounded border bg-muted px-1 py-0.5 text-xs text-muted-foreground">
|
|
{["Q", "W", "E", "R"][i]}
|
|
</kbd>
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 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>
|
|
|
|
{/* 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>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CodebookSidebar() {
|
|
return (
|
|
<div className="fixed bottom-6 right-6 z-50">
|
|
<Sheet>
|
|
<SheetTrigger className="inline-flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
<BookOpen className="size-6" />
|
|
</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="Domain-Adapted"
|
|
desc="Uses cybersecurity domain terminology (e.g., penetration testing, NIST CSF, SIEM, SOC) but nothing unique to THIS company."
|
|
/>
|
|
<SpecDef
|
|
level={3}
|
|
name="Firm-Specific"
|
|
desc="Contains at least one fact unique to THIS company: cybersecurity-specific titles (CISO, CTO, CIO), named non-generic committees, named individuals, 24/7 security operations."
|
|
/>
|
|
<SpecDef
|
|
level={4}
|
|
name="Quantified-Verifiable"
|
|
desc="Contains 1+ QV-eligible facts: specific numbers, dates, named external entities, named tools/products, verifiable certifications."
|
|
/>
|
|
</div>
|
|
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
|
|
Specificity rates the WHOLE paragraph — not just the category-relevant parts. Scan everything.
|
|
</p>
|
|
</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">
|
|
Is the paragraph about the person (credentials, background,
|
|
reporting lines) or the function (program activities, tools)?
|
|
Person → Management Role. Function → Risk Management Process.
|
|
</Rule>
|
|
<Rule title="QV threshold">
|
|
Need 2+ independently verifiable facts (dates, dollar amounts,
|
|
named firms, headcounts) for Specificity 4.
|
|
</Rule>
|
|
<Rule title="Materiality disclaimers">
|
|
Any "materially affected" assessment = Strategy Integration,
|
|
even if boilerplate. Pure cross-reference with no materiality
|
|
conclusion = None/Other.
|
|
</Rule>
|
|
<Rule title="Dual-topic paragraphs">
|
|
Choose the category whose content occupies the majority of the
|
|
paragraph — the primary communicative purpose.
|
|
</Rule>
|
|
</div>
|
|
</section>
|
|
<Separator className="my-4" />
|
|
<a
|
|
href="/codebook"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center text-sm font-medium text-primary hover:underline"
|
|
>
|
|
Open full codebook reference →
|
|
</a>
|
|
</div>
|
|
</ScrollArea>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|