labelapp scaffold

This commit is contained in:
Joey Eamigh 2026-03-28 23:44:37 -04:00
parent b1503a1942
commit 3260a9c5d9
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
67 changed files with 3152 additions and 62 deletions

32
.gitignore vendored
View File

@ -18,3 +18,35 @@ __pycache__/
# OS
.DS_Store
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# Finder (MacOS) folder config
.DS_Store

29
CLAUDE.md Normal file
View File

@ -0,0 +1,29 @@
# sec-cyBERT
Bun workspace monorepo. Three packages:
- `packages/schemas/` — shared Zod schemas (`@sec-cybert/schemas`). Import directly by path: `from "@sec-cybert/schemas/label.ts"`
- `ts/` — GenAI labeling pipeline (CLI scripts, Vercel AI SDK, OpenRouter)
- `labelapp/` — Next.js human labeling webapp (Drizzle, Postgres, shadcn/ui, Playwright)
## Quick reference
| What | Where |
|------|-------|
| Shared schemas (Zod) | `packages/schemas/src/` |
| Labeling codebook (source of truth for all category/specificity definitions) | `docs/LABELING-CODEBOOK.md` |
| Project narrative (decisions, roadblocks, lessons) | `docs/NARRATIVE.md` |
| Implementation plan for labelapp | `docs/labelapp-plan.md` |
| Labelapp-specific agent guide | `labelapp/AGENTS.md` |
| Docker compose (Postgres) | `docker-compose.yaml` (root) |
| DB credentials | `sec_cybert` / `sec_cybert` / `sec_cybert` on localhost:5432 |
## Rules
- `bun` for all JS/TS. `uv` for Python.
- No barrel files. Direct path-based imports only.
- No TODO comments. Finish what you start.
- No parallel codepaths. Find and extend existing code before writing new.
- Schemas live in `packages/schemas/` — do not duplicate type definitions elsewhere.
- `labelapp/` uses flat layout (no `src/` dir): `app/`, `db/`, `lib/`, `components/` at root.
- Tests: `bun test` for backend route integration (`__test__/` dirs adjacent to routes), Playwright for E2E (`tests/`).

1727
bun.lock Normal file

File diff suppressed because it is too large Load Diff

620
docs/labelapp-plan.md Normal file
View File

@ -0,0 +1,620 @@
# Human Labeling Webapp — Implementation Plan
## Context
The SEC cyBERT project needs 1,200 human-labeled paragraphs as a gold holdout set. 6 student annotators, 3 per paragraph, 600 per person. The narrative specifies a "quiz-gated labeling web tool that enforces codebook knowledge before each session." No webapp or monorepo structure exists yet.
---
## Phase 0: Monorepo Restructure
Convert the repo to a Bun workspace monorepo and extract shared schemas into a package.
### New repo structure
```
sec-cyBERT/
package.json # workspace root: { "workspaces": ["packages/*", "ts", "labelapp"] }
bun.lock # moved from ts/bun.lock
packages/
schemas/
package.json # { "name": "@sec-cybert/schemas" }
tsconfig.json
src/
label.ts # moved from ts/src/schemas/label.ts
paragraph.ts
annotation.ts
consensus.ts
gold.ts
benchmark.ts
session.ts
ts/
package.json # adds dependency: "@sec-cybert/schemas": "workspace:*"
tsconfig.json # add project reference to ../packages/schemas
src/
schemas/ # DELETED (moved to packages/schemas)
...
scripts/
...
labelapp/
package.json # Next.js app, depends on @sec-cybert/schemas
...
```
### Import rewrite
All imports in `ts/src/` and `ts/scripts/` change from relative schema paths to the package:
```typescript
// Before (ts/src/label/annotate.ts)
import { LabelOutputRaw, toLabelOutput } from "../schemas/label.ts";
import type { Paragraph } from "../schemas/paragraph.ts";
// After
import { LabelOutputRaw, toLabelOutput } from "@sec-cybert/schemas/label.ts";
import type { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
```
**No barrel file.** Direct path-based imports. The existing `ts/src/schemas/index.ts` is deleted.
### Files to rewrite imports in (~30 files)
**ts/src/ (15 files):** cli.ts, label/annotate.ts, label/batch.ts, label/consensus.ts, label/prompts.ts, analyze/corpus-stats.ts, analyze/data-quality.ts, analyze/dedup-analysis.ts, extract/pipeline.ts, extract/segment.ts, extract/fast-reparse.ts
**ts/scripts/ (14 files):** dispute-crosstab.ts, model-bench.ts, sample-disputes.ts, mimo-pilot.ts, model-bias-analysis.ts, segment-analysis.ts, mimo-raw-test.ts, mimo-test.ts, judge-bench.ts, judge-diag-batch.ts, judge-diag.ts, pilot.ts, stage1-run.ts, model-probe.ts
Can be done with a sed command per pattern:
- `"../schemas/``"@sec-cybert/schemas/`
- `"./schemas/``"@sec-cybert/schemas/`
- `"../src/schemas/``"@sec-cybert/schemas/`
### tsconfig setup
**packages/schemas/tsconfig.json:**
```json
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"composite": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}
```
**ts/tsconfig.json** — add project reference:
```json
{
"compilerOptions": { ... },
"references": [{ "path": "../packages/schemas" }]
}
```
### Verification
- `bun install` from root resolves all workspaces
- `bun run --filter ts typecheck` passes
- Existing scripts still work: `bun run --filter ts sec -- label:cost`
---
## Phase 1: Labelapp — Next.js + Drizzle + Postgres
### Stack
- **Next.js** (App Router, turbopack dev)
- **Tailwind CSS v4** + **shadcn/ui** (components: RadioGroup, Button, Card, Sidebar, Collapsible, Table, Charts)
- **lucide-react** for icons (shadcn default)
- **Drizzle ORM** + `drizzle-kit` (schema push, no migration files needed for this)
- **Postgres 18** via docker-compose (named volume for data)
- **Bun** as package manager (auto-uses Node for Next.js dev/build)
- **@sec-cybert/schemas** workspace dependency
- **Playwright** for E2E tests
### Postgres (already running)
Docker compose is already set up and running at the repo root. DB: `sec_cybert`, user: `sec_cybert`, password: `sec_cybert`, port 5432.
```
DATABASE_URL=postgresql://sec_cybert:sec_cybert@localhost:5432/sec_cybert
```
### Drizzle schema (labelapp/db/schema.ts)
```typescript
import { pgTable, text, integer, real, timestamp, boolean, unique } from "drizzle-orm/pg-core";
export const paragraphs = pgTable("paragraphs", {
id: text("id").primaryKey(), // UUID from Paragraph.id
text: text("text").notNull(),
wordCount: integer("word_count").notNull(),
paragraphIndex: integer("paragraph_index").notNull(),
companyName: text("company_name").notNull(),
cik: text("cik").notNull(),
ticker: text("ticker"),
filingType: text("filing_type").notNull(),
filingDate: text("filing_date").notNull(),
fiscalYear: integer("fiscal_year").notNull(),
accessionNumber: text("accession_number").notNull(),
secItem: text("sec_item").notNull(),
// Stage 1 consensus (for stratification, not shown to annotators during labeling)
stage1Category: text("stage1_category"),
stage1Specificity: integer("stage1_specificity"),
stage1Method: text("stage1_method"),
stage1Confidence: real("stage1_confidence"),
});
export const annotators = pgTable("annotators", {
id: text("id").primaryKey(), // slug: "joey", "alice", etc.
displayName: text("display_name").notNull(),
password: text("password").notNull(), // plaintext (just their name)
});
export const assignments = pgTable("assignments", {
paragraphId: text("paragraph_id").notNull().references(() => paragraphs.id),
annotatorId: text("annotator_id").notNull().references(() => annotators.id),
assignedAt: timestamp("assigned_at").notNull().defaultNow(),
isWarmup: boolean("is_warmup").notNull().default(false),
}, (t) => [unique().on(t.paragraphId, t.annotatorId)]);
export const humanLabels = pgTable("human_labels", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
paragraphId: text("paragraph_id").notNull().references(() => paragraphs.id),
annotatorId: text("annotator_id").notNull().references(() => annotators.id),
contentCategory: text("content_category").notNull(),
specificityLevel: integer("specificity_level").notNull(),
notes: text("notes"),
labeledAt: timestamp("labeled_at").notNull().defaultNow(),
sessionId: text("session_id").notNull(),
durationMs: integer("duration_ms"),
}, (t) => [unique().on(t.paragraphId, t.annotatorId)]);
export const quizSessions = pgTable("quiz_sessions", {
id: text("id").primaryKey(), // UUID
annotatorId: text("annotator_id").notNull().references(() => annotators.id),
startedAt: timestamp("started_at").notNull().defaultNow(),
completedAt: timestamp("completed_at"),
passed: boolean("passed").notNull().default(false),
score: integer("score").notNull().default(0),
totalQuestions: integer("total_questions").notNull(),
answers: text("answers").notNull().default("[]"), // JSON
});
export const adjudications = pgTable("adjudications", {
paragraphId: text("paragraph_id").primaryKey().references(() => paragraphs.id),
finalCategory: text("final_category").notNull(),
finalSpecificity: integer("final_specificity").notNull(),
method: text("method").notNull(), // consensus | majority | discussion
adjudicatorId: text("adjudicator_id"),
notes: text("notes"),
resolvedAt: timestamp("resolved_at").notNull().defaultNow(),
});
```
### Labelapp file structure (no src/ — flat root, consistent with shadcn)
```
labelapp/
package.json
next.config.ts
tsconfig.json # @/* maps to ./*
drizzle.config.ts
playwright.config.ts
components.json # shadcn config
.env.local # DATABASE_URL
db/
index.ts # drizzle client
schema.ts # tables above
lib/
utils.ts # shadcn cn() helper
sampling.ts # stratified sampling logic
assignment.ts # BIBD assignment generation
metrics.ts # Cohen's kappa, Krippendorff's alpha
quiz-questions.ts # question bank
components/
ui/ # shadcn components
app/
layout.tsx # root layout
globals.css # Tailwind + shadcn theme
page.tsx # login screen
dashboard/
page.tsx # annotator dashboard (progress, start session)
quiz/
page.tsx # quiz flow
label/
page.tsx # main labeling interface
admin/
page.tsx # adjudication queue + metrics dashboard
api/
auth/route.ts # login/logout
quiz/route.ts # start quiz, submit answers
label/route.ts # get next paragraph, submit label
warmup/route.ts # get warmup paragraph, submit + get feedback
adjudicate/route.ts # get queue, resolve
metrics/route.ts # IRR metrics
export/route.ts # trigger gold label export
scripts/
seed.ts # import paragraphs + consensus from JSONL, create annotators
sample.ts # stratified sample → 1,200 paragraphs
assign.ts # BIBD assignment → 3,600 rows
export.ts # dump gold labels to JSONL (GoldLabel schema format)
tests/
helpers/
reset-db.ts
login.ts
00-setup.spec.ts
01-auth.spec.ts
...
```
### Package.json scripts
```json
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"seed": "bun run scripts/seed.ts",
"sample": "bun run scripts/sample.ts",
"assign": "bun run scripts/assign.ts",
"export": "bun run scripts/export.ts"
}
}
```
---
## Phase 2: Core Features
### Authentication
- Login page: annotator ID dropdown + password field (password = their name)
- Server-side session via Next.js cookies (signed, httpOnly)
- Middleware checks auth on all `/dashboard`, `/quiz`, `/label`, `/admin` routes
- No external auth library needed — just `cookies()` API
### Quiz System
- **Question bank** (~30 questions) in `lib/quiz-questions.ts`, 4 types:
- Person-vs-function (8-10): "Is this Management Role or RMP?"
- Materiality disclaimers (6-8): "Strategy Integration or None/Other?"
- QV fact counting (6-8): "Specificity 3 or 4?"
- SPAC exception (3-4): "What category for this shell company?"
- **Per session:** 8 questions (2 per type, random draw), pass = 7/8
- Immediate feedback with codebook explanation after each answer
- Failed → review mistakes → retry. Passed → proceed to warmup.
- Session stored in `quiz_sessions`, referenced by `human_labels.session_id`
- Session expires on 2-hour idle (checked server-side)
### Warm-up (5 paragraphs per session)
- Pre-selected paragraphs with known gold labels + feedback text
- Identical UI to labeling, but after submit: shows gold answer + explanation
- Not counted toward gold set (`assignments.is_warmup = true`)
### Labeling Interface
- **Top bar:** Filing metadata (company, ticker, filing type, date)
- **Center:** Paragraph text, large and readable
- **Form:**
- Content category: 7 radio buttons with short labels
- Specificity level: 4 radio buttons (Generic Boilerplate / Sector-Adapted / Firm-Specific / Quantified-Verifiable)
- Notes: optional textarea
- Submit button
- **Keyboard shortcuts:** 1-7 category, Shift+1-4 specificity, Enter submit
- **Sidebar (collapsible):** Codebook quick-reference (category defs, IS/NOT lists, decision rules)
- **Progress bar:** "47 / 600 completed"
- **Hidden timer:** tracks `duration_ms` per paragraph
### Sampling (1,200 from ~50K)
Script reads Stage 1 consensus from JSONL, stratifies:
| Stratum | Count | Source |
|---------|-------|--------|
| Mgmt↔RMP split votes | 120 | Paragraphs where Stage 1 annotators disagreed on this axis |
| None/Other↔Strategy splits | 80 | Materiality disclaimer boundary |
| Spec [3,4] splits | 80 | QV counting boundary |
| Board↔Mgmt splits | 80 | Board/management boundary |
| Rare category guarantee | 120 | ≥15 per category, extra for Incident Disclosure |
| Proportional stratified random | 720 | Fill from category×specificity cells |
### Assignment: BIBD
C(6,3)=20 unique triples of 6 annotators. Assign 60 paragraphs to each triple. Each annotator appears in C(5,2)=10 triples → 10×60 = 600 paragraphs. Every annotator pair shares equal overlap → pairwise kappa is statistically valid.
### Adjudication
**Auto-resolve** after 3 labels:
- 3/3 agree both dims → consensus
- 2/3 agree both dims → majority
- Otherwise → flagged for admin
**Admin page:**
- Queue sorted by severity (3-way splits first)
- Shows paragraph + all 3 labels side-by-side + notes + Stage 1 reference
- Resolution: pick a label, enter custom, or mark for team discussion
- Stores in `adjudications` table
### Metrics Dashboard (admin page)
- Overall progress: N/1,200 fully labeled, N adjudicated
- Cohen's Kappa (category): pairwise 6×6 matrix + average. Target ≥ 0.75
- Krippendorff's Alpha (specificity): single number. Target ≥ 0.67
- Raw consensus rate. Target ≥ 75%
- Per-category confusion matrix (7×7)
- Per-annotator stats: completion, agreement rate, distribution
- Confusion axis disagreement rates
### Export
Script dumps adjudicated gold labels to `data/gold/gold-labels.jsonl` in the existing `GoldLabel` schema format, readable by `readJsonl(path, GoldLabel)`.
---
## Reused Existing Code
| What | Source | Used by |
|------|--------|---------|
| `ContentCategory`, `SpecificityLevel`, `LabelOutput` | `@sec-cybert/schemas/label.ts` | Label validation, quiz answers |
| `Paragraph`, `FilingMeta` | `@sec-cybert/schemas/paragraph.ts` | Seed script, display |
| `HumanLabel`, `GoldLabel` | `@sec-cybert/schemas/gold.ts` | Export script format |
| `Annotation` | `@sec-cybert/schemas/annotation.ts` | Seed script (Stage 1 data) |
| `readJsonl()` | `ts/src/lib/jsonl.ts` | Seed script (import from JSONL) |
| Codebook content | `docs/LABELING-CODEBOOK.md` | Quiz questions, sidebar reference |
Note: `readJsonl` stays in `ts/src/lib/` — the seed script imports it directly via relative path or we extract it to the schemas package if needed. Since it depends on Zod (which schemas already has), it could live there.
---
## Setup Sequence
```bash
# 1. Monorepo setup
bun install # from repo root, resolves all workspaces
# 2. Verify existing pipeline still works
bun run --filter ts typecheck
# 3. Start Postgres
cd labelapp && docker compose up -d
# 4. Push schema
bun run db:push
# 5. Seed data
bun run seed # imports paragraphs + consensus + creates annotators
# 6. Sample + assign
bun run sample # stratified sample → 1,200 paragraphs marked
bun run assign # BIBD → 3,600 assignment rows
# 7. Start dev server
bun run dev # Next.js on :3000
# After labeling complete:
bun run export # → data/gold/gold-labels.jsonl
```
---
## Implementation Order
1. **Monorepo restructure** — root package.json, extract schemas, rewrite imports, verify typecheck
2. **Labelapp scaffold** — Next.js init, Drizzle schema, db connection, Playwright setup
3. **Seed + sample + assign scripts** — data pipeline into Postgres
4. **Auth** — login page, session cookies, middleware
5. **Quiz system** — question bank, quiz flow page, session gating
6. **Labeling UI** — the core: next paragraph, submit label, progress tracking, keyboard shortcuts, codebook sidebar
7. **Warm-up flow** — 5 pre-labeled paragraphs with feedback
8. **Admin: adjudication** — queue, resolution UI
9. **Admin: metrics dashboard** — kappa, alpha, confusion matrix, per-annotator stats
10. **Export script** — gold labels to JSONL
**Each phase ends with Playwright tests that verify it works E2E before moving on.**
---
## Testing Strategy
### Philosophy
No unit tests. Integration/E2E only. Two layers:
1. **Backend route tests** (`bun test`) — hit real API routes against real Postgres, verify responses/DB state
2. **Playwright E2E** — click through the real UI in a real browser
### Backend Route Tests (bun test)
- Colocated `__test__` dirs adjacent to each route handler
- Tests import the route handler directly (or use `fetch` against the dev server)
- Run against real Postgres (same `DATABASE_URL`)
- Each test file resets relevant tables via Drizzle before running
```
labelapp/app/api/
auth/
route.ts
__test__/
auth.test.ts # login/logout, session validation
quiz/
route.ts
__test__/
quiz.test.ts # start quiz, submit answers, pass/fail logic
label/
route.ts
__test__/
label.test.ts # get next paragraph, submit label, skip completed
warmup/
route.ts
__test__/
warmup.test.ts # get warmup, submit + get feedback
adjudicate/
route.ts
__test__/
adjudicate.test.ts # get queue, resolve, verify DB state
metrics/
route.ts
__test__/
metrics.test.ts # kappa/alpha values, progress counts
export/
route.ts
__test__/
export.test.ts # trigger export, verify JSONL output
```
### Playwright E2E
- Playwright installed in `labelapp/` as dev dependency
- Tests in `labelapp/tests/` (Playwright default)
- `playwright.config.ts` configured to:
- Start Next.js dev server automatically via `webServer` config
- Use the real Postgres (same `DATABASE_URL`)
- Run tests serially (stateful DB)
- **Test DB reset:** Each test file starts by truncating relevant tables (not dropping — schema stays). A `tests/helpers/reset-db.ts` util handles this via Drizzle.
### Test Files & What They Cover
**`tests/00-setup.spec.ts` — Data pipeline scripts**
- Run `bun run seed` via `execSync`, verify paragraphs table has rows
- Run `bun run sample`, verify exactly 1,200 paragraphs marked (or a `sampled` flag / separate table)
- Run `bun run assign`, verify 3,600 assignment rows, each annotator has 600
- Verify BIBD property: every annotator pair shares equal paragraph count
**`tests/01-auth.spec.ts` — Login flow**
- Navigate to `/`, see login form
- Login with wrong password → error message shown
- Login with correct password → redirected to `/dashboard`
- Access `/dashboard` without login → redirected to `/`
- Logout → session cleared, redirected to `/`
**`tests/02-quiz.spec.ts` — Quiz gating**
- Login, navigate to dashboard, click "Start Session"
- Verify quiz page loads with 8 questions
- Answer all correctly → see "Passed" message, "Begin labeling" button appears
- Start new session, answer 2 wrong → see "Failed", can retry
- Verify cannot access `/label` without a passed quiz session
**`tests/03-warmup.spec.ts` — Warm-up flow**
- After passing quiz, verify 5 warm-up paragraphs shown first
- Submit a label → gold answer + explanation revealed
- After 5 warm-ups → transition to real labeling
- Verify warm-up labels are NOT counted in progress stats
**`tests/04-labeling.spec.ts` — Core labeling flow**
- Verify paragraph text + filing metadata displayed
- Select category via radio button, select specificity, submit
- Verify redirected to next paragraph, progress increments
- Verify keyboard shortcuts work (press "1" → first category selected, etc.)
- Submit several labels, verify they're stored in DB
- Verify codebook sidebar toggles open/close
- Verify "next" skips already-completed paragraphs (label one, refresh, get a different one)
**`tests/05-adjudication.spec.ts` — Multi-annotator + admin flow**
- Seed 3 test annotators with assignments for the same paragraph
- Login as each, pass quiz, label the same paragraph with DIFFERENT labels
- Login as admin, navigate to `/admin`
- Verify the disputed paragraph appears in the adjudication queue
- Resolve it (pick one label), verify adjudication stored
- Verify it no longer appears in queue
**`tests/06-metrics.spec.ts` — Dashboard metrics**
- Seed known label data (pre-computed expected kappa/alpha values)
- Navigate to admin metrics page
- Verify progress numbers match expected
- Verify consensus rate displayed and reasonable
- Verify per-annotator stats shown
**`tests/07-export.spec.ts` — Gold export**
- Ensure some adjudicated labels exist (from prior tests or seeded)
- Run `bun run export` via `execSync`
- Read the output JSONL file
- Parse each line, verify it matches GoldLabel schema
- Verify paragraph count matches expected
### Running Tests
```bash
cd labelapp
# Backend route tests (fast, no browser)
bun test # runs all __test__/*.test.ts
bun test app/api/label/__test__/ # run one route's tests
# Playwright E2E (browser)
bunx playwright install --with-deps chromium # one-time browser install
bunx playwright test # runs all tests serially
bunx playwright test tests/04-labeling.spec.ts # run one file
# Both
bun test && bunx playwright test # full suite
```
### Package.json test scripts
```json
{
"scripts": {
"test": "bun test && playwright test",
"test:api": "bun test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}
```
### How Agents Use This
Each implementation phase follows this cycle:
1. Write the feature code
2. Write the backend route test (`__test__/` adjacent to route) AND/OR Playwright test
3. Run `bun test` for route logic, `bunx playwright test` for UI flows
4. If tests fail, fix the code and re-run
5. Only move to the next phase when all tests pass
The test suite is cumulative — tests from earlier phases keep running, ensuring nothing regresses. An agent completing phase 6 (labeling UI) runs the full suite to confirm everything still works.
### Test Helpers
**Shared** (`src/lib/__test__/helpers.ts` or similar):
- `resetDb()` — truncate tables between test files via Drizzle
- `seedTestData()` — insert known paragraphs/assignments/labels for test scenarios
**Playwright-specific** (`tests/helpers/`):
- `login.ts` — reusable: login as annotator X, pass quiz, get to labeling
- `reset-db.ts` — calls resetDb() for Playwright test setup
The `login.ts` helper is critical — it encapsulates the login → quiz → warmup flow so that labeling/adjudication tests don't have to repeat that ceremony.
---
## Verification (automated)
Two test suites, both must pass:
### `bun test` — Backend route integration tests
| Route | Test file | Verifies |
|-------|-----------|----------|
| `api/auth` | `__test__/auth.test.ts` | Login/logout, bad password rejection, session cookies |
| `api/quiz` | `__test__/quiz.test.ts` | Start quiz, submit answers, pass/fail threshold, session creation |
| `api/label` | `__test__/label.test.ts` | Get next paragraph, submit label to DB, skip completed, enforce quiz gate |
| `api/warmup` | `__test__/warmup.test.ts` | Get warmup paragraph, submit + receive gold feedback |
| `api/adjudicate` | `__test__/adjudicate.test.ts` | Get disagreement queue, resolve, verify DB state |
| `api/metrics` | `__test__/metrics.test.ts` | Kappa/alpha with known data, progress counts |
| `api/export` | `__test__/export.test.ts` | Trigger export, verify JSONL matches GoldLabel schema |
### `bunx playwright test` — Browser E2E
| Test file | Verifies |
|-----------|----------|
| `00-setup.spec.ts` | Seed/sample/assign scripts produce correct DB state |
| `01-auth.spec.ts` | Login form, redirect on auth failure, logout |
| `02-quiz.spec.ts` | Quiz renders, pass/fail gating, retry flow |
| `03-warmup.spec.ts` | 5 warm-ups with feedback, transition to real labeling |
| `04-labeling.spec.ts` | Paragraph display, radio buttons, keyboard shortcuts, progress bar, codebook sidebar |
| `05-adjudication.spec.ts` | 3 annotators disagree → admin queue → resolution |
| `06-metrics.spec.ts` | Dashboard renders with correct numbers |
| `07-export.spec.ts` | Export script produces valid JSONL |
### Pre-test gates
- `bun install` from root succeeds
- `bun run --filter ts typecheck` passes (monorepo didn't break existing pipeline)
- Postgres is reachable, `bun run db:push` succeeds
**Success criteria:** `cd labelapp && bun test && bunx playwright test` exits 0.

41
labelapp/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
labelapp/AGENTS.md Normal file
View File

@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
labelapp/CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

36
labelapp/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

BIN
labelapp/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

130
labelapp/app/globals.css Normal file
View File

@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

33
labelapp/app/layout.tsx Normal file
View File

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

65
labelapp/app/page.tsx Normal file
View File

@ -0,0 +1,65 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

25
labelapp/components.json Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@ -0,0 +1,60 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

8
labelapp/db/index.ts Normal file
View File

@ -0,0 +1,8 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);
export const db = drizzle(client, { schema });

95
labelapp/db/schema.ts Normal file
View File

@ -0,0 +1,95 @@
import {
pgTable,
text,
integer,
real,
timestamp,
boolean,
unique,
} from "drizzle-orm/pg-core";
export const paragraphs = pgTable("paragraphs", {
id: text("id").primaryKey(),
text: text("text").notNull(),
wordCount: integer("word_count").notNull(),
paragraphIndex: integer("paragraph_index").notNull(),
companyName: text("company_name").notNull(),
cik: text("cik").notNull(),
ticker: text("ticker"),
filingType: text("filing_type").notNull(),
filingDate: text("filing_date").notNull(),
fiscalYear: integer("fiscal_year").notNull(),
accessionNumber: text("accession_number").notNull(),
secItem: text("sec_item").notNull(),
// Stage 1 consensus (for stratification, not shown to annotators during labeling)
stage1Category: text("stage1_category"),
stage1Specificity: integer("stage1_specificity"),
stage1Method: text("stage1_method"),
stage1Confidence: real("stage1_confidence"),
});
export const annotators = pgTable("annotators", {
id: text("id").primaryKey(),
displayName: text("display_name").notNull(),
password: text("password").notNull(),
});
export const assignments = pgTable(
"assignments",
{
paragraphId: text("paragraph_id")
.notNull()
.references(() => paragraphs.id),
annotatorId: text("annotator_id")
.notNull()
.references(() => annotators.id),
assignedAt: timestamp("assigned_at").notNull().defaultNow(),
isWarmup: boolean("is_warmup").notNull().default(false),
},
(t) => [unique().on(t.paragraphId, t.annotatorId)],
);
export const humanLabels = pgTable(
"human_labels",
{
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
paragraphId: text("paragraph_id")
.notNull()
.references(() => paragraphs.id),
annotatorId: text("annotator_id")
.notNull()
.references(() => annotators.id),
contentCategory: text("content_category").notNull(),
specificityLevel: integer("specificity_level").notNull(),
notes: text("notes"),
labeledAt: timestamp("labeled_at").notNull().defaultNow(),
sessionId: text("session_id").notNull(),
durationMs: integer("duration_ms"),
},
(t) => [unique().on(t.paragraphId, t.annotatorId)],
);
export const quizSessions = pgTable("quiz_sessions", {
id: text("id").primaryKey(),
annotatorId: text("annotator_id")
.notNull()
.references(() => annotators.id),
startedAt: timestamp("started_at").notNull().defaultNow(),
completedAt: timestamp("completed_at"),
passed: boolean("passed").notNull().default(false),
score: integer("score").notNull().default(0),
totalQuestions: integer("total_questions").notNull(),
answers: text("answers").notNull().default("[]"),
});
export const adjudications = pgTable("adjudications", {
paragraphId: text("paragraph_id")
.primaryKey()
.references(() => paragraphs.id),
finalCategory: text("final_category").notNull(),
finalSpecificity: integer("final_specificity").notNull(),
method: text("method").notNull(),
adjudicatorId: text("adjudicator_id"),
notes: text("notes"),
resolvedAt: timestamp("resolved_at").notNull().defaultNow(),
});

View File

@ -0,0 +1,9 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

6
labelapp/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

7
labelapp/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

56
labelapp/package.json Normal file
View File

@ -0,0 +1,56 @@
{
"name": "labelapp",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"seed": "bun run scripts/seed.ts",
"sample": "bun run scripts/sample.ts",
"assign": "bun run scripts/assign.ts",
"export": "bun run scripts/export.ts",
"test": "bun test && playwright test",
"test:api": "bun test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@sec-cybert/schemas": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.7.0",
"next": "16.2.1",
"postgres": "^3.4.8",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.1.1",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"tailwindcss": "^4",
"typescript": "^5"
},
"ignoreScripts": [
"sharp",
"unrs-resolver"
],
"trustedDependencies": [
"sharp",
"unrs-resolver"
]
}

View File

@ -0,0 +1,25 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "bun run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
labelapp/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
labelapp/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
labelapp/tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "sec-cybert-monorepo",
"private": true,
"workspaces": ["packages/*", "ts", "labelapp"]
}

View File

@ -0,0 +1,12 @@
{
"name": "@sec-cybert/schemas",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
"./*.ts": "./src/*.ts"
},
"dependencies": {
"zod": "^4.3.6"
}
}

View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}

View File

@ -16,6 +16,7 @@
},
"dependencies": {
"@openrouter/ai-sdk-provider": "^2.3.3",
"@sec-cybert/schemas": "workspace:*",
"ai": "^6.0.141",
"cheerio": "^1.2.0",
"p-limit": "^7.3.0",

View File

@ -4,7 +4,7 @@
* Usage: bun ts/scripts/dispute-crosstab.ts
*/
import { readJsonlRaw, readJsonl } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
const ANN_PATH = new URL("../../data/annotations/stage1.jsonl", import.meta.url).pathname;
const PARA_PATH = new URL("../../data/paragraphs/paragraphs-clean.jsonl", import.meta.url).pathname;

View File

@ -7,8 +7,8 @@
import { generateText, tool, Output } from "ai";
import { openrouter, providerOf } from "../src/lib/openrouter.ts";
import { readJsonl, readJsonlRaw, appendJsonl } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { LabelOutputRaw, toLabelOutput } from "../src/schemas/label.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import { LabelOutputRaw, toLabelOutput } from "@sec-cybert/schemas/label.ts";
import { SYSTEM_PROMPT, buildJudgePrompt, PROMPT_VERSION } from "../src/label/prompts.ts";
import { withRetry } from "../src/lib/retry.ts";
import { v4 as uuidv4 } from "uuid";

View File

@ -6,8 +6,8 @@
import { generateText, Output } from "ai";
import { openrouter } from "../src/lib/openrouter.ts";
import { readJsonl, readJsonlRaw } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { LabelOutputRaw } from "../src/schemas/label.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import { LabelOutputRaw } from "@sec-cybert/schemas/label.ts";
import { SYSTEM_PROMPT, buildJudgePrompt } from "../src/label/prompts.ts";
const MODEL = process.argv[2] ?? "z-ai/glm-5";

View File

@ -5,8 +5,8 @@
import { generateText, Output } from "ai";
import { openrouter } from "../src/lib/openrouter.ts";
import { readJsonl, readJsonlRaw } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { LabelOutputRaw } from "../src/schemas/label.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import { LabelOutputRaw } from "@sec-cybert/schemas/label.ts";
import { SYSTEM_PROMPT, buildJudgePrompt } from "../src/label/prompts.ts";
const PID = process.argv[2];

View File

@ -5,7 +5,7 @@
* Usage: bun ts/scripts/mimo-pilot.ts
*/
import { readJsonl, readJsonlRaw, appendJsonl } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import { annotateParagraph, type AnnotateOpts } from "../src/label/annotate.ts";
import { PROMPT_VERSION } from "../src/label/prompts.ts";
import { v4 as uuidv4 } from "uuid";

View File

@ -5,9 +5,9 @@
import { generateText } from "ai";
import { openrouter } from "../src/lib/openrouter.ts";
import { readJsonl } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import { SYSTEM_PROMPT, buildUserPrompt } from "../src/label/prompts.ts";
import { LabelOutputRaw } from "../src/schemas/label.ts";
import { LabelOutputRaw } from "@sec-cybert/schemas/label.ts";
const INPUT = new URL("../../data/paragraphs/training.jsonl", import.meta.url).pathname;
const MODEL = "xiaomi/mimo-v2-flash";

View File

@ -3,7 +3,7 @@
* Usage: bun ts/scripts/mimo-test.ts
*/
import { readJsonl } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import { annotateParagraph } from "../src/label/annotate.ts";
import { v4 as uuidv4 } from "uuid";

View File

@ -7,7 +7,7 @@
* --smoke: run only 5 paragraphs to check schema compliance
*/
import { readJsonl, readJsonlRaw, appendJsonl } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import { annotateParagraph, type AnnotateOpts } from "../src/label/annotate.ts";
import { PROMPT_VERSION } from "../src/label/prompts.ts";
import { v4 as uuidv4 } from "uuid";

View File

@ -6,7 +6,7 @@
* Usage: bun ts/scripts/model-bias-analysis.ts
*/
import { readJsonl, readJsonlRaw } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
const PARAGRAPHS_PATH = new URL(
"../../data/paragraphs/paragraphs-clean.jsonl",

View File

@ -4,9 +4,9 @@
*/
import { generateText, Output } from "ai";
import { openrouter } from "../src/lib/openrouter.ts";
import { LabelOutput } from "../src/schemas/label.ts";
import { LabelOutput } from "@sec-cybert/schemas/label.ts";
import { SYSTEM_PROMPT, buildUserPrompt } from "../src/label/prompts.ts";
import type { Paragraph } from "../src/schemas/paragraph.ts";
import type { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
const TEST_PARAGRAPH: Paragraph = {
id: "00000000-0000-0000-0000-000000000001",

View File

@ -11,7 +11,7 @@
*/
import { z } from "zod";
import { readJsonl, writeJsonl, appendJsonl } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import { STAGE1_MODELS } from "../src/lib/openrouter.ts";
import { annotateParagraph, type AnnotateOpts } from "../src/label/annotate.ts";
import { PROMPT_VERSION } from "../src/label/prompts.ts";

View File

@ -7,7 +7,7 @@
* Usage: bun ts/scripts/sample-disputes.ts
*/
import { readJsonl, readJsonlRaw } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
const PARAGRAPHS = new URL("../../data/paragraphs/paragraphs-clean.jsonl", import.meta.url).pathname;
const ANNOTATIONS = new URL("../../data/annotations/stage1.jsonl", import.meta.url).pathname;

View File

@ -9,7 +9,7 @@
* Usage: bun ts/scripts/segment-analysis.ts
*/
import { readJsonl, readJsonlRaw } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
const PARAGRAPHS = new URL("../../data/paragraphs/paragraphs-clean.jsonl", import.meta.url).pathname;
const ANNOTATIONS = new URL("../../data/annotations/stage1.jsonl", import.meta.url).pathname;

View File

@ -14,7 +14,7 @@
* ../data/annotations/stage1.jsonl one Annotation per (paragraph, model) pair
*/
import { readJsonl, readJsonlRaw, appendJsonl } from "../src/lib/jsonl.ts";
import { Paragraph } from "../src/schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import { STAGE1_MODELS } from "../src/lib/openrouter.ts";
import { annotateParagraph, type AnnotateOpts } from "../src/label/annotate.ts";
import { PROMPT_VERSION } from "../src/label/prompts.ts";

View File

@ -3,8 +3,8 @@
* Produces a comprehensive breakdown saved as JSON + human-readable report.
*/
import { readJsonl } from "../lib/jsonl.ts";
import { Paragraph } from "../schemas/paragraph.ts";
import type { Paragraph as ParagraphType } from "../schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import type { Paragraph as ParagraphType } from "@sec-cybert/schemas/paragraph.ts";
import { writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";

View File

@ -8,8 +8,8 @@
* - data/analysis/quality-report.json (machine-readable)
*/
import { readJsonl, writeJsonl } from "../lib/jsonl.ts";
import { Paragraph } from "../schemas/paragraph.ts";
import type { Paragraph as ParagraphType } from "../schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import type { Paragraph as ParagraphType } from "@sec-cybert/schemas/paragraph.ts";
import { writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";

View File

@ -3,8 +3,8 @@
* Tracks cross-filing and cross-year persistence of boilerplate text.
*/
import { readJsonl } from "../lib/jsonl.ts";
import { Paragraph } from "../schemas/paragraph.ts";
import type { Paragraph as ParagraphType } from "../schemas/paragraph.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import type { Paragraph as ParagraphType } from "@sec-cybert/schemas/paragraph.ts";
import { writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";

View File

@ -1,6 +1,6 @@
import { readJsonl } from "./lib/jsonl.ts";
import { Paragraph } from "./schemas/paragraph.ts";
import { Annotation } from "./schemas/annotation.ts";
import { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import { Annotation } from "@sec-cybert/schemas/annotation.ts";
import { STAGE1_MODELS } from "./lib/openrouter.ts";
import { runBatch } from "./label/batch.ts";
import { computeConsensus } from "./label/consensus.ts";

View File

@ -4,7 +4,7 @@
*/
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { segmentParagraphs } from "./segment.ts";
import type { FilingMeta, Paragraph } from "../schemas/paragraph.ts";
import type { FilingMeta, Paragraph } from "@sec-cybert/schemas/paragraph.ts";
const HTML_CACHE_DIR = "../data/raw/html";
const OUTPUT_PATH = "../data/paragraphs/paragraphs.jsonl";

View File

@ -12,7 +12,7 @@ import { appendJsonl } from "../lib/jsonl.ts";
import { loadCompletedIds } from "../lib/checkpoint.ts";
import { writeFile, mkdir } from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs";
import type { FilingMeta } from "../schemas/paragraph.ts";
import type { FilingMeta } from "@sec-cybert/schemas/paragraph.ts";
interface ExtractOpts {
outputPath: string;
@ -272,7 +272,7 @@ export async function reparse10K(opts: { outputPath: string }): Promise<void> {
let totalParagraphs = 0;
const { writeJsonl } = await import("../lib/jsonl.ts");
const allParagraphs: import("../schemas/paragraph.ts").Paragraph[] = [];
const allParagraphs: import("@sec-cybert/schemas/paragraph.ts").Paragraph[] = [];
for (const file of htmlFiles) {
const accession = file.replace(".html", "");
@ -359,7 +359,7 @@ export async function reparse8K(opts: { outputPath: string }): Promise<void> {
let totalParagraphs = 0;
const { writeJsonl } = await import("../lib/jsonl.ts");
const allParagraphs: import("../schemas/paragraph.ts").Paragraph[] = [];
const allParagraphs: import("@sec-cybert/schemas/paragraph.ts").Paragraph[] = [];
for (const accession of htmlFiles) {
const meta = accMeta[accession]!;
@ -418,7 +418,7 @@ export async function mergeTrainingData(opts: {
}): Promise<void> {
const { writeJsonl } = await import("../lib/jsonl.ts");
const { readJsonl } = await import("../lib/jsonl.ts");
const { Paragraph } = await import("../schemas/paragraph.ts");
const { Paragraph } = await import("@sec-cybert/schemas/paragraph.ts");
// Load both corpora
const { records: tenK, skipped: s1 } = await readJsonl(opts.tenKPath, Paragraph);

View File

@ -1,6 +1,6 @@
import { v4 as uuidv4 } from "uuid";
import { createHash } from "node:crypto";
import type { Paragraph, FilingMeta } from "../schemas/paragraph.ts";
import type { Paragraph, FilingMeta } from "@sec-cybert/schemas/paragraph.ts";
const MIN_WORDS = 20;
const MAX_WORDS = 500;

View File

@ -1,8 +1,8 @@
import { generateText, Output } from "ai";
import { openrouter, providerOf } from "../lib/openrouter.ts";
import { LabelOutputRaw, toLabelOutput } from "../schemas/label.ts";
import type { Annotation } from "../schemas/annotation.ts";
import type { Paragraph } from "../schemas/paragraph.ts";
import { LabelOutputRaw, toLabelOutput } from "@sec-cybert/schemas/label.ts";
import type { Annotation } from "@sec-cybert/schemas/annotation.ts";
import type { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import { SYSTEM_PROMPT, buildUserPrompt, buildJudgePrompt, PROMPT_VERSION } from "./prompts.ts";
import { withRetry } from "../lib/retry.ts";

View File

@ -3,9 +3,9 @@ import { v4 as uuidv4 } from "uuid";
import { loadCompletedIds } from "../lib/checkpoint.ts";
import { appendJsonl } from "../lib/jsonl.ts";
import { classifyError } from "../lib/retry.ts";
import type { Annotation } from "../schemas/annotation.ts";
import type { SessionLog } from "../schemas/session.ts";
import type { Paragraph } from "../schemas/paragraph.ts";
import type { Annotation } from "@sec-cybert/schemas/annotation.ts";
import type { SessionLog } from "@sec-cybert/schemas/session.ts";
import type { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
import { annotateParagraph, type AnnotateOpts } from "./annotate.ts";
import { PROMPT_VERSION } from "./prompts.ts";

View File

@ -1,6 +1,6 @@
import type { Annotation } from "../schemas/annotation.ts";
import type { ConsensusResult } from "../schemas/consensus.ts";
import type { LabelOutput } from "../schemas/label.ts";
import type { Annotation } from "@sec-cybert/schemas/annotation.ts";
import type { ConsensusResult } from "@sec-cybert/schemas/consensus.ts";
import type { LabelOutput } from "@sec-cybert/schemas/label.ts";
/**
* Compute consensus from 3 Stage 1 annotations for a single paragraph.

View File

@ -1,4 +1,4 @@
import type { Paragraph } from "../schemas/paragraph.ts";
import type { Paragraph } from "@sec-cybert/schemas/paragraph.ts";
export const PROMPT_VERSION = "v2.5";

View File

@ -1,18 +0,0 @@
export {
ContentCategory,
SpecificityLevel,
Confidence,
LabelOutput,
} from "./label.ts";
export { FilingMeta, Paragraph } from "./paragraph.ts";
export { Provenance, Annotation } from "./annotation.ts";
export { ConsensusResult } from "./consensus.ts";
export { HumanLabel, GoldLabel } from "./gold.ts";
export { BenchmarkResult } from "./benchmark.ts";
export { SessionLog } from "./session.ts";

View File

@ -26,5 +26,5 @@
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "../packages/schemas/src/**/*.ts"]
}