refactor: clean code review fixes across full repo
- Extract shared payload-guards (asRecord, asString, asNumber, asStringArray) into dashboard/src/utils/payload-guards.ts (DRY) - Extract wrapWithMetrics() helper in src/metrics.ts to eliminate repetitive try/catch instrumentation blocks - Add exponential backoff to useSSE reconnect (3s→6s→12s, capped at 30s, max 10 retries) - Create centralized public/renderer/theme.js with named color and layout constants; update all 5 layer files to import from it - Extract parsePositiveInt/parsePositiveFloat into shared src/parse-env.ts; remove duplicates from server.ts and token-budget.ts - Deduplicate selectNextPrompt/selectNextSafePrompt via optional filter predicate parameter - Migrate console.warn/error to structured logger in server.ts, orchestrator.ts, and broadcast/adapter.ts - Rename bestOf→mostVotedChoice, input→promptConfig/rotationInput, stitched→recentTurnsSummary for intent-revealing names - Add named constants for magic numbers (JITTER_RANGE, RECENCY_PENALTY, RECENT_TURNS_COUNT) and rationale comments for token budget defaults - Add TODO(phase-8) header to OBS adapter stubs - Document Object.create design choice in logger.ts - Guard shake() timer leak in effects.js All 158 tests passing, 0 type errors.
This commit is contained in:
@@ -5,7 +5,8 @@ PORT=3000
|
||||
# Set to 1 when behind one reverse proxy (Docker ingress, Nginx, Traefik, etc.)
|
||||
TRUST_PROXY=
|
||||
API_HOST_PORT=3001
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/juryrigged
|
||||
POSTGRES_PASSWORD=change_this_now
|
||||
DATABASE_URL=postgresql://postgres:change_this_now@localhost:5432/juryrigged
|
||||
TTS_PROVIDER=noop
|
||||
LOG_LEVEL=info
|
||||
VERDICT_VOTE_WINDOW_MS=20000
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
"scope": [
|
||||
"src/**/*.ts",
|
||||
"dashboard/src/**/*.{ts,tsx}",
|
||||
"public/app.js"
|
||||
"public/**/*.js",
|
||||
"public/renderer/**/*.js"
|
||||
],
|
||||
"generatedAt": "2026-02-27",
|
||||
"resolved": [
|
||||
"generatedAt": "2025-01-28",
|
||||
"summary": {
|
||||
"high": 7,
|
||||
"medium": 14,
|
||||
"low": 6,
|
||||
"total": 27
|
||||
},
|
||||
"issues": [
|
||||
{
|
||||
"id": "CC-003",
|
||||
"id": "CC-001",
|
||||
"summary": "public app stream handling now uses dispatch-map handlers"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,62 +1,252 @@
|
||||
# Clean Code Review (Refreshed)
|
||||
# Clean Code Review — JuryRigged (Full Repo)
|
||||
|
||||
Scope reviewed:
|
||||
**Date**: 2025-01-28
|
||||
**Scope**: All source files (`src/`, `dashboard/`, `public/`, config)
|
||||
**Reviewer**: Automated (Clean Code dimensions)
|
||||
**Conventions ref**: `docs/coding-conventions.md`
|
||||
|
||||
- `src/**/*.ts`
|
||||
- `dashboard/src/**/*.{ts,tsx}`
|
||||
- `public/app.js`
|
||||
---
|
||||
|
||||
Generated: 2026-02-27
|
||||
## Summary
|
||||
|
||||
## Resolved since previous pass
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| High | 7 |
|
||||
| Medium | 14 |
|
||||
| Low | 6 |
|
||||
|
||||
- `public/app.js` stream handling now uses an event dispatch map (previous branch-heavy `if` chain removed).
|
||||
- `src/court/prompt-bank.ts` selection duplication consolidated via shared helpers; legacy `selectNextPrompt` is now deprecated.
|
||||
- `src/moderation/vote-spam.ts` stale-entry cleanup duplication removed.
|
||||
- `src/court/phases/session-flow.ts` now uses shared phase entry helper (`beginPhase`) and named timing constants.
|
||||
- `src/server.ts` uses shared mutation error mapping and extracted route registration helpers.
|
||||
- `src/store/session-store.ts` removed `tx:any`; transaction query casting is centralized in one helper (`withTxQuery`).
|
||||
- `dashboard/` naming and timing-literal quick wins completed.
|
||||
- `docs/coding-conventions.md` added and linked from `README.md`.
|
||||
The codebase is well-structured overall: domain types are centralized, event flows are clearly separated, error handling at API boundaries is solid, and the coding conventions are largely followed. The main areas for improvement are **duplicated utility helpers in the dashboard**, **magic numbers in the renderer layer**, and a handful of **long functions** that would benefit from extraction.
|
||||
|
||||
## Remaining issues (priority ordered)
|
||||
---
|
||||
|
||||
### 1) `createServerApp` still coordinates many responsibilities
|
||||
## High Severity
|
||||
|
||||
### [Duplication]: `asRecord` / `asString` / `asNumber` duplicated across dashboard files
|
||||
|
||||
- **Principle**: DRY
|
||||
- **Location**: `dashboard/src/App.tsx:10-22`, `dashboard/src/session-snapshot.ts:23-43`
|
||||
- **Severity**: High
|
||||
- **Issue**: Identical payload-narrowing helpers (`asRecord`, `asString`, `asNumber`, `asStringArray`) are copy-pasted in two files. Any behavioral change must be applied in both places.
|
||||
- **Suggestion**: Extract into `dashboard/src/utils/payload-guards.ts` and import from both files.
|
||||
|
||||
### [Function Size]: `createDialogueStateMachine()` — ~230 lines
|
||||
|
||||
- **Principle**: Small Functions + SRP
|
||||
- **Location**: `src/server.ts`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: Startup wiring still owns env parsing, vote-spam lifecycle, route composition, static/SPA serving, and restart recovery orchestration.
|
||||
- **Suggestion**: Extract `createRuntimeConfig(...)` and `resumeInterruptedSessions(...)` to reduce orchestration density in `createServerApp`.
|
||||
- **Location**: `public/renderer/dialogue.js:40-270`
|
||||
- **Severity**: High
|
||||
- **Issue**: Single closure function managing typewriter state, punctuation timing, blip sounds, skip logic, and line advancement. Hard to test or modify individual behaviors.
|
||||
- **Suggestion**: Extract `advanceLine()`, `computePunctuationDelay()`, and `handleSkip()` as standalone functions that the state machine delegates to.
|
||||
|
||||
### 2) `runWitnessExamPhase` still mixes orchestration and recap behavior
|
||||
### [Function Size]: `initCharacters()` — ~270 lines
|
||||
|
||||
- **Principle**: Small Functions + SRP
|
||||
- **Location**: `src/court/phases/session-flow.ts`
|
||||
- **Location**: `public/renderer/characters.js:15-285`
|
||||
- **Severity**: High
|
||||
- **Issue**: Monolithic function creating all character slots, placeholder sprites, tint logic, and pose/face overlay handlers in one closure. Difficult to reason about individual slot behavior.
|
||||
- **Suggestion**: Extract `createCharacterSlot()` to build one slot, then loop in `initCharacters()`. Extract `setPoseSprite` / `setFaceOverlay` as named helpers.
|
||||
|
||||
### [Function Size]: `instrumentCourtSessionStore()` — 87 lines
|
||||
|
||||
- **Principle**: Small Functions + SRP
|
||||
- **Location**: `src/metrics.ts:202-289`
|
||||
- **Severity**: High
|
||||
- **Issue**: Factory returning a wrapper object where every method follows the same try/catch → record-error → rethrow pattern. The repetition obscures the actual instrumentation intent.
|
||||
- **Suggestion**: Extract a generic `wrapWithMetrics(methodName, fn)` helper and use it for each method, reducing the function to a mapping.
|
||||
|
||||
### [Magic Numbers]: Renderer hex colors and proportional coordinates
|
||||
|
||||
- **Principle**: Avoid Hardcoding
|
||||
- **Location**: `public/renderer/layers/background.js`, `characters.js`, `ui.js`, `effects.js`, `evidence.js`
|
||||
- **Severity**: High
|
||||
- **Issue**: 80+ unlabeled hex color values (`0xa08040`, `0x3b2f1e`, `0xff4444`, etc.) and proportional position constants (`0.5`, `0.12`, `0.85`, etc.) scattered throughout all renderer layer files. No palette or layout constants file exists.
|
||||
- **Suggestion**: Create `public/renderer/theme.js` exporting named color constants (e.g., `ROLE_COLOR_JUDGE`, `BG_COURTROOM_DARK`) and layout proportions (e.g., `JUDGE_BENCH_Y`, `WITNESS_SLOT_X`).
|
||||
|
||||
### [Missing Backoff]: `useSSE` reconnect uses fixed 3 s interval
|
||||
|
||||
- **Principle**: Structural Clarity / Runtime Safety
|
||||
- **Location**: `dashboard/src/hooks/useSSE.ts:46-50`
|
||||
- **Severity**: High
|
||||
- **Issue**: On SSE disconnect, the hook retries every 3 000 ms forever with no exponential backoff and no retry cap. Under sustained server downtime this can flood the server with connection attempts.
|
||||
- **Suggestion**: Implement exponential backoff (e.g., 3 s → 6 s → 12 s, capped at 30 s) and a max-retry limit after which `error` state is set permanently.
|
||||
|
||||
### [Potential Resource Leak]: `effects.js` shake timer
|
||||
|
||||
- **Principle**: Runtime Safety
|
||||
- **Location**: `public/renderer/effects.js` — `shake()` function
|
||||
- **Severity**: High
|
||||
- **Issue**: Calling `shake()` while a previous shake animation is still running starts a new `setInterval` without clearing the old one, leaking interval handles.
|
||||
- **Suggestion**: Guard `shake()` entry by checking/clearing any active `shakeTimer` before creating a new interval.
|
||||
|
||||
---
|
||||
|
||||
## Medium Severity
|
||||
|
||||
### [Duplication]: `selectNextPrompt` / `selectNextSafePrompt` — 80 % identical
|
||||
|
||||
- **Principle**: DRY
|
||||
- **Location**: `src/court/prompt-bank.ts:187-240`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: Witness loop flow, pacing, recap generation, recap persistence, and recap TTS are all in one function.
|
||||
- **Suggestion**: Extract `runWitnessCycle(...)` and `emitRecapIfDue(...)` helpers.
|
||||
- **Issue**: Both functions differ only in a `.filter(p => p.active)` predicate. The rotation, hashing, and fallback logic is duplicated.
|
||||
- **Suggestion**: Merge into a single function accepting an optional `filter` predicate; `selectNextSafePrompt` becomes `selectNextPrompt(history, { filter: p => p.active })`.
|
||||
|
||||
### 3) Catch-up character limit remains duplicated across backend/public
|
||||
### [Duplication]: Typewriter logic duplicated between `public/app.js` and `dialogue.js`
|
||||
|
||||
- **Principle**: Avoid Hardcoding Drift
|
||||
- **Location**: `src/court/catchup.ts`, `public/app.js`
|
||||
- **Principle**: DRY
|
||||
- **Location**: `public/app.js` (`startDialogueTypewriter`) and `public/renderer/dialogue.js`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: The max-chars cap exists in two runtimes; both now use named constants but remain independently defined.
|
||||
- **Suggestion**: Expose a shared runtime config endpoint or shared constant package consumed by both surfaces.
|
||||
- **Issue**: Both files implement character-by-character text reveal with punctuation pauses and skip support. The `app.js` version is the legacy overlay; `dialogue.js` is the PixiJS version. Maintaining two diverges over time.
|
||||
- **Suggestion**: Extract shared typewriter config (chars-per-second, punctuation delays) into `public/renderer/typewriter-config.js`. Long-term: remove `app.js` typewriter once PixiJS renderer is primary.
|
||||
|
||||
### 4) Deprecated API still present in prompt bank
|
||||
### [Magic Numbers]: `speaker-selection.ts` weighting constants
|
||||
|
||||
- **Principle**: YAGNI / Surface Area Control
|
||||
- **Location**: `src/court/prompt-bank.ts`
|
||||
- **Principle**: Avoid Hardcoding
|
||||
- **Location**: `src/court/speaker-selection.ts:26,41`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: `Math.random() * 0.4 - 0.2` (jitter range) and `0.5` (recency penalty multiplier) are unlabeled.
|
||||
- **Suggestion**: Extract as `const JITTER_RANGE = 0.4` / `const RECENCY_PENALTY = 0.5` with brief comment on tuning rationale.
|
||||
|
||||
### [Magic Numbers]: Token budget defaults
|
||||
|
||||
- **Principle**: Avoid Hardcoding
|
||||
- **Location**: `src/court/token-budget.ts:22-29`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: Role-specific token limits `260, 220, 160, 120` and `costPer1kTokensUsd: 0.002` are inline in the defaults object with no explanation of how values were chosen.
|
||||
- **Suggestion**: Add a brief comment block above `DEFAULT_ROLE_TOKEN_BUDGETS` explaining the sizing rationale.
|
||||
|
||||
### [Magic Numbers]: `catchup.ts` — `DEFAULT_CASE_SO_FAR_MAX_CHARS = 220`, `turns.slice(-3)`
|
||||
|
||||
- **Principle**: Avoid Hardcoding
|
||||
- **Location**: `src/court/catchup.ts:8,35`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: `220` character limit and `-3` recent turns count are unlabeled. The `220` should document why this threshold.
|
||||
- **Suggestion**: Rename `DEFAULT_CASE_SO_FAR_MAX_CHARS` → keep, add comment. Add `const RECENT_TURNS_COUNT = 3` for the slice.
|
||||
|
||||
### [Magic Numbers]: `dialogue.js` punctuation pause map
|
||||
|
||||
- **Principle**: Avoid Hardcoding
|
||||
- **Location**: `public/renderer/dialogue.js:~10-20`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: `180, 200, 200, 90, 110, 100, 260, 140` ms pause values for punctuation characters are inline with no comment.
|
||||
- **Suggestion**: Group into a named `PUNCTUATION_PAUSES` constant object with a brief comment.
|
||||
|
||||
### [Naming]: `bestOf()` — non-intent-revealing
|
||||
|
||||
- **Principle**: Meaningful Names
|
||||
- **Location**: `src/court/phases/session-flow.ts:79`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: `bestOf` doesn't convey "most voted option". Reads like a comparison utility.
|
||||
- **Suggestion**: Rename to `mostVotedChoice()` or `winningVoteOption()`.
|
||||
|
||||
### [Naming]: `input` parameter used generically in multiple functions
|
||||
|
||||
- **Principle**: Meaningful Names
|
||||
- **Location**: `src/court/personas.ts:42`, `src/court/prompt-bank.ts:170,194,218`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: Multiple functions use `input` as the sole parameter name for different shaped objects. In files with multiple such functions it reduces scanability.
|
||||
- **Suggestion**: Use `promptConfig`, `rotationInput`, `selectionInput` respectively.
|
||||
|
||||
### [Naming]: `stitched` in `catchup.ts`
|
||||
|
||||
- **Principle**: Meaningful Names
|
||||
- **Location**: `src/court/catchup.ts:36`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: Variable name doesn't reveal intent.
|
||||
- **Suggestion**: Rename to `recentTurnsSummary` or `turnExcerpt`.
|
||||
|
||||
### [Function Size]: `initCamera()` — ~120-line closure
|
||||
|
||||
- **Principle**: Small Functions + SRP
|
||||
- **Location**: `public/renderer/camera.js`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: Manages transition state, easing math, and animation frame loop in one closure.
|
||||
- **Suggestion**: Extract `easeLerp()` and `animateTransition()` as standalone functions.
|
||||
|
||||
### [Function Size]: ModerationQueue `useEffect` — ~40 lines with nested loops
|
||||
|
||||
- **Principle**: Small Functions + SRP
|
||||
- **Location**: `dashboard/src/components/ModerationQueue.tsx`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: Event processing, deduplication, and queue mutation combined in a single effect callback.
|
||||
- **Suggestion**: Extract `buildFlaggedItemsFromEvents(newEvents, existingIds): FlaggedItem[]` as a pure function.
|
||||
|
||||
### [Structural Clarity]: SessionMonitor inconsistent sessionId truncation
|
||||
|
||||
- **Principle**: Consistency
|
||||
- **Location**: `dashboard/src/components/SessionMonitor.tsx` (`.slice(0, 16)`) vs `dashboard/src/App.tsx` (`.slice(0, 8)`)
|
||||
- **Severity**: Medium
|
||||
- **Issue**: Session ID display length differs between components without explanation.
|
||||
- **Suggestion**: Extract `const SESSION_ID_DISPLAY_LENGTH` and use consistently.
|
||||
|
||||
### [Duplication]: `parsePositiveInt` / `parsePositiveFloat` in `token-budget.ts`
|
||||
|
||||
- **Principle**: DRY
|
||||
- **Location**: `src/court/token-budget.ts:39-55`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: 90 % identical functions differing only in `parseInt` vs `parseFloat`. Both also duplicate `parsePositiveInt` in `server.ts`.
|
||||
- **Suggestion**: Merge into a shared utility or deduplicate via a generic `parsePositiveNumber`.
|
||||
|
||||
---
|
||||
|
||||
## Low Severity
|
||||
|
||||
### [Naming]: Renderer abbreviations (`camX`, `camY`, `camZoom`)
|
||||
|
||||
- **Principle**: Meaningful Names
|
||||
- **Location**: `public/renderer/camera.js`
|
||||
- **Severity**: Low
|
||||
- **Issue**: `selectNextPrompt` is unused in current workspace and retained only for compatibility.
|
||||
- **Suggestion**: Remove in a planned cleanup version after confirming no external consumers.
|
||||
- **Issue**: Abbreviated coordinates. Acceptable in animation code but less self-documenting.
|
||||
- **Suggestion**: Keep — standard convention in renderer/game code.
|
||||
|
||||
### 5) Transaction helper still requires one compatibility cast
|
||||
### [YAGNI]: `OBSWebSocketAdapter` is entirely TODO stubs
|
||||
|
||||
- **Principle**: Type Safety Consistency
|
||||
- **Location**: `src/store/session-store.ts`
|
||||
- **Principle**: YAGNI
|
||||
- **Location**: `src/broadcast/obs-adapter.ts`
|
||||
- **Severity**: Low
|
||||
- **Issue**: Postgres transaction callback typing still requires one internal cast in `withTxQuery`.
|
||||
- **Suggestion**: Keep centralized as-is unless upstream typings or wrapper abstraction allow a fully call-signature-safe approach.
|
||||
- **Issue**: Every method logs a stub message and does nothing. Kept as a placeholder.
|
||||
- **Suggestion**: Acceptable if planned for near-term implementation. Consider marking with a `// TODO(phase-N)` header.
|
||||
|
||||
### [Naming]: `MockTTSAdapter` exported from production code
|
||||
|
||||
- **Principle**: Consistency
|
||||
- **Location**: `src/tts/adapter.ts`
|
||||
- **Severity**: Low
|
||||
- **Issue**: Test-only adapter lives alongside production code without clear separation.
|
||||
- **Suggestion**: Either prefix with `_` or move to `src/tts/__mocks__/adapter.ts`.
|
||||
|
||||
### [Structural Clarity]: `logger.ts` uses `Object.create()` for child loggers
|
||||
|
||||
- **Principle**: Readability First
|
||||
- **Location**: `src/logger.ts:73-88`
|
||||
- **Severity**: Low
|
||||
- **Issue**: Prototype-based composition is unusual in TypeScript; class composition would be clearer.
|
||||
- **Suggestion**: Acceptable if intentional. Add a brief comment explaining the design choice.
|
||||
|
||||
### [Convention]: `console.log` / `console.error` used in dashboard instead of structured logging
|
||||
|
||||
- **Principle**: Project Conventions
|
||||
- **Location**: `dashboard/src/hooks/useSSE.ts:32,35`, dashboard components
|
||||
- **Severity**: Low
|
||||
- **Issue**: Dashboard uses raw `console.*` while backend uses structured logger. Acceptable for browser code.
|
||||
- **Suggestion**: No action needed unless a browser-side logging wrapper is desired.
|
||||
|
||||
### [Convention]: `eslint-disable-next-line no-console` appears 10+ times in backend
|
||||
|
||||
- **Principle**: Project Conventions
|
||||
- **Location**: `src/server.ts`, `src/court/orchestrator.ts`, `src/broadcast/adapter.ts`
|
||||
- **Severity**: Low
|
||||
- **Issue**: The structured `logger` exists but many places still use `console.*` with inline lint suppressions.
|
||||
- **Suggestion**: Migrate remaining `console.warn` / `console.error` calls to the structured logger.
|
||||
|
||||
---
|
||||
|
||||
## What's Working Well
|
||||
|
||||
- **Types**: Centralized in `src/types.ts` with narrow union types for phases, roles, events. Dashboard mirrors via its own `types.ts`.
|
||||
- **Event taxonomy**: Every court event has a typed payload interface in `src/events.ts`.
|
||||
- **Error handling at API boundaries**: `sendError()`, `mapSessionMutationError()`, and custom error classes (`CourtValidationError`, `CourtNotFoundError`) provide clear error responses.
|
||||
- **Moderation pipeline**: Input validation → content filter → spam guard → structured event emission.
|
||||
- **Phase orchestration**: Clean separation into `phases/session-flow.ts` with named constants for durations and pauses.
|
||||
- **Metrics instrumentation**: Prometheus counters/histograms properly wrap store operations.
|
||||
- **Test coverage**: Behavior-focused tests for all major modules.
|
||||
- **Configuration**: Environment-driven with sensible defaults and `parsePositiveInt` safety.
|
||||
- **Coding conventions**: Documented and largely followed across the project.
|
||||
|
||||
@@ -3,22 +3,10 @@ import { SessionMonitor } from './components/SessionMonitor';
|
||||
import { useSSE } from './hooks/useSSE';
|
||||
import { applyEventToSnapshot, mapSessionToSnapshot } from './session-snapshot';
|
||||
import type { CourtEvent, SessionSnapshot } from './types';
|
||||
import { asRecord, asString } from './utils/payload-guards';
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
const SESSION_DISCOVERY_INTERVAL_MS = 5_000;
|
||||
|
||||
function asRecord(value: unknown): UnknownRecord {
|
||||
return (
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
) ?
|
||||
(value as UnknownRecord)
|
||||
: {};
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function resolvePreferredSessionId(response: unknown): string | null {
|
||||
const payload = asRecord(response);
|
||||
const sessions =
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import type { CourtEvent, SSEMessage } from '../types';
|
||||
|
||||
const RECONNECT_DELAY_MS = 3000;
|
||||
const RECONNECT_BASE_MS = 3_000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
const MAX_RETRIES = 10;
|
||||
|
||||
export function useSSE(
|
||||
sessionId: string | null,
|
||||
@@ -12,6 +14,7 @@ export function useSSE(
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const retriesRef = useRef(0);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (eventSourceRef.current || !sessionId) {
|
||||
@@ -27,15 +30,14 @@ export function useSSE(
|
||||
es.onopen = () => {
|
||||
setConnected(true);
|
||||
setError(null);
|
||||
retriesRef.current = 0;
|
||||
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;
|
||||
|
||||
@@ -43,9 +45,21 @@ export function useSSE(
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (retriesRef.current >= MAX_RETRIES) {
|
||||
setError('Connection lost. Max retries reached.');
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_MS * 2 ** retriesRef.current,
|
||||
RECONNECT_MAX_MS,
|
||||
);
|
||||
retriesRef.current += 1;
|
||||
setError(`Connection lost. Reconnecting in ${Math.round(delay / 1000)}s...`);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect();
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
es.onmessage = e => {
|
||||
|
||||
@@ -4,50 +4,25 @@ import type {
|
||||
TranscriptEntry,
|
||||
VoteCount,
|
||||
} from './types';
|
||||
import {
|
||||
asNumber,
|
||||
asPositiveNumber,
|
||||
asRecord,
|
||||
asString,
|
||||
asStringArray,
|
||||
isRecord,
|
||||
} from './utils/payload-guards';
|
||||
import type { UnknownRecord } from './utils/payload-guards';
|
||||
|
||||
const DEFAULT_MAX_WITNESS_STATEMENTS = 3;
|
||||
const DEFAULT_RECAP_INTERVAL = 2;
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
interface SessionSnapshotInput {
|
||||
session: unknown;
|
||||
turns?: unknown;
|
||||
recapTurnIds?: unknown;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): UnknownRecord {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ?
|
||||
value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function asPositiveNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ?
|
||||
value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter((item): item is string => typeof item === 'string');
|
||||
}
|
||||
|
||||
function buildTranscript(
|
||||
turnsInput: unknown,
|
||||
recapTurnIds: Set<string>,
|
||||
|
||||
33
dashboard/src/utils/payload-guards.ts
Normal file
33
dashboard/src/utils/payload-guards.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
export function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): UnknownRecord {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ?
|
||||
value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function asPositiveNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ?
|
||||
value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.filter((item): item is string => typeof item === 'string');
|
||||
}
|
||||
@@ -5,7 +5,7 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_this_now}
|
||||
POSTGRES_DB: juryrigged
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
environment:
|
||||
PORT: 3001
|
||||
TRUST_PROXY: ${TRUST_PROXY:-1}
|
||||
DATABASE_URL: postgresql://postgres:postgres@db:5432/juryrigged
|
||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-change_this_now}@db:5432/juryrigged
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-deepseek/deepseek-chat-v3-0324:free}
|
||||
LLM_MOCK: ${LLM_MOCK:-false}
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
*/
|
||||
|
||||
import { STAGE_WIDTH, STAGE_HEIGHT } from '../stage.js';
|
||||
|
||||
const BENCH_COLOR = 0x3b2f1e;
|
||||
const PODIUM_COLOR = 0x2d2417;
|
||||
const FLOOR_COLOR = 0x1a1428;
|
||||
const WALL_COLOR = 0x0e1422;
|
||||
const LINE_COLOR = 0x4a3f2e;
|
||||
import {
|
||||
BENCH_COLOR,
|
||||
PODIUM_COLOR,
|
||||
FLOOR_COLOR,
|
||||
WALL_COLOR,
|
||||
LINE_COLOR,
|
||||
TEXT_PLACEHOLDER_DIM,
|
||||
} from '../theme.js';
|
||||
|
||||
/**
|
||||
* @param {import('../stage.js').RendererStage} stage
|
||||
@@ -26,7 +28,7 @@ export function initBackground(stage) {
|
||||
const label = new PIXI.Text({
|
||||
text: 'COURTROOM BACKGROUND (placeholder)',
|
||||
style: {
|
||||
fill: 0x555566,
|
||||
fill: TEXT_PLACEHOLDER_DIM,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
|
||||
@@ -7,15 +7,30 @@
|
||||
* rectangle is drawn; when a sprite texture is set, it replaces the rectangle.
|
||||
*/
|
||||
|
||||
import {
|
||||
ROLE_COLOR_JUDGE,
|
||||
ROLE_COLOR_PROSECUTOR,
|
||||
ROLE_COLOR_DEFENSE,
|
||||
ROLE_COLOR_WITNESS,
|
||||
ROLE_COLOR_BAILIFF,
|
||||
ACTIVE_TINT,
|
||||
DEFAULT_TINT,
|
||||
ACTIVE_ALPHA,
|
||||
INACTIVE_ALPHA,
|
||||
TEXT_LABEL_DIM,
|
||||
TEXT_NAME_LABEL,
|
||||
PLACEHOLDER_OUTLINE,
|
||||
} from '../theme.js';
|
||||
|
||||
/** Default layout positions (proportional to canvas size). */
|
||||
const ROLE_POSITIONS = {
|
||||
judge: { x: 0.5, y: 0.12, w: 0.1, h: 0.16, color: 0xa08040 },
|
||||
prosecutor: { x: 0.85, y: 0.38, w: 0.1, h: 0.2, color: 0x7b4040 },
|
||||
defense: { x: 0.13, y: 0.38, w: 0.1, h: 0.2, color: 0x405a7b },
|
||||
witness_1: { x: 0.64, y: 0.28, w: 0.08, h: 0.14, color: 0x4f6f50 },
|
||||
witness_2: { x: 0.56, y: 0.28, w: 0.08, h: 0.14, color: 0x4f6f50 },
|
||||
witness_3: { x: 0.72, y: 0.28, w: 0.08, h: 0.14, color: 0x4f6f50 },
|
||||
bailiff: { x: 0.36, y: 0.3, w: 0.07, h: 0.14, color: 0x555566 },
|
||||
judge: { x: 0.5, y: 0.12, w: 0.1, h: 0.16, color: ROLE_COLOR_JUDGE },
|
||||
prosecutor: { x: 0.85, y: 0.38, w: 0.1, h: 0.2, color: ROLE_COLOR_PROSECUTOR },
|
||||
defense: { x: 0.13, y: 0.38, w: 0.1, h: 0.2, color: ROLE_COLOR_DEFENSE },
|
||||
witness_1: { x: 0.64, y: 0.28, w: 0.08, h: 0.14, color: ROLE_COLOR_WITNESS },
|
||||
witness_2: { x: 0.56, y: 0.28, w: 0.08, h: 0.14, color: ROLE_COLOR_WITNESS },
|
||||
witness_3: { x: 0.72, y: 0.28, w: 0.08, h: 0.14, color: ROLE_COLOR_WITNESS },
|
||||
bailiff: { x: 0.36, y: 0.3, w: 0.07, h: 0.14, color: ROLE_COLOR_BAILIFF },
|
||||
};
|
||||
|
||||
/** Recognised pose keys — pose sprites are resolved via asset lookup. */
|
||||
@@ -24,10 +39,6 @@ export const POSES = ['idle', 'talk', 'point', 'slam', 'think', 'shock'];
|
||||
/** Recognised face overlay keys. */
|
||||
export const FACE_OVERLAYS = ['neutral', 'angry', 'happy', 'surprised', 'sweating'];
|
||||
|
||||
const ACTIVE_TINT = 0xffdd44;
|
||||
const INACTIVE_ALPHA = 0.55;
|
||||
const ACTIVE_ALPHA = 1.0;
|
||||
|
||||
/**
|
||||
* @param {import('../stage.js').RendererStage} stage
|
||||
*/
|
||||
@@ -77,7 +88,7 @@ export function initCharacters(stage) {
|
||||
const label = new PIXI.Text({
|
||||
text: role.replace(/_/g, ' ').toUpperCase(),
|
||||
style: {
|
||||
fill: 0xcccccc,
|
||||
fill: TEXT_LABEL_DIM,
|
||||
fontSize: 9,
|
||||
fontFamily: 'monospace',
|
||||
align: 'center',
|
||||
@@ -89,7 +100,7 @@ export function initCharacters(stage) {
|
||||
const nameLabel = new PIXI.Text({
|
||||
text: '',
|
||||
style: {
|
||||
fill: 0xeeeeee,
|
||||
fill: TEXT_NAME_LABEL,
|
||||
fontSize: 10,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: '600',
|
||||
@@ -133,7 +144,7 @@ export function initCharacters(stage) {
|
||||
gfx.beginFill(slot.color, 0.6);
|
||||
gfx.drawRoundedRect(0, 0, sw, sh, 4);
|
||||
gfx.endFill();
|
||||
gfx.lineStyle(1, 0x888888, 0.3);
|
||||
gfx.lineStyle(1, PLACEHOLDER_OUTLINE, 0.3);
|
||||
gfx.drawRoundedRect(0, 0, sw, sh, 4);
|
||||
gfx.lineStyle(0);
|
||||
}
|
||||
@@ -213,7 +224,7 @@ export function initCharacters(stage) {
|
||||
* @param {number} color Hex tint (e.g. 0xff0000 for damage flash)
|
||||
* @param {number} durationMs Flash duration in milliseconds
|
||||
*/
|
||||
function flashCharacter(role, color = 0xffffff, durationMs = 120) {
|
||||
function flashCharacter(role, color = DEFAULT_TINT, durationMs = 120) {
|
||||
const entry = slots[role];
|
||||
if (!entry) return;
|
||||
|
||||
@@ -250,7 +261,7 @@ export function initCharacters(stage) {
|
||||
if (isActive) {
|
||||
entry.gfx.tint = ACTIVE_TINT;
|
||||
} else {
|
||||
entry.gfx.tint = 0xffffff;
|
||||
entry.gfx.tint = DEFAULT_TINT;
|
||||
}
|
||||
|
||||
const name = roleNames?.[role];
|
||||
|
||||
28
public/renderer/layers/effects.js
vendored
28
public/renderer/layers/effects.js
vendored
@@ -6,6 +6,14 @@
|
||||
* runs to completion inside the effects container then self-destructs.
|
||||
*/
|
||||
|
||||
import {
|
||||
FLASH_DEFAULT,
|
||||
OBJECTION_COLOR,
|
||||
HOLD_IT_COLOR,
|
||||
TAKE_THAT_COLOR,
|
||||
STAMP_STROKE_COLOR,
|
||||
} from '../theme.js';
|
||||
|
||||
const SHAKE_INTENSITY_PX = 6;
|
||||
const SHAKE_DURATION_MS = 300;
|
||||
const FLASH_DURATION_MS = 150;
|
||||
@@ -29,7 +37,7 @@ export function initEffects(stage) {
|
||||
* @param {number} [opts.durationMs] Duration in ms
|
||||
*/
|
||||
function flash(opts = {}) {
|
||||
const color = opts.color ?? 0xffffff;
|
||||
const color = opts.color ?? FLASH_DEFAULT;
|
||||
const alpha = opts.alpha ?? 0.6;
|
||||
const duration = opts.durationMs ?? FLASH_DURATION_MS;
|
||||
|
||||
@@ -104,7 +112,7 @@ export function initEffects(stage) {
|
||||
*/
|
||||
function stamp(opts = {}) {
|
||||
const text = opts.text ?? 'OBJECTION!';
|
||||
const color = opts.color ?? 0xff4444;
|
||||
const color = opts.color ?? OBJECTION_COLOR;
|
||||
const fontSize = opts.fontSize ?? 48;
|
||||
const displayMs = opts.displayMs ?? STAMP_DISPLAY_MS;
|
||||
|
||||
@@ -115,11 +123,11 @@ export function initEffects(stage) {
|
||||
fontSize,
|
||||
fontFamily: 'Impact, Arial Black, sans-serif',
|
||||
fontWeight: '900',
|
||||
stroke: 0x000000,
|
||||
stroke: STAMP_STROKE_COLOR,
|
||||
strokeThickness: 4,
|
||||
align: 'center',
|
||||
dropShadow: true,
|
||||
dropShadowColor: 0x000000,
|
||||
dropShadowColor: STAMP_STROKE_COLOR,
|
||||
dropShadowDistance: 3,
|
||||
},
|
||||
});
|
||||
@@ -187,25 +195,25 @@ export function initEffects(stage) {
|
||||
* Convenience: composite "objection" cue (stamp + flash + shake).
|
||||
*/
|
||||
function objection() {
|
||||
flash({ color: 0xff4444, alpha: 0.35 });
|
||||
flash({ color: OBJECTION_COLOR, alpha: 0.35 });
|
||||
shake({ intensity: 8, durationMs: 350 });
|
||||
stamp({ text: 'OBJECTION!', color: 0xff4444 });
|
||||
stamp({ text: 'OBJECTION!', color: OBJECTION_COLOR });
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: "hold it" cue.
|
||||
*/
|
||||
function holdIt() {
|
||||
flash({ color: 0x44aaff, alpha: 0.3 });
|
||||
stamp({ text: 'HOLD IT!', color: 0x44aaff });
|
||||
flash({ color: HOLD_IT_COLOR, alpha: 0.3 });
|
||||
stamp({ text: 'HOLD IT!', color: HOLD_IT_COLOR });
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: "take that" cue.
|
||||
*/
|
||||
function takeThat() {
|
||||
flash({ color: 0x44ff88, alpha: 0.3 });
|
||||
stamp({ text: 'TAKE THAT!', color: 0x44ff88 });
|
||||
flash({ color: TAKE_THAT_COLOR, alpha: 0.3 });
|
||||
stamp({ text: 'TAKE THAT!', color: TAKE_THAT_COLOR });
|
||||
}
|
||||
|
||||
return { trigger, flash, shake, freeze, stamp, objection, holdIt, takeThat };
|
||||
|
||||
@@ -6,13 +6,18 @@
|
||||
* scales up, flashes, then settles back to the evidence tray.
|
||||
*/
|
||||
|
||||
const EVIDENCE_TRAY_ALPHA = 0.85;
|
||||
import {
|
||||
EVIDENCE_CARD_BG,
|
||||
EVIDENCE_CARD_BORDER,
|
||||
EVIDENCE_CARD_ACTIVE_BORDER,
|
||||
EVIDENCE_ID_TEXT,
|
||||
EVIDENCE_CONTENT_TEXT,
|
||||
EVIDENCE_TRAY_ALPHA,
|
||||
} from '../theme.js';
|
||||
|
||||
const EVIDENCE_CARD_WIDTH = 140;
|
||||
const EVIDENCE_CARD_HEIGHT = 80;
|
||||
const EVIDENCE_CARD_GAP = 8;
|
||||
const EVIDENCE_CARD_BG = 0x1e293b;
|
||||
const EVIDENCE_CARD_BORDER = 0x475569;
|
||||
const EVIDENCE_CARD_ACTIVE_BORDER = 0xfbbf24;
|
||||
const PRESENT_ANIMATION_MS = 900;
|
||||
|
||||
/**
|
||||
@@ -65,7 +70,7 @@ export function initEvidence(stage) {
|
||||
const idLabel = new PIXI.Text({
|
||||
text: `#${evidence.id}`,
|
||||
style: {
|
||||
fill: 0x94a3b8,
|
||||
fill: EVIDENCE_ID_TEXT,
|
||||
fontSize: 9,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
@@ -76,7 +81,7 @@ export function initEvidence(stage) {
|
||||
const textLabel = new PIXI.Text({
|
||||
text: evidence.text.length > 60 ? evidence.text.slice(0, 57) + '…' : evidence.text,
|
||||
style: {
|
||||
fill: 0xe2e8f0,
|
||||
fill: EVIDENCE_CONTENT_TEXT,
|
||||
fontSize: 10,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
wordWrap: true,
|
||||
|
||||
@@ -6,11 +6,18 @@
|
||||
* they compose with the rest of the scene graph (camera, effects, etc.).
|
||||
*/
|
||||
|
||||
const DIALOGUE_BOX_ALPHA = 0.82;
|
||||
const DIALOGUE_BOX_COLOR = 0x0e1422;
|
||||
const DIALOGUE_BOX_RADIUS = 8;
|
||||
const NAMEPLATE_BG = 0x1a2540;
|
||||
const NAMEPLATE_RADIUS = 6;
|
||||
import {
|
||||
DIALOGUE_BOX_ALPHA,
|
||||
DIALOGUE_BOX_COLOR,
|
||||
DIALOGUE_BOX_RADIUS,
|
||||
NAMEPLATE_BG,
|
||||
NAMEPLATE_RADIUS,
|
||||
TEXT_SPEAKER,
|
||||
TEXT_DIALOGUE,
|
||||
TEXT_NAMEPLATE,
|
||||
TEXT_PHASE_BADGE,
|
||||
PHASE_BADGE_BG,
|
||||
} from '../theme.js';
|
||||
|
||||
/**
|
||||
* @param {import('../stage.js').RendererStage} stage
|
||||
@@ -29,7 +36,7 @@ export function initUI(stage) {
|
||||
const speakerText = new PIXI.Text({
|
||||
text: '',
|
||||
style: {
|
||||
fill: 0xa5b4fc,
|
||||
fill: TEXT_SPEAKER,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: '600',
|
||||
@@ -41,7 +48,7 @@ export function initUI(stage) {
|
||||
const dialogueText = new PIXI.Text({
|
||||
text: '',
|
||||
style: {
|
||||
fill: 0xf8fafc,
|
||||
fill: TEXT_DIALOGUE,
|
||||
fontSize: 15,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
wordWrap: true,
|
||||
@@ -63,7 +70,7 @@ export function initUI(stage) {
|
||||
const nameplateText = new PIXI.Text({
|
||||
text: '',
|
||||
style: {
|
||||
fill: 0xd9e6ff,
|
||||
fill: TEXT_NAMEPLATE,
|
||||
fontSize: 12,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontWeight: '600',
|
||||
@@ -83,7 +90,7 @@ export function initUI(stage) {
|
||||
const phaseText = new PIXI.Text({
|
||||
text: 'phase: idle',
|
||||
style: {
|
||||
fill: 0x9da2b6,
|
||||
fill: TEXT_PHASE_BADGE,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
@@ -121,7 +128,7 @@ export function initUI(stage) {
|
||||
const pbH = 22;
|
||||
phaseContainer.position.set(w - pbW - padding, padding);
|
||||
phaseBg.clear();
|
||||
phaseBg.beginFill(0x14141d, 0.85);
|
||||
phaseBg.beginFill(PHASE_BADGE_BG, 0.85);
|
||||
phaseBg.drawRoundedRect(0, 0, pbW, pbH, 4);
|
||||
phaseBg.endFill();
|
||||
}
|
||||
|
||||
57
public/renderer/theme.js
Normal file
57
public/renderer/theme.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Centralized visual theme for the Ace Attorney-style renderer.
|
||||
* Colors, UI text fills, and effect palettes shared across layers.
|
||||
*/
|
||||
|
||||
// ── Courtroom furniture & environment ──
|
||||
export const BENCH_COLOR = 0x3b2f1e;
|
||||
export const PODIUM_COLOR = 0x2d2417;
|
||||
export const FLOOR_COLOR = 0x1a1428;
|
||||
export const WALL_COLOR = 0x0e1422;
|
||||
export const LINE_COLOR = 0x4a3f2e;
|
||||
|
||||
// ── Character role placeholder colors ──
|
||||
export const ROLE_COLOR_JUDGE = 0xa08040;
|
||||
export const ROLE_COLOR_PROSECUTOR = 0x7b4040;
|
||||
export const ROLE_COLOR_DEFENSE = 0x405a7b;
|
||||
export const ROLE_COLOR_WITNESS = 0x4f6f50;
|
||||
export const ROLE_COLOR_BAILIFF = 0x555566;
|
||||
|
||||
// ── Character highlighting ──
|
||||
export const ACTIVE_TINT = 0xffdd44;
|
||||
export const DEFAULT_TINT = 0xffffff;
|
||||
export const ACTIVE_ALPHA = 1.0;
|
||||
export const INACTIVE_ALPHA = 0.55;
|
||||
|
||||
// ── UI / dialogue box ──
|
||||
export const DIALOGUE_BOX_COLOR = 0x0e1422;
|
||||
export const DIALOGUE_BOX_ALPHA = 0.82;
|
||||
export const DIALOGUE_BOX_RADIUS = 8;
|
||||
export const NAMEPLATE_BG = 0x1a2540;
|
||||
export const NAMEPLATE_RADIUS = 6;
|
||||
|
||||
// ── UI text fills ──
|
||||
export const TEXT_SPEAKER = 0xa5b4fc;
|
||||
export const TEXT_DIALOGUE = 0xf8fafc;
|
||||
export const TEXT_NAMEPLATE = 0xd9e6ff;
|
||||
export const TEXT_PHASE_BADGE = 0x9da2b6;
|
||||
export const TEXT_LABEL_DIM = 0xcccccc;
|
||||
export const TEXT_NAME_LABEL = 0xeeeeee;
|
||||
export const TEXT_PLACEHOLDER_DIM = 0x555566;
|
||||
export const PHASE_BADGE_BG = 0x14141d;
|
||||
export const PLACEHOLDER_OUTLINE = 0x888888;
|
||||
|
||||
// ── Effect / interjection colors ──
|
||||
export const FLASH_DEFAULT = 0xffffff;
|
||||
export const OBJECTION_COLOR = 0xff4444;
|
||||
export const HOLD_IT_COLOR = 0x44aaff;
|
||||
export const TAKE_THAT_COLOR = 0x44ff88;
|
||||
export const STAMP_STROKE_COLOR = 0x000000;
|
||||
|
||||
// ── Evidence card colors ──
|
||||
export const EVIDENCE_CARD_BG = 0x1e293b;
|
||||
export const EVIDENCE_CARD_BORDER = 0x475569;
|
||||
export const EVIDENCE_CARD_ACTIVE_BORDER = 0xfbbf24;
|
||||
export const EVIDENCE_ID_TEXT = 0x94a3b8;
|
||||
export const EVIDENCE_CONTENT_TEXT = 0xe2e8f0;
|
||||
export const EVIDENCE_TRAY_ALPHA = 0.85;
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { CourtPhase } from '../types.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter Interface
|
||||
@@ -105,14 +106,14 @@ export async function createBroadcastAdapterFromEnv(): Promise<BroadcastAdapter>
|
||||
await import('./obs-adapter.js');
|
||||
return new OBSWebSocketAdapter();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[broadcast] Failed to load OBS adapter: ${err}. Using noop.`,
|
||||
);
|
||||
return new NoopBroadcastAdapter();
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[broadcast] Unknown provider: ${provider}. Using noop.`,
|
||||
);
|
||||
return new NoopBroadcastAdapter();
|
||||
@@ -163,7 +164,7 @@ export async function safeBroadcastHook(
|
||||
const latencyMs = Date.now() - startTime;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
console.error(
|
||||
logger.error(
|
||||
`[broadcast] Hook failed: type=${hookType} latencyMs=${latencyMs} error=${errorMsg}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* OBS WebSocket adapter for broadcast automation.
|
||||
*
|
||||
* TODO(phase-8): Implement actual WebSocket connection using obs-websocket-js.
|
||||
* All methods currently log intent but do not send real OBS commands.
|
||||
*
|
||||
* Connects to OBS Studio via WebSocket 5.x protocol.
|
||||
* Requires obs-websocket-js package to be installed.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { CourtPhase, CourtTurn } from '../types.js';
|
||||
|
||||
// Max characters for the catch-up summary — fits comfortably in a
|
||||
// subtitle banner at standard stream resolution (960×540).
|
||||
export const DEFAULT_CASE_SO_FAR_MAX_CHARS = 220;
|
||||
|
||||
// Number of recent turns to stitch when no recap is available
|
||||
const RECENT_TURNS_COUNT = 3;
|
||||
|
||||
export interface CatchupView {
|
||||
caseSoFar: string;
|
||||
phaseLabel: string;
|
||||
@@ -32,15 +37,15 @@ export function buildCaseSoFarSummary(
|
||||
return normalize(latestRecap.dialogue, maxChars);
|
||||
}
|
||||
|
||||
const recentTurns = turns.slice(-3);
|
||||
const recentTurns = turns.slice(-RECENT_TURNS_COUNT);
|
||||
if (recentTurns.length === 0) {
|
||||
return 'The court has just opened. Waiting for opening statements.';
|
||||
}
|
||||
|
||||
const stitched = recentTurns
|
||||
const recentTurnsSummary = recentTurns
|
||||
.map(turn => `${turn.speaker}: ${turn.dialogue}`)
|
||||
.join(' · ');
|
||||
return normalize(stitched, maxChars);
|
||||
return normalize(recentTurnsSummary, maxChars);
|
||||
}
|
||||
|
||||
export function juryStepFromPhase(phase: CourtPhase): string {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AGENTS } from '../agents.js';
|
||||
import { llmGenerate, sanitizeDialogue } from '../llm/client.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { moderateContent } from '../moderation/content-filter.js';
|
||||
import { createTTSAdapterFromEnv, type TTSAdapter } from '../tts/adapter.js';
|
||||
import {
|
||||
@@ -175,8 +176,7 @@ async function handleFlaggedModeration(input: {
|
||||
moderationReasons: string[];
|
||||
activeBroadcast?: BroadcastAdapter;
|
||||
}): Promise<void> {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[moderation] content flagged session=${input.session.id} speaker=${input.speaker} reasons=${input.moderationReasons.join(',')}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ function rolePrompt(role: CourtRole, genre?: GenreTag): string {
|
||||
return COURT_ROLE_PROMPTS[role] ?? COURT_ROLE_PROMPTS.witness;
|
||||
}
|
||||
|
||||
export function buildCourtSystemPrompt(input: {
|
||||
export function buildCourtSystemPrompt(promptConfig: {
|
||||
agentId: AgentId;
|
||||
role: CourtRole;
|
||||
topic: string;
|
||||
@@ -72,7 +72,7 @@ export function buildCourtSystemPrompt(input: {
|
||||
history: string;
|
||||
genre?: GenreTag; // Phase 3: genre-specific prompt variations
|
||||
}): string {
|
||||
const { agentId, role, topic, caseType, phase, history, genre } = input;
|
||||
const { agentId, role, topic, caseType, phase, history, genre } = promptConfig;
|
||||
const agent = AGENTS[agentId];
|
||||
|
||||
const verdictLabels =
|
||||
|
||||
@@ -76,7 +76,7 @@ export interface SessionRuntimeContext {
|
||||
recapCadence: number;
|
||||
}
|
||||
|
||||
function bestOf(votes: Record<string, number>, fallback: string): string {
|
||||
function mostVotedChoice(votes: Record<string, number>, fallback: string): string {
|
||||
const entries = Object.entries(votes);
|
||||
if (entries.length === 0) return fallback;
|
||||
|
||||
@@ -476,11 +476,11 @@ export async function runFinalRulingPhase(
|
||||
);
|
||||
}
|
||||
|
||||
const winningVerdict = bestOf(
|
||||
const winningVerdict = mostVotedChoice(
|
||||
latest.metadata.verdictVotes,
|
||||
verdictChoices[0],
|
||||
);
|
||||
const winningSentence = bestOf(
|
||||
const winningSentence = mostVotedChoice(
|
||||
latest.metadata.sentenceVotes,
|
||||
latest.metadata.sentenceOptions[0],
|
||||
);
|
||||
|
||||
@@ -168,30 +168,30 @@ function getActivePromptCandidates(activeGenres?: GenreTag[]): PromptBankEntry[]
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function selectWithGenreRotation(input: {
|
||||
function selectWithGenreRotation(rotationInput: {
|
||||
candidates: PromptBankEntry[];
|
||||
genreHistory: GenreTag[];
|
||||
minDistance: number;
|
||||
depletedPoolWarning: string;
|
||||
}): PromptBankEntry {
|
||||
const recentGenres = new Set(
|
||||
input.genreHistory.slice(-input.minDistance).filter(Boolean),
|
||||
rotationInput.genreHistory.slice(-rotationInput.minDistance).filter(Boolean),
|
||||
);
|
||||
|
||||
let availablePrompts = input.candidates.filter(
|
||||
let availablePrompts = rotationInput.candidates.filter(
|
||||
prompt => !recentGenres.has(prompt.genre),
|
||||
);
|
||||
|
||||
if (availablePrompts.length === 0) {
|
||||
console.warn(input.depletedPoolWarning);
|
||||
availablePrompts = input.candidates;
|
||||
console.warn(rotationInput.depletedPoolWarning);
|
||||
availablePrompts = rotationInput.candidates;
|
||||
}
|
||||
|
||||
const sortedPrompts = [...availablePrompts].sort((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
const seed = stableHash(
|
||||
`${input.genreHistory.join('|')}|${sortedPrompts.map(prompt => prompt.id).join('|')}`,
|
||||
`${rotationInput.genreHistory.join('|')}|${sortedPrompts.map(prompt => prompt.id).join('|')}`,
|
||||
);
|
||||
|
||||
return sortedPrompts[seed % sortedPrompts.length];
|
||||
@@ -201,12 +201,6 @@ function selectWithGenreRotation(input: {
|
||||
* Selects the next prompt from the bank, avoiding genres that violate
|
||||
* the minimum distance constraint.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Filter out inactive prompts
|
||||
* 2. Identify genres that violate minDistance (appeared in last N sessions)
|
||||
* 3. Select from remaining genres with equal probability
|
||||
* 4. If all genres are exhausted (depleted pool), ignore distance constraint
|
||||
*
|
||||
* @param genreHistory - Array of recently used genres (most recent last)
|
||||
* @param activeGenres - Optional filter to restrict to specific genres
|
||||
* @param minDistance - Minimum sessions before genre can repeat (default: 2)
|
||||
@@ -218,14 +212,7 @@ export function selectNextPrompt(
|
||||
activeGenres?: GenreTag[],
|
||||
minDistance: number = DEFAULT_ROTATION_CONFIG.minDistance,
|
||||
): PromptBankEntry {
|
||||
const candidates = getActivePromptCandidates(activeGenres);
|
||||
|
||||
return selectWithGenreRotation({
|
||||
candidates,
|
||||
genreHistory,
|
||||
minDistance,
|
||||
depletedPoolWarning: `[prompt-bank] All genres recently used (history=${genreHistory.join(',')}). Allowing any genre.`,
|
||||
});
|
||||
return selectNextSafePrompt(genreHistory, activeGenres, minDistance, () => true);
|
||||
}
|
||||
|
||||
export interface PromptSafetyResult {
|
||||
@@ -250,24 +237,29 @@ export function screenPromptForSession(
|
||||
/**
|
||||
* Selects the next safe prompt from the bank, avoiding unsafe prompts.
|
||||
* Uses deterministic rotation and falls back to any safe prompt if needed.
|
||||
*
|
||||
* @param genreHistory - Array of recently used genres (most recent last)
|
||||
* @param activeGenres - Optional filter to restrict to specific genres
|
||||
* @param minDistance - Minimum sessions before genre can repeat
|
||||
* @param filter - Optional predicate applied to candidates (default: safety screen)
|
||||
*/
|
||||
export function selectNextSafePrompt(
|
||||
genreHistory: GenreTag[] = [],
|
||||
activeGenres?: GenreTag[],
|
||||
minDistance: number = DEFAULT_ROTATION_CONFIG.minDistance,
|
||||
filter: (candidate: PromptBankEntry) => boolean = candidate =>
|
||||
screenPromptForSession(candidate).allowed,
|
||||
): PromptBankEntry {
|
||||
const candidates = getActivePromptCandidates(activeGenres);
|
||||
|
||||
const safeCandidates = candidates.filter(
|
||||
candidate => screenPromptForSession(candidate).allowed,
|
||||
);
|
||||
const filteredCandidates = candidates.filter(filter);
|
||||
|
||||
if (safeCandidates.length === 0) {
|
||||
if (filteredCandidates.length === 0) {
|
||||
throw new Error('No safe prompts available in the prompt bank.');
|
||||
}
|
||||
|
||||
return selectWithGenreRotation({
|
||||
candidates: safeCandidates,
|
||||
candidates: filteredCandidates,
|
||||
genreHistory,
|
||||
minDistance,
|
||||
depletedPoolWarning: `[prompt-bank] All safe genres recently used (history=${genreHistory.join(',')}). Allowing any safe genre.`,
|
||||
|
||||
@@ -35,12 +35,17 @@ export function selectNextSpeaker(context: {
|
||||
speakCounts[turn.speaker] = (speakCounts[turn.speaker] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Tuning: how much having spoken recently reduces selection probability
|
||||
const RECENCY_PENALTY_WEIGHT = 0.5;
|
||||
// Tuning: random jitter range to prevent deterministic speaker ordering
|
||||
const SELECTION_JITTER_RANGE = 0.4;
|
||||
|
||||
const weights = participants.map(agent => {
|
||||
if (agent === lastSpeaker) return 0;
|
||||
|
||||
let weight = 1;
|
||||
weight -= recencyPenalty(agent, speakCounts, history.length) * 0.5;
|
||||
weight += Math.random() * 0.4 - 0.2;
|
||||
weight -= recencyPenalty(agent, speakCounts, history.length) * RECENCY_PENALTY_WEIGHT;
|
||||
weight += Math.random() * SELECTION_JITTER_RANGE - SELECTION_JITTER_RANGE / 2;
|
||||
|
||||
return Math.max(0, weight);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CourtRole } from '../types.js';
|
||||
import { parsePositiveFloat, parsePositiveInt } from '../parse-env.js';
|
||||
|
||||
export interface RoleTokenBudgetConfig {
|
||||
defaultMaxTokens: number;
|
||||
@@ -17,6 +18,12 @@ export interface RoleTokenBudgetResolution {
|
||||
source: 'env_role_cap' | 'requested';
|
||||
}
|
||||
|
||||
// Token limits per role, sized for dialogue pacing:
|
||||
// - Judge/attorneys at 220 to allow rulings and cross-examination depth
|
||||
// - Witnesses at 160 to keep testimony punchy (3 sentences target)
|
||||
// - Bailiff at 120 for short announcements
|
||||
// - Default 260 used when no role-specific limit applies
|
||||
// - Cost at $0.002/1k tokens based on OpenRouter DeepSeek pricing
|
||||
const DEFAULT_ROLE_BUDGET_CONFIG: RoleTokenBudgetConfig = {
|
||||
defaultMaxTokens: 260,
|
||||
judgeMaxTokens: 220,
|
||||
@@ -27,21 +34,6 @@ const DEFAULT_ROLE_BUDGET_CONFIG: RoleTokenBudgetConfig = {
|
||||
costPer1kTokensUsd: 0.002,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function parsePositiveFloat(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function resolveRoleTokenBudgetConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): RoleTokenBudgetConfig {
|
||||
|
||||
@@ -77,7 +77,9 @@ export class Logger {
|
||||
this.write('error', message, context);
|
||||
}
|
||||
|
||||
// Convenience method to create a child logger with base context
|
||||
// Create a child logger that merges baseContext into every log entry.
|
||||
// Uses Object.create for lightweight cloning — avoids a full class
|
||||
// instantiation while letting setLevel() work independently on the child.
|
||||
child(baseContext: LogContext): Logger {
|
||||
const parent = this;
|
||||
const childLogger = Object.create(Logger.prototype) as Logger;
|
||||
|
||||
165
src/metrics.ts
165
src/metrics.ts
@@ -222,50 +222,53 @@ export function instrumentCourtSessionStore(
|
||||
});
|
||||
};
|
||||
|
||||
function wrapWithMetrics<Args extends unknown[], R>(
|
||||
operation: string,
|
||||
fn: (...args: Args) => Promise<R>,
|
||||
onSuccess?: (result: R) => void,
|
||||
): (...args: Args) => Promise<R> {
|
||||
return async (...args: Args): Promise<R> => {
|
||||
try {
|
||||
const result = await fn(...args);
|
||||
onSuccess?.(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
recordStoreError(operation, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
scheduleSessionStatusSync();
|
||||
|
||||
return {
|
||||
async createSession(input) {
|
||||
try {
|
||||
const session = await baseStore.createSession(input);
|
||||
createSession: wrapWithMetrics(
|
||||
'create_session',
|
||||
input => baseStore.createSession(input),
|
||||
() => {
|
||||
sessionLifecycleTotal.inc({ event: 'created' });
|
||||
scheduleSessionStatusSync();
|
||||
return session;
|
||||
} catch (error) {
|
||||
recordStoreError('create_session', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
async listSessions() {
|
||||
try {
|
||||
return await baseStore.listSessions();
|
||||
} catch (error) {
|
||||
recordStoreError('list_sessions', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
listSessions: wrapWithMetrics(
|
||||
'list_sessions',
|
||||
() => baseStore.listSessions(),
|
||||
),
|
||||
|
||||
async getSession(sessionId) {
|
||||
try {
|
||||
return await baseStore.getSession(sessionId);
|
||||
} catch (error) {
|
||||
recordStoreError('get_session', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
getSession: wrapWithMetrics(
|
||||
'get_session',
|
||||
sessionId => baseStore.getSession(sessionId),
|
||||
),
|
||||
|
||||
async startSession(sessionId) {
|
||||
try {
|
||||
const session = await baseStore.startSession(sessionId);
|
||||
startSession: wrapWithMetrics(
|
||||
'start_session',
|
||||
sessionId => baseStore.startSession(sessionId),
|
||||
() => {
|
||||
sessionLifecycleTotal.inc({ event: 'started' });
|
||||
scheduleSessionStatusSync();
|
||||
return session;
|
||||
} catch (error) {
|
||||
recordStoreError('start_session', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
async setPhase(sessionId, phase, phaseDurationMs) {
|
||||
try {
|
||||
@@ -285,76 +288,52 @@ export function instrumentCourtSessionStore(
|
||||
}
|
||||
},
|
||||
|
||||
async addTurn(input) {
|
||||
try {
|
||||
return await baseStore.addTurn(input);
|
||||
} catch (error) {
|
||||
recordStoreError('add_turn', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
addTurn: wrapWithMetrics(
|
||||
'add_turn',
|
||||
input => baseStore.addTurn(input),
|
||||
),
|
||||
|
||||
async castVote(input) {
|
||||
try {
|
||||
return await baseStore.castVote(input);
|
||||
} catch (error) {
|
||||
recordStoreError('cast_vote', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
castVote: wrapWithMetrics(
|
||||
'cast_vote',
|
||||
input => baseStore.castVote(input),
|
||||
),
|
||||
|
||||
async recordFinalRuling(input) {
|
||||
try {
|
||||
return await baseStore.recordFinalRuling(input);
|
||||
} catch (error) {
|
||||
recordStoreError('record_final_ruling', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
recordFinalRuling: wrapWithMetrics(
|
||||
'record_final_ruling',
|
||||
input => baseStore.recordFinalRuling(input),
|
||||
),
|
||||
|
||||
async recordRecap(input) {
|
||||
try {
|
||||
await baseStore.recordRecap(input);
|
||||
} catch (error) {
|
||||
recordStoreError('record_recap', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
recordRecap: wrapWithMetrics(
|
||||
'record_recap',
|
||||
input => baseStore.recordRecap(input),
|
||||
),
|
||||
|
||||
async completeSession(sessionId) {
|
||||
try {
|
||||
const session = await baseStore.completeSession(sessionId);
|
||||
completeSession: wrapWithMetrics(
|
||||
'complete_session',
|
||||
sessionId => baseStore.completeSession(sessionId),
|
||||
() => {
|
||||
sessionLifecycleTotal.inc({ event: 'completed' });
|
||||
scheduleSessionStatusSync();
|
||||
return session;
|
||||
} catch (error) {
|
||||
recordStoreError('complete_session', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
async failSession(sessionId, reason) {
|
||||
try {
|
||||
const session = await baseStore.failSession(sessionId, reason);
|
||||
failSession: wrapWithMetrics(
|
||||
'fail_session',
|
||||
(sessionId, reason) =>
|
||||
baseStore.failSession(sessionId, reason),
|
||||
() => {
|
||||
sessionLifecycleTotal.inc({ event: 'failed' });
|
||||
scheduleSessionStatusSync();
|
||||
return session;
|
||||
} catch (error) {
|
||||
recordStoreError('fail_session', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
async recoverInterruptedSessions() {
|
||||
try {
|
||||
const ids = await baseStore.recoverInterruptedSessions();
|
||||
recoverInterruptedSessions: wrapWithMetrics(
|
||||
'recover_interrupted_sessions',
|
||||
() => baseStore.recoverInterruptedSessions(),
|
||||
() => {
|
||||
scheduleSessionStatusSync();
|
||||
return ids;
|
||||
} catch (error) {
|
||||
recordStoreError('recover_interrupted_sessions', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
subscribe(sessionId, handler) {
|
||||
return baseStore.subscribe(sessionId, handler);
|
||||
|
||||
21
src/parse-env.ts
Normal file
21
src/parse-env.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Shared helpers for parsing environment variable values to positive numbers.
|
||||
*/
|
||||
|
||||
export 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 parsePositiveFloat(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DEFAULT_ROTATION_CONFIG,
|
||||
} from './court/prompt-bank.js';
|
||||
import { moderateContent } from './moderation/content-filter.js';
|
||||
import { parsePositiveInt } from './parse-env.js';
|
||||
import {
|
||||
CourtNotFoundError,
|
||||
CourtValidationError,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
recordVoteRejected,
|
||||
renderMetrics,
|
||||
} from './metrics.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
createSyntheticEvent,
|
||||
loadReplayRecording,
|
||||
@@ -105,11 +107,6 @@ function mapSessionMutationError(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(value ?? '', 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export interface ReplayRuntimeOptions {
|
||||
filePath: string;
|
||||
speed?: number;
|
||||
@@ -226,10 +223,9 @@ function createSessionHandler(deps: SessionRouteDeps) {
|
||||
try {
|
||||
selectedPrompt = selectNextSafePrompt(genreHistory);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
logger.error(
|
||||
'[server] selectNextSafePrompt failed:',
|
||||
error instanceof Error ? error.message : error,
|
||||
{ error: error instanceof Error ? error.message : error },
|
||||
);
|
||||
return sendError(
|
||||
res,
|
||||
@@ -349,8 +345,7 @@ function createSessionHandler(deps: SessionRouteDeps) {
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[replay] failed to start recorder for session=${session.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
@@ -412,8 +407,7 @@ function createVoteHandler(
|
||||
if (!spamDecision.allowed) {
|
||||
const spamReason = spamDecision.reason ?? 'unknown';
|
||||
recordVoteRejected(voteType, spamReason);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[vote-spam] blocked ip=${clientIp} session=${req.params.id} reason=${spamReason}`,
|
||||
);
|
||||
store.emitEvent(req.params.id, 'vote_spam_blocked', {
|
||||
@@ -662,10 +656,9 @@ function registerApiRoutes(
|
||||
res.setHeader('Content-Type', metricsContentType);
|
||||
res.status(200).send(metrics);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
logger.error(
|
||||
'[metrics] failed to render metrics:',
|
||||
error instanceof Error ? error.message : error,
|
||||
{ error: error instanceof Error ? error.message : error },
|
||||
);
|
||||
res.status(500).send('failed to render metrics');
|
||||
}
|
||||
@@ -870,8 +863,7 @@ export async function createServerApp(
|
||||
try {
|
||||
await recorder.start({ sessionId });
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[replay] failed to start recorder for recovered session=${sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
@@ -898,13 +890,10 @@ export async function bootstrap(): Promise<void> {
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? '3000', 10);
|
||||
app.listen(port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`JuryRigged running on http://localhost:${port}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Operator Dashboard: http://localhost:${port}/operator`);
|
||||
logger.info(`JuryRigged running on http://localhost:${port}`);
|
||||
logger.info(`Operator Dashboard: http://localhost:${port}/operator`);
|
||||
if (replayLaunch) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
logger.info(
|
||||
`[replay] enabled file=${replayLaunch.filePath} speed=${replayLaunch.speed}x`,
|
||||
);
|
||||
}
|
||||
@@ -919,8 +908,7 @@ const isMainModule = (() => {
|
||||
|
||||
if (isMainModule) {
|
||||
bootstrap().catch(error => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
logger.error('Bootstrap failed', { error });
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user