fix: apply all PR review feedback - API endpoints, types, logger, caps, build config

Co-authored-by: PatrickFanella <61631520+PatrickFanella@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-27 05:29:20 +00:00
parent 8248ee2901
commit d16be3b696
12 changed files with 206 additions and 78 deletions

View File

@@ -42,7 +42,6 @@ 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

@@ -13,17 +13,29 @@ function App() {
const [sessionId, setSessionId] = useState<string | null>(null);
const [events, setEvents] = useState<CourtEvent[]>([]);
const { connected, error } = useSSE(event => {
const { connected, error } = useSSE(sessionId, event => {
setEvents(prev => [...prev, event]);
});
useEffect(() => {
// Fetch current session on mount
fetch('/api/session')
.then(res => res.json())
fetch('/api/court/sessions')
.then(res => {
if (!res.ok) {
throw new Error(`Unexpected status ${res.status}`);
}
return res.json();
})
.then(data => {
if (data.sessionId) {
setSessionId(data.sessionId);
let id: string | null = null;
if (Array.isArray(data.sessions) && data.sessions.length > 0) {
const first = data.sessions[0] as any;
id = (first && (first.id || first.sessionId)) ?? null;
}
if (id) {
setSessionId(id);
}
})
.catch(err => console.error('Failed to fetch session:', err));

View File

@@ -16,18 +16,19 @@ export function Analytics({ events }: AnalyticsProps) {
);
const byPhase = events
.filter(e => e.phase)
.filter(e => e.type === 'phase_changed')
.reduce(
(acc, event) => {
acc[event.phase!] = (acc[event.phase!] || 0) + 1;
const phase = event.payload.phase as string;
if (phase) acc[phase] = (acc[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');
const votes = events.filter(e => e.type === 'vote_updated');
const statements = events.filter(e => e.type === 'turn');
const recaps = events.filter(e => e.type === 'judge_recap_emitted');
return {
total: events.length,
@@ -157,18 +158,24 @@ export function Analytics({ events }: AnalyticsProps) {
>
<div className='text-xs text-gray-500 font-mono w-20'>
{new Date(
event.timestamp,
event.at,
).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>
)}
{event.type === 'phase_changed' &&
event.payload.phase && (
<span className='ml-2 text-xs text-gray-400'>
(
{
event.payload
.phase as string
}
)
</span>
)}
</div>
</div>
))

View File

@@ -24,10 +24,20 @@ export function ManualControls({ sessionId }: ManualControlsProps) {
setMessage(null);
try {
const response = await fetch(`/api/session/${action}`, {
let url: string;
let body: Record<string, unknown> = data || {};
if (action === 'advance-phase') {
url = `/api/court/sessions/${sessionId}/phase`;
body = { phase: data?.targetPhase };
} else {
url = `/api/court/sessions/${sessionId}/${action}`;
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data || {}),
body: JSON.stringify(body),
});
if (!response.ok) {
@@ -50,9 +60,10 @@ export function ManualControls({ sessionId }: ManualControlsProps) {
setMessage(null);
try {
const response = await fetch('/api/session', {
const response = await fetch('/api/court/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic: 'Operator-created session' }),
});
if (!response.ok) {
@@ -62,9 +73,10 @@ export function ManualControls({ sessionId }: ManualControlsProps) {
}
const data = await response.json();
const sessionId = data.session?.id ?? data.sessionId;
setMessage({
type: 'success',
text: `New session created: ${data.sessionId}`,
text: `New session created: ${sessionId}`,
});
// Reload page to connect to new session
@@ -127,23 +139,23 @@ export function ManualControls({ sessionId }: ManualControlsProps) {
<div className='grid grid-cols-1 md:grid-cols-2 gap-3'>
{[
{
phase: 'WITNESS_1',
label: 'Start Witness 1',
phase: 'witness_exam',
label: 'Start Witness Exam',
emoji: '👤',
},
{
phase: 'WITNESS_2',
label: 'Start Witness 2',
emoji: '👥',
},
{
phase: 'DELIBERATION',
label: 'Start Deliberation',
phase: 'closings',
label: 'Start Closings',
emoji: '⚖️',
},
{
phase: 'VERDICT',
label: 'Start Verdict',
phase: 'verdict_vote',
label: 'Start Verdict Vote',
emoji: '🗳️',
},
{
phase: 'final_ruling',
label: 'Final Ruling',
emoji: '📜',
},
].map(({ phase, label, emoji }) => (

View File

@@ -13,10 +13,40 @@ export function SessionMonitor({ events, sessionId }: SessionMonitorProps) {
useEffect(() => {
if (!sessionId) return;
fetch('/api/session')
.then(res => res.json())
fetch(`/api/court/sessions/${sessionId}`)
.then(res => {
if (!res.ok) throw new Error(`Status ${res.status}`);
return res.json();
})
.then(data => {
setSnapshot(data);
const s = data.session;
if (!s) return;
setSnapshot({
sessionId: s.id,
phase: s.phase,
transcript: (s.turns ?? []).map((t: any) => ({
speaker: t.speaker,
content: t.dialogue,
timestamp: t.createdAt,
isRecap: s.metadata?.recapTurnIds?.includes(t.id),
})),
votes: {
verdict: {
guilty:
s.metadata?.verdictVotes?.guilty ?? 0,
innocent:
s.metadata?.verdictVotes?.not_guilty ??
s.metadata?.verdictVotes?.not_liable ??
0,
total: Object.values(
s.metadata?.verdictVotes ?? {},
).reduce((a: number, b) => a + (b as number), 0),
},
},
recapCount: (s.metadata?.recapTurnIds ?? []).length,
witnessCaps: { witness1: 0, witness2: 0 },
config: { maxWitnessStatements: 3, recapInterval: 2 },
});
setLoading(false);
})
.catch(err => {
@@ -190,26 +220,31 @@ export function SessionMonitor({ events, sessionId }: SessionMonitorProps) {
</span>
<span className='text-xs text-gray-400'>
{new Date(
event.timestamp,
event.at,
).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>
)}
{event.type === 'turn' &&
(event.payload.turn as any)?.speaker && (
<div className='text-gray-300'>
<span className='text-gray-400'>
Speaker:
</span>{' '}
{
(event.payload.turn as any)
.speaker
}
</div>
)}
{event.type === 'phase_changed' &&
event.payload.phase && (
<div className='text-gray-300'>
<span className='text-gray-400'>
Phase:
</span>{' '}
{event.payload.phase as string}
</div>
)}
</div>
))
}

View File

@@ -1,19 +1,24 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import type { CourtEvent } from '../types';
import type { CourtEvent, SSEMessage } from '../types';
export function useSSE(onEvent: (event: CourtEvent) => void) {
export function useSSE(
sessionId: string | null,
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) {
if (eventSourceRef.current || !sessionId) {
return;
}
try {
const es = new EventSource('/api/events');
const es = new EventSource(
`/api/court/sessions/${sessionId}/stream`,
);
eventSourceRef.current = es;
es.onopen = () => {
@@ -42,8 +47,10 @@ export function useSSE(onEvent: (event: CourtEvent) => void) {
es.onmessage = e => {
try {
const event = JSON.parse(e.data) as CourtEvent;
onEvent(event);
const msg = JSON.parse(e.data) as SSEMessage;
if (msg.type !== 'snapshot') {
onEvent(msg as CourtEvent);
}
} catch (err) {
console.error('Failed to parse SSE event:', err);
}
@@ -52,9 +59,11 @@ export function useSSE(onEvent: (event: CourtEvent) => void) {
console.error('Failed to create EventSource:', err);
setError('Failed to connect to event stream');
}
}, [onEvent]);
}, [onEvent, sessionId]);
useEffect(() => {
if (!sessionId) return;
connect();
return () => {
@@ -66,7 +75,7 @@ export function useSSE(onEvent: (event: CourtEvent) => void) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, [connect]);
}, [connect, sessionId]);
return { connected, error };
}

View File

@@ -1,15 +1,33 @@
export type CourtEventType =
| 'session_created'
| 'session_started'
| 'phase_changed'
| 'turn'
| 'vote_updated'
| 'vote_closed'
| 'witness_response_capped'
| 'judge_recap_emitted'
| 'analytics_event'
| 'moderation_action'
| 'vote_spam_blocked'
| 'session_completed'
| 'session_failed';
export interface CourtEvent {
type: string;
timestamp: string;
sessionId?: string;
phase?: string;
speaker?: string;
content?: string;
voterId?: string;
vote?: string;
[key: string]: unknown;
id: string;
sessionId: string;
type: CourtEventType;
at: string;
payload: Record<string, unknown>;
}
export interface SnapshotMessage {
type: 'snapshot';
payload: Record<string, unknown>;
}
export type SSEMessage = CourtEvent | SnapshotMessage;
export interface SessionSnapshot {
sessionId: string;
phase: string;

View File

@@ -2,7 +2,11 @@ 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 {
applyWitnessCap,
effectiveTokenLimit,
resolveWitnessCapConfig,
} from './witness-caps.js';
import type { WitnessCapConfig } from './witness-caps.js';
import type {
AgentId,
@@ -252,7 +256,10 @@ 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),
maxTokens: Math.min(
260,
effectiveTokenLimit(witnessCapConfig).limit ?? 260,
),
capConfig: witnessCapConfig,
});
await pause(600);

View File

@@ -26,12 +26,24 @@ function parsePositiveInt(value: string | undefined, fallback: number): number {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function parseNonNegativeInt(
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(
maxTokens: parseNonNegativeInt(
env.WITNESS_MAX_TOKENS,
DEFAULTS.maxTokens,
),
maxSeconds: parseNonNegativeInt(
env.WITNESS_MAX_SECONDS,
DEFAULTS.maxSeconds,
),
@@ -57,7 +69,7 @@ export function truncateToTokens(text: string, maxTokens: number): string {
return parts.slice(0, maxTokens).join(' ');
}
function effectiveTokenLimit(config: WitnessCapConfig): {
export function effectiveTokenLimit(config: WitnessCapConfig): {
limit: number | undefined;
reason?: 'tokens' | 'seconds';
} {

View File

@@ -83,14 +83,15 @@ export class Logger {
const childLogger = Object.create(Logger.prototype);
childLogger.minLevel = this.minLevel;
childLogger.shouldLog = this.shouldLog.bind(this);
// Override write to merge contexts
// Override write to check child's own minLevel and merge contexts.
// This ensures setLevel() on the child has the expected effect.
childLogger.write = (
level: LogLevel,
message: string,
context?: LogContext,
) => {
if (LOG_LEVELS[level] < LOG_LEVELS[childLogger.minLevel]) return;
const mergedContext = { ...baseContext, ...context };
parent.write(level, message, mergedContext);
};
@@ -104,7 +105,11 @@ export class Logger {
}
// Create singleton logger instance
const logLevel = (process.env.LOG_LEVEL as LogLevel) || 'info';
const rawLogLevel = process.env.LOG_LEVEL;
const logLevel: LogLevel =
rawLogLevel && rawLogLevel in LOG_LEVELS ?
(rawLogLevel as LogLevel)
: 'info';
export const logger = new Logger(logLevel);
// Export convenience function for creating child loggers

View File

@@ -333,7 +333,18 @@ export async function createServerApp(
// Catch-all for operator dashboard (SPA routing)
app.get('/operator/*', (_req, res) => {
res.sendFile(path.join(dashboardDir, 'index.html'));
const indexPath = path.join(dashboardDir, 'index.html');
res.sendFile(indexPath, err => {
if (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
res.status(404).send(
'Operator dashboard not found. Run `npm run build:dashboard` first.',
);
} else {
res.status(500).send('Failed to load operator dashboard.');
}
}
});
});
// Catch-all for main app (SPA routing)

View File

@@ -5,6 +5,7 @@ import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
root: 'dashboard',
base: '/operator/',
build: {
outDir: '../dist/dashboard',
emptyOutDir: true,