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:
1
Makefile
1
Makefile
@@ -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/
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
} {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolve } from 'path';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: 'dashboard',
|
||||
base: '/operator/',
|
||||
build: {
|
||||
outDir: '../dist/dashboard',
|
||||
emptyOutDir: true,
|
||||
|
||||
Reference in New Issue
Block a user