feat: implement Phase 2 operator dashboard and structured logging

Implement complete Phase 2 features including:

- Operator Dashboard (React + Vite + Tailwind):
  * Real-time session monitoring with live event feed
  * Vote tallies and witness cap tracking
  * Moderation queue for content review
  * Manual controls for session management
  * Analytics dashboard with event timelines
  * Accessible at /operator route

- Structured Logging Service:
  * JSON-formatted logs with session/phase/event correlation
  * Configurable log levels (debug/info/warn/error)
  * Child loggers with inherited context
  * Production-ready logging architecture

- Transcript & Vote UI enhancements:
  * Live SSE transcript stream with dedupe
  * Verdict/sentence poll bars with real-time updates
  * Phase-gated voting controls

- Witness Caps & Recap System:
  * Token and time-based witness response limits
  * Configurable truncation markers
  * Judge recap events at configurable cadence

- End-to-End Testing:
  * Full round E2E test with caps and recap
  * Integration tests for all new features
  * 102 tests passing (100 pass, 2 skipped)

- Documentation & Configuration:
  * Updated API docs with new endpoints
  * Event taxonomy with new event types
  * README with dashboard and logging sections
  * Makefile targets for dashboard dev/build
  * Environment variables for all features

Closes #17
Closes #18
Closes #19
Closes #20
Closes #21
This commit is contained in:
2026-02-26 23:02:03 -06:00
parent c79f381963
commit 1f50cbb2ff
39 changed files with 5219 additions and 50 deletions

View File

@@ -1,8 +1,15 @@
OPENROUTER_API_KEY=sk-or-v1-your-key-here
LLM_MODEL=deepseek/deepseek-chat-v3-0324:free
PORT=3001
API_HOST_PORT=3001
LLM_MOCK=false
PORT=3000
API_HOST_PORT=3000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/improv_court
TTS_PROVIDER=noop
LOG_LEVEL=info
VERDICT_VOTE_WINDOW_MS=20000
SENTENCE_VOTE_WINDOW_MS=20000
WITNESS_MAX_TOKENS=150
WITNESS_MAX_SECONDS=30
WITNESS_TOKENS_PER_SECOND=3
WITNESS_TRUNCATION_MARKER=[The witness was cut off by the judge.]
JUDGE_RECAP_CADENCE=2

View File

@@ -10,7 +10,7 @@ SHELL := /usr/bin/env bash
NPM ?= npm
DOCKER_COMPOSE ?= docker compose
.PHONY: help install dev lint build test test-spec ci start migrate migrate-dist docker-up docker-down docker-restart clean status
.PHONY: help install dev dev-dashboard lint build build-dashboard test test-spec ci start migrate migrate-dist docker-up docker-down docker-restart clean status
help: ## Show available commands
@awk 'BEGIN {FS = ":.*##"; printf "\nImprov Court Make targets:\n\n"} /^[a-zA-Z0-9_.-]+:.*##/ { printf " %-18s %s\n", $$1, $$2 } END { printf "\n" }' $(MAKEFILE_LIST)
@@ -21,12 +21,18 @@ install: ## Install Node dependencies
dev: ## Start local dev server with watch mode
$(NPM) run dev
dev-dashboard: ## Start operator dashboard dev server with Vite
$(NPM) run dev:dashboard
lint: ## Run TypeScript type-check (no emit)
$(NPM) run lint
build: ## Compile TypeScript to dist/
build: ## Compile TypeScript to dist/ and build dashboard
$(NPM) run build
build-dashboard: ## Build operator dashboard for production
$(NPM) run build:dashboard
test: ## Run test suite
$(NPM) test
@@ -36,6 +42,7 @@ test-spec: ## Run tests with spec reporter
ci: ## Run local CI parity checks (lint + build + test)
$(MAKE) lint
$(MAKE) build
$(MAKE) build-dashboard
$(MAKE) test
start: ## Run compiled app from dist/

View File

@@ -34,6 +34,17 @@ It does **not** depend on `subcult-corp` at runtime.
- Overlay shell with phase timer, active speaker, and live captions
- Verdict/sentence poll bars with live percentages and phase-gated voting
- SSE analytics events for poll start/close and vote completion
- **Operator Dashboard** (`/operator`)
- Real-time session monitoring with live event feed
- Vote tallies and witness cap tracking
- Moderation queue for content review
- Manual controls for session management
- Analytics dashboard with event timelines
- **Structured Logging Service**
- JSON-formatted logs with session/phase/event correlation
- Configurable log levels (debug/info/warn/error)
- Child loggers with inherited context
- Production-ready logging architecture
## Environment
@@ -43,11 +54,18 @@ Key variables:
- `OPENROUTER_API_KEY` (optional for local mock mode; required for real LLM calls)
- `LLM_MODEL`
- `LLM_MOCK` (set to `true` to force deterministic mock responses)
- `PORT`
- `DATABASE_URL` (Postgres connection string for durable persistence)
- `TTS_PROVIDER` (`noop` or `mock`; defaults to `noop`)
- `VERDICT_VOTE_WINDOW_MS`
- `SENTENCE_VOTE_WINDOW_MS`
- `WITNESS_MAX_TOKENS`
- `WITNESS_MAX_SECONDS`
- `WITNESS_TOKENS_PER_SECOND`
- `WITNESS_TRUNCATION_MARKER`
- `JUDGE_RECAP_CADENCE`
- `LOG_LEVEL` (debug, info, warn, error; defaults to `info`)
If `OPENROUTER_API_KEY` is empty, the app falls back to deterministic mock dialogue.
@@ -56,6 +74,8 @@ If `DATABASE_URL` is missing, the app falls back to in-memory storage (non-durab
`TTS_PROVIDER=noop` keeps TTS silent (default). `TTS_PROVIDER=mock` records adapter calls for local/testing workflows without requiring an external speech provider.
Witness response caps are controlled by `WITNESS_MAX_TOKENS` and `WITNESS_MAX_SECONDS`. The recap cadence uses `JUDGE_RECAP_CADENCE` (every N witness cycles). `WITNESS_TRUNCATION_MARKER` customizes the appended cutoff text.
## Run
1. Install dependencies:
@@ -64,8 +84,12 @@ If `DATABASE_URL` is missing, the app falls back to in-memory storage (non-durab
- `npm run migrate`
3. Start dev server:
- `npm run dev`
4. Open:
- `http://localhost:3001`
4. Build operator dashboard:
- `npm run build:dashboard` (production build)
- `npm run dev:dashboard` (development mode on port 3001)
5. Open:
- Main app: `http://localhost:3000`
- Operator dashboard: `http://localhost:3000/operator`
## Run with Docker (API + Postgres)
@@ -90,10 +114,12 @@ The API container runs migrations on startup (`npm run migrate:dist`) before sta
Endpoints when running with compose:
- API: `http://localhost:${API_HOST_PORT:-3001}`
- Main app: `http://localhost:${API_HOST_PORT:-3000}`
- Operator dashboard: `http://localhost:${API_HOST_PORT:-3000}/operator`
- API: `http://localhost:${API_HOST_PORT:-3000}/api`
- Postgres: internal-only by default (`db:5432` inside compose network)
If port `3001` is already in use on your machine, set `API_HOST_PORT` in `.env` (for example `API_HOST_PORT=3002`) and restart compose.
If port `3000` is already in use on your machine, set `API_HOST_PORT` in `.env` (for example `API_HOST_PORT=3002`) and restart compose.
If you need host access to Postgres, add a `ports` mapping to the `db` service in `docker-compose.yml` (for example `"5433:5432"` to avoid conflicts with local Postgres).

12
dashboard/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Improv Court - Operator Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

126
dashboard/src/App.tsx Normal file
View File

@@ -0,0 +1,126 @@
import React, { useState, useEffect } from 'react';
import { SessionMonitor } from './components/SessionMonitor';
import { ModerationQueue } from './components/ModerationQueue';
import { ManualControls } from './components/ManualControls';
import { Analytics } from './components/Analytics';
import { useSSE } from './hooks/useSSE';
import type { CourtEvent } from './types';
function App() {
const [activeTab, setActiveTab] = useState<
'monitor' | 'moderation' | 'controls' | 'analytics'
>('monitor');
const [sessionId, setSessionId] = useState<string | null>(null);
const [events, setEvents] = useState<CourtEvent[]>([]);
const { connected, error } = useSSE(event => {
setEvents(prev => [...prev, event]);
});
useEffect(() => {
// Fetch current session on mount
fetch('/api/session')
.then(res => res.json())
.then(data => {
if (data.sessionId) {
setSessionId(data.sessionId);
}
})
.catch(err => console.error('Failed to fetch session:', err));
}, []);
const tabs = [
{ id: 'monitor', label: 'Session Monitor', icon: '📊' },
{ id: 'moderation', label: 'Moderation Queue', icon: '🛡️' },
{ id: 'controls', label: 'Manual Controls', icon: '🎛️' },
{ id: 'analytics', label: 'Analytics', icon: '📈' },
] as const;
return (
<div className='min-h-screen bg-gray-900 text-white'>
{/* Header */}
<header className='bg-gray-800 border-b border-gray-700 shadow-lg'>
<div className='container mx-auto px-4 py-4'>
<div className='flex items-center justify-between'>
<div>
<h1 className='text-2xl font-bold text-primary-400'>
Improv Court
</h1>
<p className='text-sm text-gray-400'>
Operator Dashboard
</p>
</div>
<div className='flex items-center gap-4'>
{sessionId && (
<div className='text-sm text-gray-400'>
<span className='font-medium'>
Session:
</span>{' '}
<span className='font-mono text-primary-400'>
{sessionId.slice(0, 8)}
</span>
</div>
)}
<div className='flex items-center gap-2'>
<div
className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
/>
<span className='text-sm text-gray-400'>
{connected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
</div>
</div>
</header>
{/* Tab Navigation */}
<nav className='bg-gray-800 border-b border-gray-700'>
<div className='container mx-auto px-4'>
<div className='flex gap-1'>
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-6 py-3 font-medium transition-colors ${
activeTab === tab.id ?
'bg-gray-900 text-primary-400 border-b-2 border-primary-400'
: 'text-gray-400 hover:text-white hover:bg-gray-700'
}`}
>
<span className='mr-2'>{tab.icon}</span>
{tab.label}
</button>
))}
</div>
</div>
</nav>
{/* Error Banner */}
{error && (
<div className='bg-red-900 border-l-4 border-red-500 text-white p-4'>
<div className='container mx-auto'>
<p className='font-medium'>Connection Error</p>
<p className='text-sm text-red-200'>{error}</p>
</div>
</div>
)}
{/* Main Content */}
<main className='container mx-auto px-4 py-6'>
{activeTab === 'monitor' && (
<SessionMonitor events={events} sessionId={sessionId} />
)}
{activeTab === 'moderation' && (
<ModerationQueue events={events} />
)}
{activeTab === 'controls' && (
<ManualControls sessionId={sessionId} />
)}
{activeTab === 'analytics' && <Analytics events={events} />}
</main>
</div>
);
}
export default App;

View File

@@ -0,0 +1,180 @@
import React, { useMemo } from 'react';
import type { CourtEvent } from '../types';
interface AnalyticsProps {
events: CourtEvent[];
}
export function Analytics({ events }: AnalyticsProps) {
const stats = useMemo(() => {
const byType = events.reduce(
(acc, event) => {
acc[event.type] = (acc[event.type] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
const byPhase = events
.filter(e => e.phase)
.reduce(
(acc, event) => {
acc[event.phase!] = (acc[event.phase!] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
const votes = events.filter(e => e.type === 'VOTE_CAST');
const statements = events.filter(e => e.type === 'STATEMENT_COMPLETE');
const recaps = events.filter(e => e.type === 'RECAP_GENERATED');
return {
total: events.length,
byType,
byPhase,
votes: votes.length,
statements: statements.length,
recaps: recaps.length,
};
}, [events]);
return (
<div className='space-y-6'>
{/* Summary Stats */}
<div className='grid grid-cols-1 md:grid-cols-4 gap-4'>
<div className='bg-gray-800 rounded-lg p-6'>
<div className='text-sm text-gray-400'>Total Events</div>
<div className='text-3xl font-bold text-primary-400'>
{stats.total}
</div>
</div>
<div className='bg-gray-800 rounded-lg p-6'>
<div className='text-sm text-gray-400'>Statements</div>
<div className='text-3xl font-bold text-blue-400'>
{stats.statements}
</div>
</div>
<div className='bg-gray-800 rounded-lg p-6'>
<div className='text-sm text-gray-400'>Votes</div>
<div className='text-3xl font-bold text-green-400'>
{stats.votes}
</div>
</div>
<div className='bg-gray-800 rounded-lg p-6'>
<div className='text-sm text-gray-400'>Recaps</div>
<div className='text-3xl font-bold text-purple-400'>
{stats.recaps}
</div>
</div>
</div>
{/* Events by Type */}
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
Events by Type
</h2>
<div className='space-y-2'>
{Object.entries(stats.byType)
.sort(([, a], [, b]) => b - a)
.map(([type, count]) => (
<div key={type} className='flex items-center gap-3'>
<div className='flex-1'>
<div className='flex justify-between mb-1'>
<span className='text-sm font-medium text-gray-300'>
{type}
</span>
<span className='text-sm text-gray-400'>
{count}
</span>
</div>
<div className='w-full bg-gray-700 rounded-full h-2'>
<div
className='bg-primary-500 h-2 rounded-full transition-all'
style={{
width: `${(count / stats.total) * 100}%`,
}}
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Events by Phase */}
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
Events by Phase
</h2>
{Object.keys(stats.byPhase).length === 0 ?
<div className='text-gray-500 text-center py-4'>
No phase data available
</div>
: <div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
{Object.entries(stats.byPhase)
.sort(([, a], [, b]) => b - a)
.map(([phase, count]) => (
<div
key={phase}
className='bg-gray-700 rounded-lg p-4'
>
<div className='text-sm text-gray-400'>
{phase}
</div>
<div className='text-2xl font-bold text-primary-400'>
{count}
</div>
<div className='text-xs text-gray-500 mt-1'>
{((count / stats.total) * 100).toFixed(
1,
)}
% of all events
</div>
</div>
))}
</div>
}
</div>
{/* Timeline */}
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
Event Timeline
</h2>
<div className='space-y-1 max-h-96 overflow-y-auto'>
{events.length === 0 ?
<div className='text-gray-500 text-center py-4'>
No events recorded
</div>
: events
.slice(-50)
.reverse()
.map((event, idx) => (
<div
key={idx}
className='flex items-center gap-3 py-2 px-3 hover:bg-gray-700 rounded transition-colors'
>
<div className='text-xs text-gray-500 font-mono w-20'>
{new Date(
event.timestamp,
).toLocaleTimeString()}
</div>
<div className='flex-1'>
<span className='text-sm font-medium text-primary-300'>
{event.type}
</span>
{event.phase && (
<span className='ml-2 text-xs text-gray-400'>
({event.phase})
</span>
)}
</div>
</div>
))
}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,263 @@
import React, { useState } from 'react';
interface ManualControlsProps {
sessionId: string | null;
}
export function ManualControls({ sessionId }: ManualControlsProps) {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{
type: 'success' | 'error';
text: string;
} | null>(null);
const handleAction = async (
action: string,
data?: Record<string, unknown>,
) => {
if (!sessionId) {
setMessage({ type: 'error', text: 'No active session' });
return;
}
setLoading(true);
setMessage(null);
try {
const response = await fetch(`/api/session/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data || {}),
});
if (!response.ok) {
throw new Error(`Action failed: ${response.statusText}`);
}
setMessage({
type: 'success',
text: `${action} completed successfully`,
});
} catch (err) {
setMessage({ type: 'error', text: (err as Error).message });
} finally {
setLoading(false);
}
};
const handleNewSession = async () => {
setLoading(true);
setMessage(null);
try {
const response = await fetch('/api/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(
`Failed to create session: ${response.statusText}`,
);
}
const data = await response.json();
setMessage({
type: 'success',
text: `New session created: ${data.sessionId}`,
});
// Reload page to connect to new session
setTimeout(() => window.location.reload(), 1500);
} catch (err) {
setMessage({ type: 'error', text: (err as Error).message });
} finally {
setLoading(false);
}
};
return (
<div className='space-y-6'>
{/* Status Message */}
{message && (
<div
className={`p-4 rounded-lg ${
message.type === 'success' ?
'bg-green-900/30 border border-green-700 text-green-300'
: 'bg-red-900/30 border border-red-700 text-red-300'
}`}
>
{message.text}
</div>
)}
{/* Session Control */}
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
Session Control
</h2>
<div className='space-y-3'>
<button
onClick={handleNewSession}
disabled={loading}
className='w-full px-4 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors'
>
{loading ? 'Processing...' : '🆕 Create New Session'}
</button>
{sessionId && (
<button
onClick={() => handleAction('reset')}
disabled={loading}
className='w-full px-4 py-3 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors'
>
{loading ?
'Processing...'
: '🔄 Reset Current Session'}
</button>
)}
</div>
</div>
{/* Phase Control */}
{sessionId && (
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
Phase Control
</h2>
<div className='grid grid-cols-1 md:grid-cols-2 gap-3'>
{[
{
phase: 'WITNESS_1',
label: 'Start Witness 1',
emoji: '👤',
},
{
phase: 'WITNESS_2',
label: 'Start Witness 2',
emoji: '👥',
},
{
phase: 'DELIBERATION',
label: 'Start Deliberation',
emoji: '⚖️',
},
{
phase: 'VERDICT',
label: 'Start Verdict',
emoji: '📜',
},
].map(({ phase, label, emoji }) => (
<button
key={phase}
onClick={() =>
handleAction('advance-phase', {
targetPhase: phase,
})
}
disabled={loading}
className='px-4 py-3 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors text-left'
>
<span className='mr-2'>{emoji}</span>
{label}
</button>
))}
</div>
</div>
)}
{/* Statement Injection */}
{sessionId && (
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
Inject Statement
</h2>
<form
onSubmit={e => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const speaker = formData.get('speaker') as string;
const content = formData.get('content') as string;
handleAction('inject-statement', {
speaker,
content,
});
e.currentTarget.reset();
}}
>
<div className='space-y-3'>
<div>
<label
htmlFor='speaker'
className='block text-sm font-medium text-gray-300 mb-1'
>
Speaker
</label>
<select
id='speaker'
name='speaker'
required
className='w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500'
>
<option value='JUDGE'>Judge</option>
<option value='WITNESS_1'>Witness 1</option>
<option value='WITNESS_2'>Witness 2</option>
<option value='NARRATOR'>Narrator</option>
</select>
</div>
<div>
<label
htmlFor='content'
className='block text-sm font-medium text-gray-300 mb-1'
>
Content
</label>
<textarea
id='content'
name='content'
required
rows={3}
className='w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500'
placeholder='Enter statement content...'
/>
</div>
<button
type='submit'
disabled={loading}
className='w-full px-4 py-2 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors'
>
{loading ?
'Injecting...'
: '💉 Inject Statement'}
</button>
</div>
</form>
</div>
)}
{/* Emergency Controls */}
{sessionId && (
<div className='bg-gray-800 rounded-lg p-6 shadow-lg border-2 border-red-700'>
<h2 className='text-xl font-semibold mb-4 text-red-400'>
Emergency Controls
</h2>
<div className='space-y-3'>
<button
onClick={() => handleAction('pause')}
disabled={loading}
className='w-full px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors'
>
{loading ? 'Processing...' : '⏸️ Pause Session'}
</button>
<button
onClick={() => handleAction('terminate')}
disabled={loading}
className='w-full px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors'
>
{loading ? 'Processing...' : '🛑 Terminate Session'}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,184 @@
import React, { useState } from 'react';
import type { CourtEvent } from '../types';
interface ModerationQueueProps {
events: CourtEvent[];
}
interface FlaggedItem {
id: string;
type: 'statement' | 'vote';
content: string;
speaker?: string;
timestamp: string;
reason: string;
status: 'pending' | 'approved' | 'rejected';
}
export function ModerationQueue({ events }: ModerationQueueProps) {
const [queue, setQueue] = useState<FlaggedItem[]>([]);
const [filter, setFilter] = useState<
'all' | 'pending' | 'approved' | 'rejected'
>('pending');
const handleApprove = (id: string) => {
setQueue(prev =>
prev.map(item =>
item.id === id ?
{ ...item, status: 'approved' as const }
: item,
),
);
};
const handleReject = (id: string) => {
setQueue(prev =>
prev.map(item =>
item.id === id ?
{ ...item, status: 'rejected' as const }
: item,
),
);
};
const filteredQueue = queue.filter(
item => filter === 'all' || item.status === filter,
);
return (
<div className='space-y-6'>
{/* Stats */}
<div className='grid grid-cols-1 md:grid-cols-4 gap-4'>
<div className='bg-gray-800 rounded-lg p-4'>
<div className='text-sm text-gray-400'>Total</div>
<div className='text-2xl font-bold'>{queue.length}</div>
</div>
<div className='bg-yellow-900/30 border border-yellow-700 rounded-lg p-4'>
<div className='text-sm text-gray-400'>Pending</div>
<div className='text-2xl font-bold text-yellow-400'>
{queue.filter(item => item.status === 'pending').length}
</div>
</div>
<div className='bg-green-900/30 border border-green-700 rounded-lg p-4'>
<div className='text-sm text-gray-400'>Approved</div>
<div className='text-2xl font-bold text-green-400'>
{
queue.filter(item => item.status === 'approved')
.length
}
</div>
</div>
<div className='bg-red-900/30 border border-red-700 rounded-lg p-4'>
<div className='text-sm text-gray-400'>Rejected</div>
<div className='text-2xl font-bold text-red-400'>
{
queue.filter(item => item.status === 'rejected')
.length
}
</div>
</div>
</div>
{/* Filters */}
<div className='flex gap-2'>
{(['all', 'pending', 'approved', 'rejected'] as const).map(
status => (
<button
key={status}
onClick={() => setFilter(status)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filter === status ?
'bg-primary-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</button>
),
)}
</div>
{/* Queue */}
<div className='bg-gray-800 rounded-lg shadow-lg'>
{filteredQueue.length === 0 ?
<div className='p-8 text-center text-gray-400'>
{filter === 'pending' ?
'No pending items'
: `No ${filter} items`}
</div>
: <div className='divide-y divide-gray-700'>
{filteredQueue.map(item => (
<div
key={item.id}
className='p-4 hover:bg-gray-750 transition-colors'
>
<div className='flex items-start justify-between mb-2'>
<div className='flex-1'>
<div className='flex items-center gap-2 mb-1'>
<span
className={`px-2 py-1 rounded text-xs font-medium ${
item.type === 'statement' ?
'bg-blue-900/50 text-blue-300'
: 'bg-purple-900/50 text-purple-300'
}`}
>
{item.type}
</span>
{item.speaker && (
<span className='text-sm text-gray-400'>
by {item.speaker}
</span>
)}
<span className='text-xs text-gray-500'>
{new Date(
item.timestamp,
).toLocaleString()}
</span>
</div>
<p className='text-white mb-2'>
{item.content}
</p>
<p className='text-sm text-yellow-400'>
{item.reason}
</p>
</div>
<div className='ml-4 flex gap-2'>
{item.status === 'pending' ?
<>
<button
onClick={() =>
handleApprove(item.id)
}
className='px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm font-medium transition-colors'
>
Approve
</button>
<button
onClick={() =>
handleReject(item.id)
}
className='px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm font-medium transition-colors'
>
Reject
</button>
</>
: <span
className={`px-3 py-1 rounded text-sm font-medium ${
item.status === 'approved' ?
'bg-green-900/50 text-green-300'
: 'bg-red-900/50 text-red-300'
}`}
>
{item.status}
</span>
}
</div>
</div>
</div>
))}
</div>
}
</div>
</div>
);
}

View File

@@ -0,0 +1,220 @@
import React, { useState, useEffect } from 'react';
import type { CourtEvent, SessionSnapshot, TranscriptEntry } from '../types';
interface SessionMonitorProps {
events: CourtEvent[];
sessionId: string | null;
}
export function SessionMonitor({ events, sessionId }: SessionMonitorProps) {
const [snapshot, setSnapshot] = useState<SessionSnapshot | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!sessionId) return;
fetch('/api/session')
.then(res => res.json())
.then(data => {
setSnapshot(data);
setLoading(false);
})
.catch(err => {
console.error('Failed to fetch snapshot:', err);
setLoading(false);
});
}, [sessionId, events.length]);
if (loading) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-gray-400'>Loading session data...</div>
</div>
);
}
if (!snapshot) {
return (
<div className='bg-gray-800 rounded-lg p-8 text-center'>
<p className='text-gray-400 text-lg'>No active session</p>
<p className='text-gray-500 text-sm mt-2'>
Start a new session to begin monitoring
</p>
</div>
);
}
const latestEvents = events.slice(-10).reverse();
return (
<div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
{/* Session Info */}
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
Session Info
</h2>
<div className='space-y-3'>
<div className='flex justify-between'>
<span className='text-gray-400'>Session ID:</span>
<span className='font-mono text-sm'>
{snapshot.sessionId.slice(0, 16)}...
</span>
</div>
<div className='flex justify-between'>
<span className='text-gray-400'>Current Phase:</span>
<span className='font-semibold text-primary-400'>
{snapshot.phase}
</span>
</div>
<div className='flex justify-between'>
<span className='text-gray-400'>
Transcript Entries:
</span>
<span>{snapshot.transcript.length}</span>
</div>
<div className='flex justify-between'>
<span className='text-gray-400'>Total Votes:</span>
<span>
{Object.values(snapshot.votes).reduce(
(sum, v) => sum + v.total,
0,
)}
</span>
</div>
<div className='flex justify-between'>
<span className='text-gray-400'>Recap Count:</span>
<span>{snapshot.recapCount}</span>
</div>
</div>
</div>
{/* Witness Caps */}
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
Witness Caps
</h2>
<div className='space-y-4'>
<div>
<div className='flex justify-between mb-2'>
<span className='text-gray-400'>Witness 1</span>
<span className='text-sm'>
{snapshot.witnessCaps.witness1} /{' '}
{snapshot.config.maxWitnessStatements}
</span>
</div>
<div className='w-full bg-gray-700 rounded-full h-2.5'>
<div
className='bg-blue-500 h-2.5 rounded-full transition-all'
style={{
width: `${(snapshot.witnessCaps.witness1 / snapshot.config.maxWitnessStatements) * 100}%`,
}}
/>
</div>
</div>
<div>
<div className='flex justify-between mb-2'>
<span className='text-gray-400'>Witness 2</span>
<span className='text-sm'>
{snapshot.witnessCaps.witness2} /{' '}
{snapshot.config.maxWitnessStatements}
</span>
</div>
<div className='w-full bg-gray-700 rounded-full h-2.5'>
<div
className='bg-purple-500 h-2.5 rounded-full transition-all'
style={{
width: `${(snapshot.witnessCaps.witness2 / snapshot.config.maxWitnessStatements) * 100}%`,
}}
/>
</div>
</div>
</div>
<div className='mt-4 text-sm text-gray-400'>
Recap interval: every {snapshot.config.recapInterval}{' '}
statements
</div>
</div>
{/* Vote Tallies */}
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
Vote Tallies
</h2>
<div className='space-y-4'>
{Object.entries(snapshot.votes).map(([phase, counts]) => (
<div key={phase}>
<div className='text-sm font-medium text-gray-300 mb-2'>
{phase}
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='bg-green-900/30 border border-green-700 rounded p-3'>
<div className='text-xs text-gray-400'>
Innocent
</div>
<div className='text-2xl font-bold text-green-400'>
{counts.innocent}
</div>
</div>
<div className='bg-red-900/30 border border-red-700 rounded p-3'>
<div className='text-xs text-gray-400'>
Guilty
</div>
<div className='text-2xl font-bold text-red-400'>
{counts.guilty}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Live Event Feed */}
<div className='bg-gray-800 rounded-lg p-6 shadow-lg'>
<h2 className='text-xl font-semibold mb-4 text-primary-400'>
Live Event Feed
</h2>
<div className='space-y-2 max-h-96 overflow-y-auto'>
{latestEvents.length === 0 ?
<div className='text-gray-500 text-center py-4'>
No recent events
</div>
: latestEvents.map((event, idx) => (
<div
key={idx}
className='bg-gray-700 rounded p-3 text-sm border-l-4 border-primary-500'
>
<div className='flex justify-between items-start mb-1'>
<span className='font-medium text-primary-300'>
{event.type}
</span>
<span className='text-xs text-gray-400'>
{new Date(
event.timestamp,
).toLocaleTimeString()}
</span>
</div>
{event.speaker && (
<div className='text-gray-300'>
<span className='text-gray-400'>
Speaker:
</span>{' '}
{event.speaker}
</div>
)}
{event.phase && (
<div className='text-gray-300'>
<span className='text-gray-400'>
Phase:
</span>{' '}
{event.phase}
</div>
)}
</div>
))
}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import type { CourtEvent } from '../types';
export function useSSE(onEvent: (event: CourtEvent) => void) {
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const connect = useCallback(() => {
if (eventSourceRef.current) {
return;
}
try {
const es = new EventSource('/api/events');
eventSourceRef.current = es;
es.onopen = () => {
setConnected(true);
setError(null);
console.log('SSE connected');
};
es.onerror = err => {
console.error('SSE error:', err);
setConnected(false);
setError('Connection lost. Reconnecting...');
// Clean up and schedule reconnect
es.close();
eventSourceRef.current = null;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, 3000);
};
es.onmessage = e => {
try {
const event = JSON.parse(e.data) as CourtEvent;
onEvent(event);
} catch (err) {
console.error('Failed to parse SSE event:', err);
}
};
} catch (err) {
console.error('Failed to create EventSource:', err);
setError('Failed to connect to event stream');
}
}, [onEvent]);
useEffect(() => {
connect();
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, [connect]);
return { connected, error };
}

18
dashboard/src/index.css Normal file
View File

@@ -0,0 +1,18 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family:
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}

10
dashboard/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

40
dashboard/src/types.ts Normal file
View File

@@ -0,0 +1,40 @@
export interface CourtEvent {
type: string;
timestamp: string;
sessionId?: string;
phase?: string;
speaker?: string;
content?: string;
voterId?: string;
vote?: string;
[key: string]: unknown;
}
export interface SessionSnapshot {
sessionId: string;
phase: string;
transcript: TranscriptEntry[];
votes: Record<string, VoteCount>;
recapCount: number;
witnessCaps: {
witness1: number;
witness2: number;
};
config: {
maxWitnessStatements: number;
recapInterval: number;
};
}
export interface TranscriptEntry {
speaker: string;
content: string;
timestamp: string;
isRecap?: boolean;
}
export interface VoteCount {
guilty: number;
innocent: number;
total: number;
}

View File

@@ -235,6 +235,7 @@ data: {"type":"turn","payload":{…}}\n\n
votes: Record<string, number>;
};
};
recapTurnIds?: string[];
finalRuling?: {
verdict: string;
sentence: string;
@@ -280,17 +281,19 @@ Every SSE payload is a `CourtEvent`:
### Event Types
| Type | When emitted | Key payload fields |
| ------------------- | ------------------------------------------------------------------- | ----------------------------------------------------- |
| `snapshot` | Immediately on SSE connect | `session`, `turns`, `verdictVotes`, `sentenceVotes` |
| `session_created` | Session record inserted | `sessionId` |
| `session_started` | Orchestration begins | `sessionId` |
| `phase_changed` | Phase advances | `phase`, `durationMs` |
| `turn` | A new dialogue turn is stored | `turn: CourtTurn` |
| `vote_updated` | A vote is successfully cast | `voteType`, `choice`, `verdictVotes`, `sentenceVotes` |
| `vote_closed` | Transitioned away from a vote phase; includes frozen tally snapshot | `pollType`, `closedAt`, `votes`, `nextPhase` |
| `analytics_event` | Poll open/close lifecycle events | `event`, `phase` |
| `moderation_action` | Turn content was flagged and redacted | `speaker`, `reasons` |
| `vote_spam_blocked` | Vote rejected due to rate limiting | `ip`, `voteType` |
| `session_completed` | Session reached `final_ruling` successfully | `sessionId`, `finalRuling` |
| `session_failed` | Orchestration threw an unrecoverable error | `sessionId`, `reason` |
| Type | When emitted | Key payload fields |
| ------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `snapshot` | Immediately on SSE connect | `session`, `turns`, `verdictVotes`, `sentenceVotes`, `recapTurnIds` |
| `session_created` | Session record inserted | `sessionId` |
| `session_started` | Orchestration begins | `sessionId` |
| `phase_changed` | Phase advances | `phase`, `durationMs` |
| `turn` | A new dialogue turn is stored | `turn: CourtTurn` |
| `vote_updated` | A vote is successfully cast | `voteType`, `choice`, `verdictVotes`, `sentenceVotes` |
| `vote_closed` | Transitioned away from a vote phase; includes frozen tally snapshot | `pollType`, `closedAt`, `votes`, `nextPhase` |
| `witness_response_capped` | Witness response was truncated due to caps | `turnId`, `speaker`, `phase`, `originalLength`, `truncatedLength`, `reason` |
| `judge_recap_emitted` | Judge recap emitted during witness exam | `turnId`, `phase`, `cycleNumber` |
| `analytics_event` | Poll open/close lifecycle events | `event`, `phase` |
| `moderation_action` | Turn content was flagged and redacted | `speaker`, `reasons` |
| `vote_spam_blocked` | Vote rejected due to rate limiting | `ip`, `voteType` |
| `session_completed` | Session reached `final_ruling` successfully | `sessionId`, `finalRuling` |
| `session_failed` | Orchestration threw an unrecoverable error | `sessionId`, `reason` |

View File

@@ -244,6 +244,45 @@ Emitted once when a vote phase closes (i.e., when transitioning away from
---
### `witness_response_capped`
Emitted when a witness response is truncated due to configured response caps.
**Severity:** `info`
**Payload**
```ts
{
turnId: string;
speaker: AgentId;
phase: CourtPhase;
originalLength: number; // token estimate
truncatedLength: number; // token estimate after cap
reason: 'tokens' | 'seconds';
}
```
---
### `judge_recap_emitted`
Emitted when the judge recap is generated during witness examination.
**Severity:** `info`
**Payload**
```ts
{
turnId: string;
phase: CourtPhase;
cycleNumber: number; // witness cycle count when recap occurred
}
```
---
### `analytics_event`
Poll lifecycle signals. Three named sub-events are emitted under this type:
@@ -428,11 +467,11 @@ Emitted when the orchestrator throws an unrecoverable error.
## Severity levels
| Severity | Event types |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `info` | `session_created`, `session_started`, `phase_changed`, `turn`, `vote_updated`, `vote_closed`, `analytics_event`, `session_completed` |
| `warn` | `moderation_action`, `vote_spam_blocked` |
| `error` | `session_failed` |
| Severity | Event types |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `info` | `session_created`, `session_started`, `phase_changed`, `turn`, `vote_updated`, `vote_closed`, `witness_response_capped`, `judge_recap_emitted`, `analytics_event`, `session_completed` |
| `warn` | `moderation_action`, `vote_spam_blocked` |
| `error` | `session_failed` |
---

2726
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,10 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"dev:dashboard": "vite",
"lint": "tsc --noEmit",
"build": "tsc -p tsconfig.json",
"build": "tsc -p tsconfig.json && vite build",
"build:dashboard": "vite build",
"test": "node --import tsx --test src/*.test.ts src/**/*.test.ts",
"start": "node dist/server.js",
"migrate": "tsx src/scripts/migrate.ts",
@@ -17,12 +19,21 @@
"dependencies": {
"dotenv": "^17.2.3",
"express": "^4.21.2",
"postgres": "^3.4.5"
"postgres": "^3.4.5",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.13.9",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tsx": "^4.20.5",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vite": "^6.0.7"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,3 +1,11 @@
import {
createStreamState,
isRecapTurn,
markRecap,
resetStreamState,
shouldAppendTurn,
} from './stream-state.js';
const topicInput = document.getElementById('topic');
const caseTypeSelect = document.getElementById('caseType');
const startBtn = document.getElementById('startBtn');
@@ -8,6 +16,14 @@ const verdictTallies = document.getElementById('verdictTallies');
const sentenceTallies = document.getElementById('sentenceTallies');
const verdictActions = document.getElementById('verdictActions');
const sentenceActions = document.getElementById('sentenceActions');
const verdictStatus = document.getElementById('verdictStatus');
const verdictCountdown = document.getElementById('verdictCountdown');
const verdictError = document.getElementById('verdictError');
const verdictNote = document.getElementById('verdictNote');
const sentenceStatus = document.getElementById('sentenceStatus');
const sentenceCountdown = document.getElementById('sentenceCountdown');
const sentenceError = document.getElementById('sentenceError');
const sentenceNote = document.getElementById('sentenceNote');
const statusEl = document.getElementById('status');
const phaseTimer = document.getElementById('phaseTimer');
const phaseTimerFill = document.getElementById('phaseTimerFill');
@@ -18,9 +34,26 @@ const connectionBanner = document.getElementById('connectionBanner');
let activeSession = null;
let source = null;
let timerInterval = null;
let voteCountdownInterval = null;
let reconnectTimer = null;
let reconnectAttempts = 0;
const streamState = createStreamState();
const voteState = {
verdict: {
isOpen: false,
closesAt: null,
hasVoted: false,
error: '',
},
sentence: {
isOpen: false,
closesAt: null,
hasVoted: false,
error: '',
},
};
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 10_000;
@@ -52,15 +85,37 @@ function pulseActiveSpeaker() {
activeSpeakerEl.classList.add('speaker-live');
}
function appendTurn(turn) {
function appendTurn(turn, { recap = false } = {}) {
const item = document.createElement('div');
item.className = 'turn';
item.dataset.turnId = turn.id;
if (recap) {
item.classList.add('recap');
}
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = `#${turn.turnNumber + 1} · ${turn.role} · ${turn.speaker} · ${turn.phase}`;
const turnNumber = document.createElement('span');
turnNumber.className = 'turn-number';
turnNumber.textContent = `#${turn.turnNumber + 1}`;
const roleBadge = document.createElement('span');
roleBadge.className = 'role-badge';
roleBadge.textContent = turn.role;
const speakerName = document.createElement('span');
speakerName.className = 'speaker';
speakerName.textContent = turn.speaker;
const phaseLabel = document.createElement('span');
phaseLabel.className = 'phase-label';
phaseLabel.textContent = turn.phase;
meta.append(turnNumber, roleBadge, speakerName, phaseLabel);
const body = document.createElement('div');
body.className = 'body';
body.textContent = turn.dialogue;
item.append(meta, body);
@@ -71,6 +126,13 @@ function appendTurn(turn) {
captionLineEl.textContent = turn.dialogue;
}
function markTurnRecap(turnId) {
const target = feed.querySelector(`[data-turn-id="${turnId}"]`);
if (target) {
target.classList.add('recap');
}
}
function renderTally(container, map) {
container.innerHTML = '';
const entries = Object.entries(map || {});
@@ -105,6 +167,8 @@ function renderTally(container, map) {
async function castVote(type, choice) {
if (!activeSession) return;
voteState[type].error = '';
renderVoteMeta();
const res = await fetch(`/api/court/sessions/${activeSession.id}/vote`, {
method: 'POST',
@@ -114,13 +178,17 @@ async function castVote(type, choice) {
if (!res.ok) {
const err = await res.json();
setStatus(err.error || 'Vote failed', 'error');
voteState[type].error = err.error || 'Vote failed';
renderVoteMeta();
return;
}
const data = await res.json();
renderTally(verdictTallies, data.verdictVotes);
renderTally(sentenceTallies, data.sentenceVotes);
voteState[type].hasVoted = true;
renderActions(activeSession);
renderVoteMeta();
setStatus('Vote recorded.');
}
@@ -137,7 +205,10 @@ function renderActions(session) {
const button = document.createElement('button');
button.textContent = option;
button.onclick = () => castVote('verdict', option);
button.disabled = session.phase !== 'verdict_vote';
button.disabled =
session.phase !== 'verdict_vote' ||
!voteState.verdict.isOpen ||
voteState.verdict.hasVoted;
verdictActions.appendChild(button);
}
@@ -145,11 +216,111 @@ function renderActions(session) {
const button = document.createElement('button');
button.textContent = option;
button.onclick = () => castVote('sentence', option);
button.disabled = session.phase !== 'sentence_vote';
button.disabled =
session.phase !== 'sentence_vote' ||
!voteState.sentence.isOpen ||
voteState.sentence.hasVoted;
sentenceActions.appendChild(button);
}
}
function resetVoteState() {
voteState.verdict.isOpen = false;
voteState.verdict.closesAt = null;
voteState.verdict.hasVoted = false;
voteState.verdict.error = '';
voteState.sentence.isOpen = false;
voteState.sentence.closesAt = null;
voteState.sentence.hasVoted = false;
voteState.sentence.error = '';
}
function formatCountdown(ms) {
if (ms <= 0) return '00:00';
const minutes = Math.floor(ms / 60000)
.toString()
.padStart(2, '0');
const seconds = Math.floor((ms % 60000) / 1000)
.toString()
.padStart(2, '0');
return `${minutes}:${seconds}`;
}
function renderVoteMeta() {
verdictStatus.textContent = voteState.verdict.isOpen ? 'Open' : 'Closed';
verdictStatus.className = `badge ${voteState.verdict.isOpen ? 'ok' : ''}`;
verdictError.textContent = voteState.verdict.error || '';
verdictNote.textContent =
voteState.verdict.hasVoted ? 'Your vote is in.' : '';
sentenceStatus.textContent = voteState.sentence.isOpen ? 'Open' : 'Closed';
sentenceStatus.className = `badge ${voteState.sentence.isOpen ? 'ok' : ''}`;
sentenceError.textContent = voteState.sentence.error || '';
sentenceNote.textContent =
voteState.sentence.hasVoted ? 'Your vote is in.' : '';
}
function updateVoteCountdowns() {
const now = Date.now();
const verdictCloseAt = voteState.verdict.closesAt;
const sentenceCloseAt = voteState.sentence.closesAt;
if (verdictCloseAt) {
const remaining = Math.max(0, verdictCloseAt - now);
verdictCountdown.textContent = formatCountdown(remaining);
if (remaining === 0 && voteState.verdict.isOpen) {
voteState.verdict.isOpen = false;
if (activeSession) {
renderActions(activeSession);
}
renderVoteMeta();
}
} else {
verdictCountdown.textContent = '--:--';
}
if (sentenceCloseAt) {
const remaining = Math.max(0, sentenceCloseAt - now);
sentenceCountdown.textContent = formatCountdown(remaining);
if (remaining === 0 && voteState.sentence.isOpen) {
voteState.sentence.isOpen = false;
if (activeSession) {
renderActions(activeSession);
}
renderVoteMeta();
}
} else {
sentenceCountdown.textContent = '--:--';
}
}
function startVoteCountdowns() {
if (voteCountdownInterval) {
clearInterval(voteCountdownInterval);
}
updateVoteCountdowns();
voteCountdownInterval = setInterval(updateVoteCountdowns, 250);
}
function openVoteWindow(type, phaseStartedAt, phaseDurationMs) {
if (!phaseStartedAt || !phaseDurationMs) {
return;
}
const start = Date.parse(phaseStartedAt);
voteState[type].isOpen = true;
voteState[type].hasVoted = false;
voteState[type].error = '';
voteState[type].closesAt = start + phaseDurationMs;
renderVoteMeta();
startVoteCountdowns();
}
function closeVoteWindow(type, closedAt) {
voteState[type].isOpen = false;
voteState[type].closesAt = closedAt ? Date.parse(closedAt) : null;
renderVoteMeta();
}
function updateTimer(phaseStartedAt, phaseDurationMs) {
if (timerInterval) {
clearInterval(timerInterval);
@@ -246,19 +417,52 @@ function connectStream(sessionId, isReconnect = false) {
);
feed.innerHTML = '';
turns.forEach(appendTurn);
resetStreamState(streamState, payload.payload);
turns.forEach(turn => {
appendTurn(turn, {
recap: isRecapTurn(streamState, turn.id),
});
});
if (turns.length === 0) {
activeSpeakerEl.textContent = 'Waiting for first turn…';
captionLineEl.textContent = 'Captions will appear here.';
}
renderTally(verdictTallies, verdictVotes);
renderTally(sentenceTallies, sentenceVotes);
resetVoteState();
if (session.phase === 'verdict_vote') {
openVoteWindow(
'verdict',
session.metadata.phaseStartedAt,
session.metadata.phaseDurationMs,
);
}
if (session.phase === 'sentence_vote') {
openVoteWindow(
'sentence',
session.metadata.phaseStartedAt,
session.metadata.phaseDurationMs,
);
}
renderActions(session);
renderVoteMeta();
return;
}
if (payload.type === 'turn') {
appendTurn(payload.payload.turn);
const turn = payload.payload.turn;
if (!shouldAppendTurn(streamState, turn)) {
return;
}
appendTurn(turn, {
recap: isRecapTurn(streamState, turn.id),
});
return;
}
if (payload.type === 'judge_recap_emitted') {
markRecap(streamState, payload.payload.turnId);
markTurnRecap(payload.payload.turnId);
return;
}
@@ -276,6 +480,20 @@ function connectStream(sessionId, isReconnect = false) {
payload.payload.phaseStartedAt,
payload.payload.phaseDurationMs,
);
if (payload.payload.phase === 'verdict_vote') {
openVoteWindow(
'verdict',
payload.payload.phaseStartedAt,
payload.payload.phaseDurationMs,
);
}
if (payload.payload.phase === 'sentence_vote') {
openVoteWindow(
'sentence',
payload.payload.phaseStartedAt,
payload.payload.phaseDurationMs,
);
}
return;
}
@@ -293,12 +511,19 @@ function connectStream(sessionId, isReconnect = false) {
setStatus(
`${payload.payload.pollType} poll closed with ${voteTotal} vote${voteTotal === 1 ? '' : 's'}.`,
);
closeVoteWindow(payload.payload.pollType, payload.payload.closedAt);
if (activeSession) {
renderActions(activeSession);
}
return;
}
if (payload.type === 'session_completed') {
setStatus('Session complete. Verdict delivered.');
updateTimer();
voteState.verdict.isOpen = false;
voteState.sentence.isOpen = false;
renderVoteMeta();
}
if (payload.type === 'session_failed') {

View File

@@ -198,6 +198,50 @@
color: var(--muted);
font-size: 12px;
margin-bottom: 6px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.turn .meta .turn-number {
color: var(--muted);
}
.turn .meta .role-badge {
background: #2b2f41;
color: #d9e6ff;
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.turn .meta .speaker {
color: var(--text);
font-weight: 600;
}
.turn .meta .phase-label {
font-size: 11px;
color: var(--muted);
}
.turn .body {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.turn.recap {
border-left-color: #f7d794;
background: #1b1a12;
}
.turn.recap .meta {
color: #d0c199;
}
.vote-grid {
@@ -233,6 +277,25 @@
margin-top: 8px;
}
.vote-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.vote-error {
color: var(--danger);
font-size: 12px;
min-height: 14px;
}
.vote-note {
color: var(--ok);
font-size: 12px;
min-height: 14px;
}
.ok {
color: var(--ok);
}
@@ -318,7 +381,13 @@
<h3 style="font-size: 14px; margin-bottom: 6px">
Verdict vote
</h3>
<div class="vote-meta">
<span id="verdictStatus" class="badge">Closed</span>
<span id="verdictCountdown" class="muted">--:--</span>
</div>
<div id="verdictTallies" class="vote-grid"></div>
<div id="verdictError" class="vote-error"></div>
<div id="verdictNote" class="vote-note"></div>
<div class="vote-actions" id="verdictActions"></div>
</div>
@@ -326,7 +395,13 @@
<h3 style="font-size: 14px; margin-bottom: 6px">
Sentence vote
</h3>
<div class="vote-meta">
<span id="sentenceStatus" class="badge">Closed</span>
<span id="sentenceCountdown" class="muted">--:--</span>
</div>
<div id="sentenceTallies" class="vote-grid"></div>
<div id="sentenceError" class="vote-error"></div>
<div id="sentenceNote" class="vote-note"></div>
<div class="vote-actions" id="sentenceActions"></div>
</div>

20
public/stream-state.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
export interface StreamState {
seenTurnIds: Set<string>;
recapTurnIds: Set<string>;
}
export function createStreamState(): StreamState;
export function resetStreamState(
state: StreamState,
snapshot: { turns: Array<{ id: string }>; recapTurnIds?: string[] },
): void;
export function shouldAppendTurn(
state: StreamState,
turn: { id: string },
): boolean;
export function markRecap(state: StreamState, turnId: string): void;
export function isRecapTurn(state: StreamState, turnId: string): boolean;

28
public/stream-state.js Normal file
View File

@@ -0,0 +1,28 @@
export function createStreamState() {
return {
seenTurnIds: new Set(),
recapTurnIds: new Set(),
};
}
export function resetStreamState(state, snapshot) {
state.seenTurnIds = new Set(snapshot.turns.map(turn => turn.id));
state.recapTurnIds = new Set(snapshot.recapTurnIds ?? []);
}
export function shouldAppendTurn(state, turn) {
if (state.seenTurnIds.has(turn.id)) {
return false;
}
state.seenTurnIds.add(turn.id);
return true;
}
export function markRecap(state, turnId) {
state.recapTurnIds.add(turnId);
}
export function isRecapTurn(state, turnId) {
return state.recapTurnIds.has(turnId);
}

View File

@@ -2,6 +2,8 @@ import { AGENTS } from '../agents.js';
import { llmGenerate, sanitizeDialogue } from '../llm/client.js';
import { moderateContent } from '../moderation/content-filter.js';
import { createTTSAdapterFromEnv, type TTSAdapter } from '../tts/adapter.js';
import { applyWitnessCap, resolveWitnessCapConfig } from './witness-caps.js';
import type { WitnessCapConfig } from './witness-caps.js';
import type {
AgentId,
CaseType,
@@ -40,7 +42,10 @@ async function generateTurn(input: {
speaker: AgentId;
role: CourtRole;
userInstruction: string;
}): Promise<void> {
maxTokens?: number;
capConfig?: WitnessCapConfig;
dialoguePrefix?: string;
}): Promise<CourtTurn> {
const { store, session, speaker, role, userInstruction } = input;
const systemPrompt = buildCourtSystemPrompt({
@@ -58,11 +63,21 @@ async function generateTurn(input: {
{ role: 'user', content: userInstruction },
],
temperature: session.phase === 'witness_exam' ? 0.8 : 0.7,
maxTokens: 260,
maxTokens: input.maxTokens ?? 260,
});
const sanitized = sanitizeDialogue(raw);
const moderation = moderateContent(sanitized);
let dialogue = sanitizeDialogue(raw);
const capResult =
input.capConfig ? applyWitnessCap(dialogue, input.capConfig) : null;
if (capResult?.capped) {
dialogue = capResult.text;
}
if (input.dialoguePrefix) {
dialogue = `${input.dialoguePrefix} ${dialogue}`.trim();
}
const moderation = moderateContent(dialogue);
if (moderation.flagged) {
// eslint-disable-next-line no-console
@@ -71,7 +86,7 @@ async function generateTurn(input: {
);
}
await input.store.addTurn({
const turn = await input.store.addTurn({
sessionId: session.id,
speaker,
role,
@@ -82,6 +97,19 @@ async function generateTurn(input: {
{ flagged: true, reasons: moderation.reasons }
: undefined,
});
if (capResult?.capped && !moderation.flagged) {
store.emitEvent(session.id, 'witness_response_capped', {
turnId: turn.id,
speaker,
phase: session.phase,
originalLength: capResult.originalTokens,
truncatedLength: capResult.truncatedTokens,
reason: capResult.reason ?? 'tokens',
});
}
return turn;
}
function verdictOptions(caseType: CaseType): string[] {
@@ -103,6 +131,15 @@ export async function runCourtSession(
const session = await store.startSession(sessionId);
const tts = options.ttsAdapter ?? createTTSAdapterFromEnv();
const pause = options.sleepFn ?? sleep;
const witnessCapConfig = resolveWitnessCapConfig();
const recapCadenceRaw = Number.parseInt(
process.env.JUDGE_RECAP_CADENCE ?? '2',
10,
);
const recapCadence =
Number.isFinite(recapCadenceRaw) && recapCadenceRaw > 0 ?
recapCadenceRaw
: 2;
const ttsMetrics = {
success: 0,
failure: 0,
@@ -215,6 +252,8 @@ export async function runCourtSession(
role: `witness_${Math.min(index + 1, 3)}` as CourtRole,
userInstruction:
'Provide testimony in 1-3 sentences with one concrete detail and one comedic detail.',
maxTokens: Math.min(260, witnessCapConfig.maxTokens),
capConfig: witnessCapConfig,
});
await pause(600);
@@ -238,21 +277,28 @@ export async function runCourtSession(
});
exchangeCount += 1;
if (exchangeCount % 2 === 0) {
if (exchangeCount % recapCadence === 0) {
await pause(600);
await generateTurn({
const recapTurn = await generateTurn({
store,
session,
speaker: judge,
role: 'judge',
userInstruction:
'Give a two-sentence recap of what matters so far and keep the jury oriented.',
dialoguePrefix: 'Recap:',
});
await store.recordRecap({
sessionId: session.id,
turnId: recapTurn.id,
phase: session.phase,
cycleNumber: exchangeCount,
});
await safelySpeak('speakRecap', () =>
tts.speakRecap({
sessionId: session.id,
phase: 'witness_exam',
text: 'The court has issued a recap for the jury.',
text: recapTurn.dialogue,
}),
);
}

View File

@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyWitnessCap,
estimateTokens,
resolveWitnessCapConfig,
} from './witness-caps.js';
test('estimateTokens counts words', () => {
assert.equal(estimateTokens(''), 0);
assert.equal(estimateTokens('one two three'), 3);
assert.equal(estimateTokens(' spaced words '), 2);
});
test('applyWitnessCap truncates when maxTokens exceeded', () => {
const text = 'one two three four five six seven';
const result = applyWitnessCap(text, {
maxTokens: 5,
maxSeconds: 0,
tokensPerSecond: 3,
truncationMarker: '[cut off]'.trim(),
});
assert.equal(result.capped, true);
assert.equal(result.originalTokens, 7);
assert.equal(result.truncatedTokens, 5);
assert.equal(result.reason, 'tokens');
assert.match(result.text, /\[cut off\]$/);
});
test('applyWitnessCap respects maxSeconds when stricter than maxTokens', () => {
const text = 'one two three four five';
const result = applyWitnessCap(text, {
maxTokens: 10,
maxSeconds: 1,
tokensPerSecond: 2,
truncationMarker: '[cut off]'.trim(),
});
assert.equal(result.capped, true);
assert.equal(result.truncatedTokens, 2);
assert.equal(result.reason, 'seconds');
});
test('applyWitnessCap returns original text when under limits', () => {
const text = 'short response';
const result = applyWitnessCap(text, {
maxTokens: 50,
maxSeconds: 0,
tokensPerSecond: 3,
truncationMarker: '[cut off]'.trim(),
});
assert.equal(result.capped, false);
assert.equal(result.text, text);
});
test('resolveWitnessCapConfig falls back to defaults on invalid values', () => {
const config = resolveWitnessCapConfig({
WITNESS_MAX_TOKENS: 'not-a-number',
WITNESS_MAX_SECONDS: '-1',
WITNESS_TOKENS_PER_SECOND: '0',
WITNESS_TRUNCATION_MARKER: '!!!',
});
assert.equal(config.maxTokens, 150);
assert.equal(config.maxSeconds, 30);
assert.equal(config.tokensPerSecond, 3);
assert.equal(config.truncationMarker, '!!!');
});

107
src/court/witness-caps.ts Normal file
View File

@@ -0,0 +1,107 @@
export interface WitnessCapConfig {
maxTokens: number;
maxSeconds: number;
tokensPerSecond: number;
truncationMarker: string;
}
export interface WitnessCapResult {
text: string;
capped: boolean;
originalTokens: number;
truncatedTokens: number;
reason?: 'tokens' | 'seconds';
}
const DEFAULTS: WitnessCapConfig = {
maxTokens: 150,
maxSeconds: 30,
tokensPerSecond: 3,
truncationMarker: '[The witness was cut off by the judge.]',
};
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function resolveWitnessCapConfig(
env: NodeJS.ProcessEnv = process.env,
): WitnessCapConfig {
return {
maxTokens: parsePositiveInt(env.WITNESS_MAX_TOKENS, DEFAULTS.maxTokens),
maxSeconds: parsePositiveInt(
env.WITNESS_MAX_SECONDS,
DEFAULTS.maxSeconds,
),
tokensPerSecond: parsePositiveInt(
env.WITNESS_TOKENS_PER_SECOND,
DEFAULTS.tokensPerSecond,
),
truncationMarker:
env.WITNESS_TRUNCATION_MARKER ?? DEFAULTS.truncationMarker,
};
}
export function estimateTokens(text: string): number {
const trimmed = text.trim();
if (!trimmed) return 0;
return trimmed.split(/\s+/).length;
}
export function truncateToTokens(text: string, maxTokens: number): string {
if (maxTokens <= 0) return '';
const parts = text.trim().split(/\s+/);
if (parts.length <= maxTokens) return text.trim();
return parts.slice(0, maxTokens).join(' ');
}
function effectiveTokenLimit(config: WitnessCapConfig): {
limit: number | undefined;
reason?: 'tokens' | 'seconds';
} {
const tokenLimit =
config.maxTokens > 0 ? config.maxTokens : Number.POSITIVE_INFINITY;
const timeLimit =
config.maxSeconds > 0 ?
Math.floor(config.maxSeconds * config.tokensPerSecond)
: Number.POSITIVE_INFINITY;
const limit = Math.min(tokenLimit, timeLimit);
if (!Number.isFinite(limit)) {
return { limit: undefined };
}
if (timeLimit < tokenLimit) {
return { limit, reason: 'seconds' };
}
return { limit, reason: 'tokens' };
}
export function applyWitnessCap(
text: string,
config: WitnessCapConfig,
): WitnessCapResult {
const originalTokens = estimateTokens(text);
const { limit, reason } = effectiveTokenLimit(config);
if (!limit || originalTokens <= limit) {
return {
text,
capped: false,
originalTokens,
truncatedTokens: originalTokens,
};
}
const cappedText = truncateToTokens(text, Math.max(1, limit));
return {
text: `${cappedText} ${config.truncationMarker}`.trim(),
capped: true,
originalTokens,
truncatedTokens: Math.max(1, limit),
reason,
};
}

128
src/e2e-round.test.ts Normal file
View File

@@ -0,0 +1,128 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { AGENT_IDS } from './agents.js';
import { assignCourtRoles } from './court/roles.js';
import { runCourtSession } from './court/orchestrator.js';
import { MockTTSAdapter } from './tts/adapter.js';
import { createCourtSessionStore } from './store/session-store.js';
import type { CourtEvent } from './types.js';
async function createInMemoryStore() {
const previousDatabaseUrl = process.env.DATABASE_URL;
process.env.DATABASE_URL = '';
try {
return await createCourtSessionStore();
} finally {
if (previousDatabaseUrl === undefined) {
delete process.env.DATABASE_URL;
} else {
process.env.DATABASE_URL = previousDatabaseUrl;
}
}
}
test('e2e round completes with witness caps and recap cadence', async () => {
const previousCapTokens = process.env.WITNESS_MAX_TOKENS;
const previousCapSeconds = process.env.WITNESS_MAX_SECONDS;
const previousCadence = process.env.JUDGE_RECAP_CADENCE;
process.env.WITNESS_MAX_TOKENS = '5';
process.env.WITNESS_MAX_SECONDS = '999';
process.env.JUDGE_RECAP_CADENCE = '2';
try {
const store = await createInMemoryStore();
const participants = AGENT_IDS;
const session = await store.createSession({
topic: 'Did the defendant replace all office coffee with soup?',
participants,
metadata: {
mode: 'improv_court',
casePrompt:
'Did the defendant replace all office coffee with soup?',
caseType: 'criminal',
roleAssignments: assignCourtRoles(participants),
sentenceOptions: ['Fine', 'Community service'],
verdictVoteWindowMs: 1,
sentenceVoteWindowMs: 1,
verdictVotes: {},
sentenceVotes: {},
},
});
const recapEvents: CourtEvent[] = [];
const capEvents: CourtEvent[] = [];
const unsubscribe = store.subscribe(session.id, event => {
if (event.type === 'judge_recap_emitted') {
recapEvents.push(event);
}
if (event.type === 'witness_response_capped') {
capEvents.push(event);
}
});
const voteFlags = { verdict: false, sentence: false };
const sleepFn = async () => {
const current = await store.getSession(session.id);
if (!current) return;
if (current.phase === 'verdict_vote' && !voteFlags.verdict) {
await store.castVote({
sessionId: session.id,
voteType: 'verdict',
choice: 'guilty',
});
await store.castVote({
sessionId: session.id,
voteType: 'verdict',
choice: 'guilty',
});
await store.castVote({
sessionId: session.id,
voteType: 'verdict',
choice: 'not_guilty',
});
voteFlags.verdict = true;
}
if (current.phase === 'sentence_vote' && !voteFlags.sentence) {
await store.castVote({
sessionId: session.id,
voteType: 'sentence',
choice: 'Fine',
});
voteFlags.sentence = true;
}
};
await runCourtSession(session.id, store, {
ttsAdapter: new MockTTSAdapter(),
sleepFn,
});
unsubscribe();
const completed = await store.getSession(session.id);
assert.equal(completed?.status, 'completed');
assert.equal(completed?.metadata.finalRuling?.verdict, 'guilty');
assert.ok((completed?.metadata.recapTurnIds ?? []).length >= 1);
assert.ok(recapEvents.length >= 1);
assert.ok(capEvents.length >= 1);
} finally {
if (previousCapTokens === undefined) {
delete process.env.WITNESS_MAX_TOKENS;
} else {
process.env.WITNESS_MAX_TOKENS = previousCapTokens;
}
if (previousCapSeconds === undefined) {
delete process.env.WITNESS_MAX_SECONDS;
} else {
process.env.WITNESS_MAX_SECONDS = previousCapSeconds;
}
if (previousCadence === undefined) {
delete process.env.JUDGE_RECAP_CADENCE;
} else {
process.env.JUDGE_RECAP_CADENCE = previousCadence;
}
}
});

View File

@@ -94,6 +94,33 @@ test('assertEventPayload: vote_closed valid', () => {
);
});
test('assertEventPayload: witness_response_capped valid', () => {
assert.doesNotThrow(() =>
assertEventPayload(
makeEvent('witness_response_capped', {
turnId: 'turn-1',
speaker: 'chora',
phase: 'witness_exam',
originalLength: 180,
truncatedLength: 120,
reason: 'tokens',
}),
),
);
});
test('assertEventPayload: judge_recap_emitted valid', () => {
assert.doesNotThrow(() =>
assertEventPayload(
makeEvent('judge_recap_emitted', {
turnId: 'turn-2',
phase: 'witness_exam',
cycleNumber: 2,
}),
),
);
});
test('assertEventPayload: analytics_event poll_started valid', () => {
assert.doesNotThrow(() =>
assertEventPayload(
@@ -252,6 +279,35 @@ test('assertEventPayload: vote_closed missing votes', () => {
);
});
test('assertEventPayload: witness_response_capped missing turnId', () => {
assert.throws(
() =>
assertEventPayload(
makeEvent('witness_response_capped', {
speaker: 'chora',
phase: 'witness_exam',
originalLength: 180,
truncatedLength: 120,
reason: 'tokens',
}),
),
TypeError,
);
});
test('assertEventPayload: judge_recap_emitted missing cycleNumber', () => {
assert.throws(
() =>
assertEventPayload(
makeEvent('judge_recap_emitted', {
turnId: 'turn-2',
phase: 'witness_exam',
}),
),
TypeError,
);
});
test('assertEventPayload: analytics_event missing pollType', () => {
assert.throws(
() =>

View File

@@ -53,6 +53,21 @@ export interface VoteClosedPayload {
nextPhase: CourtPhase;
}
export interface WitnessResponseCappedPayload {
turnId: string;
speaker: string;
phase: CourtPhase;
originalLength: number;
truncatedLength: number;
reason: 'tokens' | 'seconds';
}
export interface JudgeRecapEmittedPayload {
turnId: string;
phase: CourtPhase;
cycleNumber: number;
}
export type AnalyticsEventName =
| 'poll_started'
| 'vote_completed'
@@ -174,6 +189,34 @@ export function assertEventPayload(event: CourtEvent): void {
}
break;
case 'witness_response_capped':
if (
!hasStringKeys(payload, [
'turnId',
'speaker',
'phase',
'reason',
]) ||
typeof payload['originalLength'] !== 'number' ||
typeof payload['truncatedLength'] !== 'number'
) {
throw new TypeError(
`witness_response_capped payload missing required fields: turnId, speaker, phase, originalLength, truncatedLength, reason`,
);
}
break;
case 'judge_recap_emitted':
if (
!hasStringKeys(payload, ['turnId', 'phase']) ||
typeof payload['cycleNumber'] !== 'number'
) {
throw new TypeError(
`judge_recap_emitted payload missing required fields: turnId, phase, cycleNumber`,
);
}
break;
case 'analytics_event':
if (!hasStringKeys(payload, ['name', 'pollType'])) {
throw new TypeError(

View File

@@ -3,6 +3,7 @@ import type { LLMGenerateOptions } from '../types.js';
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? '';
const DEFAULT_MODEL =
process.env.LLM_MODEL ?? 'deepseek/deepseek-chat-v3-0324:free';
const FORCE_MOCK = process.env.LLM_MOCK === 'true';
function extractFromXml(text: string): string {
const contentMatch = text.match(
@@ -60,7 +61,7 @@ export async function llmGenerate(
.reverse()
.find(message => message.role === 'user')?.content;
if (!OPENROUTER_API_KEY) {
if (!OPENROUTER_API_KEY || FORCE_MOCK) {
return mockReply(latestUserMessage ?? '');
}

96
src/logger.test.ts Normal file
View File

@@ -0,0 +1,96 @@
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { Logger, createLogger } from './logger.js';
describe('Logger', () => {
it('should create logger with default info level', () => {
const logger = new (Logger as any)();
assert.equal(logger.minLevel, 'info');
});
it('should respect log level hierarchy', () => {
const logger = new (Logger as any)('warn');
// Mock console methods
const logs: string[] = [];
const originalLog = console.log;
const originalError = console.error;
console.log = (msg: string) => logs.push(msg);
console.error = (msg: string) => logs.push(msg);
logger.debug('debug message');
logger.info('info message');
logger.warn('warn message');
logger.error('error message');
// Restore console
console.log = originalLog;
console.error = originalError;
// Only warn and error should be logged
assert.equal(logs.length, 2);
assert.ok(logs[0].includes('warn message'));
assert.ok(logs[1].includes('error message'));
});
it('should include context in log entries', () => {
const logger = new (Logger as any)('debug');
let capturedLog = '';
const originalLog = console.log;
console.log = (msg: string) => {
capturedLog = msg;
};
logger.info('test message', {
sessionId: 'sess123',
phase: 'WITNESS_1',
});
console.log = originalLog;
const parsed = JSON.parse(capturedLog);
assert.equal(parsed.message, 'test message');
assert.equal(parsed.context.sessionId, 'sess123');
assert.equal(parsed.context.phase, 'WITNESS_1');
assert.ok(parsed.timestamp);
});
it('should create child logger with base context', () => {
const logger = new (Logger as any)('debug');
const childLogger = logger.child({ sessionId: 'sess456' });
let capturedLog = '';
const originalLog = console.log;
console.log = (msg: string) => {
capturedLog = msg;
};
childLogger.info('child message', { eventType: 'PHASE_START' });
console.log = originalLog;
const parsed = JSON.parse(capturedLog);
assert.equal(parsed.context.sessionId, 'sess456');
assert.equal(parsed.context.eventType, 'PHASE_START');
});
it('should write errors to stderr', () => {
const logger = new (Logger as any)('error');
let capturedError = '';
const originalError = console.error;
console.error = (msg: string) => {
capturedError = msg;
};
logger.error('error message');
console.error = originalError;
assert.ok(capturedError.includes('error message'));
const parsed = JSON.parse(capturedError);
assert.equal(parsed.level, 'error');
});
});

113
src/logger.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* Structured logging service for Improv Court
* Provides JSON-formatted logs with session/phase/event correlation
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type LogContext = {
sessionId?: string;
phase?: string;
eventType?: string;
userId?: string;
[key: string]: unknown;
};
interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
context?: LogContext;
}
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
export class Logger {
private minLevel: LogLevel;
constructor(minLevel: LogLevel = 'info') {
this.minLevel = minLevel;
}
private shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[this.minLevel];
}
private write(
level: LogLevel,
message: string,
context?: LogContext,
): void {
if (!this.shouldLog(level)) return;
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
context,
};
const output = JSON.stringify(entry);
// Write to appropriate stream based on level
if (level === 'error' || level === 'warn') {
console.error(output);
} else {
console.log(output);
}
}
debug(message: string, context?: LogContext): void {
this.write('debug', message, context);
}
info(message: string, context?: LogContext): void {
this.write('info', message, context);
}
warn(message: string, context?: LogContext): void {
this.write('warn', message, context);
}
error(message: string, context?: LogContext): void {
this.write('error', message, context);
}
// Convenience method to create a child logger with base context
child(baseContext: LogContext): Logger {
const parent = this;
const childLogger = Object.create(Logger.prototype);
childLogger.minLevel = this.minLevel;
childLogger.shouldLog = this.shouldLog.bind(this);
// Override write to merge contexts
childLogger.write = (
level: LogLevel,
message: string,
context?: LogContext,
) => {
const mergedContext = { ...baseContext, ...context };
parent.write(level, message, mergedContext);
};
return childLogger;
}
setLevel(level: LogLevel): void {
this.minLevel = level;
}
}
// Create singleton logger instance
const logLevel = (process.env.LOG_LEVEL as LogLevel) || 'info';
export const logger = new Logger(logLevel);
// Export convenience function for creating child loggers
export function createLogger(context: LogContext): Logger {
return logger.child(context);
}

View File

@@ -53,6 +53,7 @@ export async function createServerApp(
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const publicDir = path.resolve(__dirname, '../public');
const dashboardDir = path.resolve(__dirname, '../dist/dashboard');
const verdictWindowMs = Number.parseInt(
process.env.VERDICT_VOTE_WINDOW_MS ?? '20000',
@@ -72,6 +73,11 @@ export async function createServerApp(
pruneTimer.unref();
app.use(express.json());
// Serve operator dashboard
app.use('/operator', express.static(dashboardDir));
// Serve main public app
app.use(express.static(publicDir));
app.get('/api/health', (_req, res) => {
@@ -309,6 +315,7 @@ export async function createServerApp(
turns: session.turns,
verdictVotes: session.metadata.verdictVotes,
sentenceVotes: session.metadata.sentenceVotes,
recapTurnIds: session.metadata.recapTurnIds ?? [],
},
});
@@ -324,6 +331,12 @@ export async function createServerApp(
},
);
// Catch-all for operator dashboard (SPA routing)
app.get('/operator/*', (_req, res) => {
res.sendFile(path.join(dashboardDir, 'index.html'));
});
// Catch-all for main app (SPA routing)
app.get('*', (_req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
@@ -347,10 +360,12 @@ export async function createServerApp(
export async function bootstrap(): Promise<void> {
const { app } = await createServerApp();
const port = Number.parseInt(process.env.PORT ?? '3001', 10);
const port = Number.parseInt(process.env.PORT ?? '3000', 10);
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`Improv Court POC running on http://localhost:${port}`);
// eslint-disable-next-line no-console
console.log(`Operator Dashboard: http://localhost:${port}/operator`);
});
}

View File

@@ -221,6 +221,45 @@ test('emits vote_closed and persists vote snapshots when vote phases end', async
});
});
test('recordRecap stores recap turn ids and emits judge_recap_emitted', async () => {
const { store, sessionId } = await createRunningSession();
const recapEvents: CourtEvent[] = [];
const unsubscribe = store.subscribe(sessionId, event => {
if (event.type === 'judge_recap_emitted') {
recapEvents.push(event);
}
});
try {
await store.setPhase(sessionId, 'openings');
const recapTurn = await store.addTurn({
sessionId,
speaker: 'chora',
role: 'judge',
phase: 'openings',
dialogue: 'Recap: The witness spilled the soup.',
});
await store.recordRecap({
sessionId,
turnId: recapTurn.id,
phase: 'openings',
cycleNumber: 2,
});
} finally {
unsubscribe();
}
assert.equal(recapEvents.length, 1);
const session = await store.getSession(sessionId);
assert.deepEqual(session?.metadata.recapTurnIds, [
recapEvents[0]?.payload.turnId,
]);
assert.equal(recapEvents[0]?.payload.phase, 'openings');
assert.equal(recapEvents[0]?.payload.cycleNumber, 2);
});
test(
'postgres store persists final ruling when TEST_DATABASE_URL is provided',
{ skip: !process.env.TEST_DATABASE_URL },

View File

@@ -118,6 +118,12 @@ export interface CourtSessionStore {
verdict: string;
sentence: string;
}): Promise<CourtSession>;
recordRecap(input: {
sessionId: string;
turnId: string;
phase: CourtPhase;
cycleNumber: number;
}): Promise<void>;
completeSession(sessionId: string): Promise<CourtSession>;
failSession(sessionId: string, reason: string): Promise<CourtSession>;
recoverInterruptedSessions(): Promise<string[]>;
@@ -402,6 +408,29 @@ class InMemoryCourtSessionStore implements CourtSessionStore {
return deepCopy(session);
}
async recordRecap(input: {
sessionId: string;
turnId: string;
phase: CourtPhase;
cycleNumber: number;
}): Promise<void> {
const session = this.mustGet(input.sessionId);
session.metadata.recapTurnIds ??= [];
if (!session.metadata.recapTurnIds.includes(input.turnId)) {
session.metadata.recapTurnIds.push(input.turnId);
}
this.publish({
sessionId: input.sessionId,
type: 'judge_recap_emitted',
payload: {
turnId: input.turnId,
phase: input.phase,
cycleNumber: input.cycleNumber,
},
});
}
async completeSession(sessionId: string): Promise<CourtSession> {
const session = this.mustGet(sessionId);
session.status = 'completed';
@@ -960,6 +989,55 @@ class PostgresCourtSessionStore implements CourtSessionStore {
return this.mapSession(row, turns);
}
async recordRecap(input: {
sessionId: string;
turnId: string;
phase: CourtPhase;
cycleNumber: number;
}): Promise<void> {
const row = await this.db.begin(async (tx: any) => {
const [current] = await tx<SessionRow[]>`
SELECT *
FROM court_sessions
WHERE id = ${input.sessionId}
FOR UPDATE
`;
if (!current) {
throw new CourtNotFoundError(
`Session not found: ${input.sessionId}`,
);
}
const metadata = {
...(current.metadata ?? {}),
} as CourtSessionMetadata;
metadata.recapTurnIds ??= [];
if (!metadata.recapTurnIds.includes(input.turnId)) {
metadata.recapTurnIds.push(input.turnId);
}
const [updated] = await tx<SessionRow[]>`
UPDATE court_sessions
SET metadata = ${tx.json(metadata as unknown as Record<string, unknown>)}
WHERE id = ${input.sessionId}
RETURNING *
`;
return updated;
});
this.publish({
sessionId: input.sessionId,
type: 'judge_recap_emitted',
payload: {
turnId: input.turnId,
phase: input.phase,
cycleNumber: input.cycleNumber,
},
});
}
async completeSession(sessionId: string): Promise<CourtSession> {
const [row] = await this.db<SessionRow[]>`
UPDATE court_sessions

36
src/stream-state.test.ts Normal file
View File

@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createStreamState,
isRecapTurn,
markRecap,
resetStreamState,
shouldAppendTurn,
} from '../public/stream-state.js';
test('resetStreamState seeds seen turns and recap ids', () => {
const state = createStreamState();
resetStreamState(state, {
turns: [{ id: 'turn-1' }, { id: 'turn-2' }],
recapTurnIds: ['turn-2'],
});
assert.equal(shouldAppendTurn(state, { id: 'turn-1' }), false);
assert.equal(isRecapTurn(state, 'turn-2'), true);
});
test('shouldAppendTurn prevents duplicates and accepts new turns', () => {
const state = createStreamState();
resetStreamState(state, { turns: [], recapTurnIds: [] });
assert.equal(shouldAppendTurn(state, { id: 'turn-3' }), true);
assert.equal(shouldAppendTurn(state, { id: 'turn-3' }), false);
});
test('markRecap flags recap turn ids', () => {
const state = createStreamState();
resetStreamState(state, { turns: [], recapTurnIds: [] });
markRecap(state, 'turn-9');
assert.equal(isRecapTurn(state, 'turn-9'), true);
});

View File

@@ -77,6 +77,7 @@ export interface CourtSessionMetadata {
votes: Record<string, number>;
};
};
recapTurnIds?: string[];
finalRuling?: {
verdict: string;
sentence: string;
@@ -121,6 +122,8 @@ export type CourtEventType =
| 'turn'
| 'vote_updated'
| 'vote_closed'
| 'witness_response_capped'
| 'judge_recap_emitted'
| 'analytics_event'
| 'moderation_action'
| 'vote_spam_blocked'

23
tailwind.config.js Normal file
View File

@@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./dashboard/index.html', './dashboard/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: {
50: '#fdf2f8',
100: '#fce7f3',
200: '#fbcfe8',
300: '#f9a8d4',
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
700: '#be185d',
800: '#9d174d',
900: '#831843',
},
},
},
},
plugins: [],
};

View File

@@ -11,5 +11,5 @@
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}

21
vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
root: 'dashboard',
build: {
outDir: '../dist/dashboard',
emptyOutDir: true,
},
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});