chore: cleanup unused imports and apply formatting
- Remove unused `and` import from VisionRecorder.ts - Remove unused `IncomingMessage` and `ws` imports from socket-server.ts - Add `muted` attribute to video element in ProblemVideoPlayer - Apply code formatting across vision and practice components - Update documentation formatting in DEPLOYMENT.md and README Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cecd1e93e2
commit
e703e90875
|
|
@ -52,6 +52,7 @@ curl https://abaci.one/api/health
|
|||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
|
|
@ -173,11 +174,13 @@ ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && docker-c
|
|||
### Health Check Failing
|
||||
|
||||
1. Check container logs:
|
||||
|
||||
```bash
|
||||
ssh nas.home.network "docker logs abaci-blue"
|
||||
```
|
||||
|
||||
2. Test health endpoint manually:
|
||||
|
||||
```bash
|
||||
ssh nas.home.network "docker exec abaci-blue curl -sf http://localhost:3000/api/health"
|
||||
```
|
||||
|
|
@ -209,6 +212,7 @@ If upgrading from the old single-container setup:
|
|||
```
|
||||
|
||||
This script will:
|
||||
|
||||
1. Stop the old `soroban-abacus-flashcards` container
|
||||
2. Stop compose-updater temporarily
|
||||
3. Deploy the new docker-compose.yaml
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ curl https://abaci.one/api/health
|
|||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
|
|
@ -174,11 +175,13 @@ git rev-parse HEAD
|
|||
### Health Check Failing
|
||||
|
||||
1. Check container logs:
|
||||
|
||||
```bash
|
||||
ssh nas.home.network "docker logs abaci-blue"
|
||||
```
|
||||
|
||||
2. Test health endpoint manually:
|
||||
|
||||
```bash
|
||||
ssh nas.home.network "docker exec abaci-blue curl -sf http://localhost:3000/api/health"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -152,7 +152,9 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||
|
||||
if (video.status === 'no_video') {
|
||||
return NextResponse.json(
|
||||
{ error: 'No video was recorded for this problem (camera may have been off)' },
|
||||
{
|
||||
error: 'No video was recorded for this problem (camera may have been off)',
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -582,7 +582,10 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
|
|||
// Handle part transition complete - called when transition screen finishes
|
||||
// This is where we trigger game break (after "put away abacus" message is shown)
|
||||
const handlePartTransitionComplete = useCallback(() => {
|
||||
console.log('[PracticeClient] handlePartTransitionComplete called, pendingGameBreak:', pendingGameBreak)
|
||||
console.log(
|
||||
'[PracticeClient] handlePartTransitionComplete called, pendingGameBreak:',
|
||||
pendingGameBreak
|
||||
)
|
||||
// First, broadcast to observers
|
||||
sendPartTransitionComplete()
|
||||
|
||||
|
|
|
|||
|
|
@ -247,7 +247,13 @@ export function BroadcastDebugPanel({
|
|||
border: '1px solid rgba(96, 165, 250, 0.3)',
|
||||
})}
|
||||
>
|
||||
<div className={css({ color: '#60a5fa', fontWeight: 'bold', marginBottom: '6px' })}>
|
||||
<div
|
||||
className={css({
|
||||
color: '#60a5fa',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '6px',
|
||||
})}
|
||||
>
|
||||
Current State
|
||||
</div>
|
||||
<div className={css({ color: '#d1d5db', fontSize: '10px' })}>
|
||||
|
|
@ -275,7 +281,13 @@ export function BroadcastDebugPanel({
|
|||
)}
|
||||
|
||||
{lastBroadcastTime && (
|
||||
<div className={css({ color: '#6b7280', fontSize: '10px', marginBottom: '8px' })}>
|
||||
<div
|
||||
className={css({
|
||||
color: '#6b7280',
|
||||
fontSize: '10px',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
Last broadcast: {new Date(lastBroadcastTime).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -283,7 +283,13 @@ export function ObserverDebugPanel({
|
|||
border: '1px solid rgba(244, 114, 182, 0.3)',
|
||||
})}
|
||||
>
|
||||
<div className={css({ color: '#f472b6', fontWeight: 'bold', marginBottom: '6px' })}>
|
||||
<div
|
||||
className={css({
|
||||
color: '#f472b6',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '6px',
|
||||
})}
|
||||
>
|
||||
Observed State
|
||||
</div>
|
||||
<div className={css({ color: '#d1d5db', fontSize: '10px' })}>
|
||||
|
|
@ -325,7 +331,13 @@ export function ObserverDebugPanel({
|
|||
border: '1px solid rgba(96, 165, 250, 0.3)',
|
||||
})}
|
||||
>
|
||||
<div className={css({ color: '#60a5fa', fontWeight: 'bold', marginBottom: '6px' })}>
|
||||
<div
|
||||
className={css({
|
||||
color: '#60a5fa',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '6px',
|
||||
})}
|
||||
>
|
||||
Vision Frame
|
||||
</div>
|
||||
<div className={css({ color: '#d1d5db', fontSize: '10px' })}>
|
||||
|
|
@ -349,7 +361,13 @@ export function ObserverDebugPanel({
|
|||
border: '1px solid rgba(168, 85, 247, 0.3)',
|
||||
})}
|
||||
>
|
||||
<div className={css({ color: '#a855f7', fontWeight: 'bold', marginBottom: '6px' })}>
|
||||
<div
|
||||
className={css({
|
||||
color: '#a855f7',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '6px',
|
||||
})}
|
||||
>
|
||||
DVR Buffer
|
||||
</div>
|
||||
<div className={css({ color: '#d1d5db', fontSize: '10px' })}>
|
||||
|
|
|
|||
|
|
@ -680,7 +680,10 @@ export function useInteractionPhase(
|
|||
|
||||
// If no active problem, nothing to do
|
||||
if (!activeProblem) {
|
||||
console.log('[useInteractionPhase] No active problem - cannot load. Current phase:', phase.phase)
|
||||
console.log(
|
||||
'[useInteractionPhase] No active problem - cannot load. Current phase:',
|
||||
phase.phase
|
||||
)
|
||||
prevActiveProblemKeyRef.current = null
|
||||
return
|
||||
}
|
||||
|
|
@ -718,7 +721,10 @@ export function useInteractionPhase(
|
|||
|
||||
// Case 2: Key changed - handle redo mode or session advancement
|
||||
if (keyChanged) {
|
||||
console.log('[useInteractionPhase] Key changed:', { prevKey, currentKey })
|
||||
console.log('[useInteractionPhase] Key changed:', {
|
||||
prevKey,
|
||||
currentKey,
|
||||
})
|
||||
|
||||
// CRITICAL: Don't interrupt normal progression flow
|
||||
// If we're in showingFeedback, submitting, or transitioning, the normal flow
|
||||
|
|
|
|||
|
|
@ -469,6 +469,7 @@ export function ProblemVideoPlayer({
|
|||
src={videoUrl}
|
||||
controls
|
||||
playsInline
|
||||
muted
|
||||
onCanPlay={handleCanPlay}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
className={css({
|
||||
|
|
@ -839,8 +840,13 @@ export function ProblemVideoPlayer({
|
|||
Step {noVideoPlaybackIndex + 1} of {answerProgression.length}
|
||||
</span>
|
||||
{currentNoVideoState?.originalTimestamp !== undefined && (
|
||||
<span className={css({ color: isDark ? 'gray.600' : 'gray.300' })}>
|
||||
(actual: {(currentNoVideoState.originalTimestamp / 1000).toFixed(1)}s)
|
||||
<span
|
||||
className={css({
|
||||
color: isDark ? 'gray.600' : 'gray.300',
|
||||
})}
|
||||
>
|
||||
(actual: {(currentNoVideoState.originalTimestamp / 1000).toFixed(1)}
|
||||
s)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -325,7 +325,9 @@ describe('useUserPlayers hooks', () => {
|
|||
})
|
||||
|
||||
// Should have invalidated with playerKeys.all
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: playerKeys.all })
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: playerKeys.all,
|
||||
})
|
||||
})
|
||||
|
||||
test('invalidates all player queries even on error', async () => {
|
||||
|
|
@ -349,7 +351,9 @@ describe('useUserPlayers hooks', () => {
|
|||
})
|
||||
|
||||
// onSettled runs on both success and error
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: playerKeys.all })
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: playerKeys.all,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -422,7 +426,10 @@ describe('useUserPlayers hooks', () => {
|
|||
const { result } = renderHook(() => useUpdatePlayer(), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({ id: 'player-1', updates: { name: 'Updated Name' } })
|
||||
result.current.mutate({
|
||||
id: 'player-1',
|
||||
updates: { name: 'Updated Name' },
|
||||
})
|
||||
})
|
||||
|
||||
// Wait for optimistic update
|
||||
|
|
@ -461,7 +468,10 @@ describe('useUserPlayers hooks', () => {
|
|||
const { result } = renderHook(() => useUpdatePlayer(), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.mutate({ id: 'player-1', updates: { name: 'Updated Name' } })
|
||||
result.current.mutate({
|
||||
id: 'player-1',
|
||||
updates: { name: 'Updated Name' },
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
|
|||
|
|
@ -406,13 +406,12 @@ export function useSessionBroadcast(
|
|||
return
|
||||
}
|
||||
|
||||
// Only send markers if recording is active
|
||||
if (!isRecordingRef.current) {
|
||||
return
|
||||
}
|
||||
// Always send markers - server will capture metadata even without video frames
|
||||
// This enables playback of student answers even when camera wasn't enabled
|
||||
|
||||
socketRef.current.emit('vision-problem-marker', {
|
||||
sessionId,
|
||||
playerId, // Include playerId for auto-starting metadata-only sessions
|
||||
problemNumber,
|
||||
partIndex,
|
||||
eventType,
|
||||
|
|
@ -430,7 +429,7 @@ export function useSessionBroadcast(
|
|||
retryContext,
|
||||
})
|
||||
},
|
||||
[sessionId]
|
||||
[sessionId, playerId]
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ When a student practices with their camera enabled, the system records a separat
|
|||
Problem markers are socket events that coordinate video recording with problem lifecycle:
|
||||
|
||||
| Marker Event | When Sent | VisionRecorder Action |
|
||||
|--------------|-----------|----------------------|
|
||||
| ------------------ | ----------------------------- | ------------------------------------------ |
|
||||
| `problem-shown` | New problem appears on screen | Start buffering frames for this problem |
|
||||
| `answer-submitted` | Student submits their answer | Stop buffering, encode video, save to disk |
|
||||
| `feedback-shown` | Feedback displayed | (Reserved for future use) |
|
||||
|
|
@ -68,27 +68,29 @@ Problem markers are socket events that coordinate video recording with problem l
|
|||
|
||||
```typescript
|
||||
interface ProblemMarker {
|
||||
sessionId: string
|
||||
problemNumber: number // 1-indexed problem number
|
||||
partIndex: number // Which part of the session
|
||||
eventType: 'problem-shown' | 'answer-submitted' | 'feedback-shown'
|
||||
isCorrect?: boolean // Only for answer-submitted
|
||||
sessionId: string;
|
||||
problemNumber: number; // 1-indexed problem number
|
||||
partIndex: number; // Which part of the session
|
||||
eventType: "problem-shown" | "answer-submitted" | "feedback-shown";
|
||||
isCorrect?: boolean; // Only for answer-submitted
|
||||
|
||||
// Retry context (for multiple attempts at same problem)
|
||||
epochNumber: number // 0 = initial pass, 1+ = retry epochs
|
||||
attemptNumber: number // Which attempt (1, 2, 3...)
|
||||
isRetry: boolean // True if in a retry epoch
|
||||
isManualRedo: boolean // True if student clicked dot to redo
|
||||
epochNumber: number; // 0 = initial pass, 1+ = retry epochs
|
||||
attemptNumber: number; // Which attempt (1, 2, 3...)
|
||||
isRetry: boolean; // True if in a retry epoch
|
||||
isManualRedo: boolean; // True if student clicked dot to redo
|
||||
}
|
||||
```
|
||||
|
||||
### Why Retry Context Matters
|
||||
|
||||
Students can attempt the same problem multiple times:
|
||||
|
||||
- **Epoch retries**: End-of-part retry rounds for missed problems
|
||||
- **Manual redos**: Student clicks a completed problem dot to practice again
|
||||
|
||||
Each attempt gets its own recording. The retry context determines:
|
||||
|
||||
- **Filename**: `problem_001_e0_a1.mp4` (epoch 0, attempt 1) vs `problem_001_e0_a2.mp4` (attempt 2)
|
||||
- **Database record**: Separate rows in `vision_problem_videos` with epoch/attempt fields
|
||||
|
||||
|
|
@ -112,29 +114,29 @@ Each video has a companion `.meta.json` file with time-coded state for synchroni
|
|||
```typescript
|
||||
interface ProblemMetadata {
|
||||
problem: {
|
||||
terms: number[] // e.g., [45, -23, 12]
|
||||
answer: number // e.g., 34
|
||||
}
|
||||
terms: number[]; // e.g., [45, -23, 12]
|
||||
answer: number; // e.g., 34
|
||||
};
|
||||
entries: Array<{
|
||||
t: number // ms from video start
|
||||
detectedValue: number | null // ML-detected abacus value
|
||||
confidence: number // 0-1 detection confidence
|
||||
studentAnswer: string // Current typed answer
|
||||
phase: 'problem' | 'feedback'
|
||||
isCorrect?: boolean
|
||||
}>
|
||||
durationMs: number
|
||||
frameCount: number
|
||||
isCorrect: boolean | null
|
||||
t: number; // ms from video start
|
||||
detectedValue: number | null; // ML-detected abacus value
|
||||
confidence: number; // 0-1 detection confidence
|
||||
studentAnswer: string; // Current typed answer
|
||||
phase: "problem" | "feedback";
|
||||
isCorrect?: boolean;
|
||||
}>;
|
||||
durationMs: number;
|
||||
frameCount: number;
|
||||
isCorrect: boolean | null;
|
||||
}
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| ------------------------------------------------- | ----------------------------------------------- |
|
||||
| `src/lib/vision/recording/VisionRecorder.ts` | Server-side recording manager |
|
||||
| `src/socket-server.ts` | Socket event routing (lines handling vision-*) |
|
||||
| `src/socket-server.ts` | Socket event routing (lines handling vision-\*) |
|
||||
| `src/hooks/useSessionBroadcast.ts` | Client socket helpers (`sendProblemMarker`) |
|
||||
| `src/app/practice/[studentId]/PracticeClient.tsx` | Sends markers on problem state changes |
|
||||
| `src/components/vision/DockedVisionFeed.tsx` | Captures and sends camera frames |
|
||||
|
|
@ -143,7 +145,7 @@ interface ProblemMetadata {
|
|||
## API Routes
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| ------------------------------------------------------------------ | ------------------------- |
|
||||
| `GET /api/curriculum/[playerId]/sessions/[sessionId]/videos` | List available recordings |
|
||||
| `GET /api/.../problems/[problemNumber]/video?epoch=X&attempt=Y` | Stream video file |
|
||||
| `GET /api/.../problems/[problemNumber]/metadata?epoch=X&attempt=Y` | Get metadata JSON |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq, and } from 'drizzle-orm'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
import { mkdir, writeFile, rm } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { Server as HTTPServer, IncomingMessage } from 'http'
|
||||
import type { Server as HTTPServer } from 'http'
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import type { Server as SocketIOServerType } from 'socket.io'
|
||||
import { WebSocketServer, type WebSocket } from 'ws'
|
||||
import {
|
||||
applyGameMove,
|
||||
createArcadeSession,
|
||||
|
|
@ -1174,6 +1173,8 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
|||
})
|
||||
|
||||
// Vision Recording: Handle problem marker (triggers encoding on problem transitions)
|
||||
// NOTE: Markers are always processed - if no recording session exists, one is auto-started
|
||||
// for metadata-only capture (student answers without video)
|
||||
socket.on(
|
||||
'vision-problem-marker',
|
||||
async ({
|
||||
|
|
@ -1186,6 +1187,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
|||
attemptNumber,
|
||||
isRetry,
|
||||
isManualRedo,
|
||||
playerId,
|
||||
}: {
|
||||
sessionId: string
|
||||
problemNumber: number
|
||||
|
|
@ -1196,8 +1198,19 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
|||
attemptNumber?: number
|
||||
isRetry?: boolean
|
||||
isManualRedo?: boolean
|
||||
playerId?: string
|
||||
}) => {
|
||||
const recorder = VisionRecorder.getInstance()
|
||||
|
||||
// Auto-start a metadata-only session if one doesn't exist
|
||||
// This allows capturing student answers even when camera isn't enabled
|
||||
if (!recorder.isRecording(sessionId) && playerId) {
|
||||
console.log(
|
||||
`📝 Auto-starting metadata-only recording session for ${sessionId} (no camera)`
|
||||
)
|
||||
recorder.startSession(sessionId, playerId)
|
||||
}
|
||||
|
||||
if (recorder.isRecording(sessionId)) {
|
||||
// This triggers encoding when 'problem-shown' arrives for the next problem
|
||||
await recorder.onProblemMarker(sessionId, {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@ services:
|
|||
networks:
|
||||
- webgateway
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@ services:
|
|||
networks:
|
||||
- webgateway
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
|
|
|||
|
|
@ -20,7 +20,13 @@ x-app: &app
|
|||
networks:
|
||||
- webgateway
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"require('http').get('http://localhost:3000/', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
|
|
|||
Loading…
Reference in New Issue