Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,14 @@
"perclientserverlabel",
"nvmap",
"pixelformat",
"platlib",
"plottable",
"pollable",
"poutine",
"preds",
"printloglevel",
"prio",
"purelib",
"pyrealsense",
"pyremote",
"pyremotecp",
Expand Down
45 changes: 18 additions & 27 deletions data-management/viewer/backend/src/api/routers/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@
frame editing, removal, and sub-task annotations applied.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
from pathlib import Path
from typing import Any
Expand All @@ -23,7 +20,6 @@
from ..services.hdf5_exporter import (
EpisodeEditOperations,
ExportProgress,
ExportResult,
HDF5Exporter,
HDF5ExportError,
parse_edit_operations,
Expand All @@ -36,7 +32,6 @@
)

router = APIRouter()
logger = logging.getLogger(__name__)


class ImageTransformRequest(SanitizedModel):
Expand Down Expand Up @@ -105,25 +100,14 @@


class ExportResultResponse(BaseModel):
"""Batch result with public error text and aggregate statistics on success or failure."""
"""Export result response model."""

success: bool
outputFiles: list[str]
error: str | None = None
stats: dict[str, Any] = Field(default_factory=dict)


def _public_export_result(result: ExportResult) -> ExportResultResponse:
if not result.success:
logger.error("Export failed: %s", result.error)
return ExportResultResponse(
success=result.success,
outputFiles=result.output_files,
error=None if result.success else "Export failed",
stats=result.stats,
)


@router.post(
"/{dataset_id}/export",
response_model=ExportResultResponse,
Expand Down Expand Up @@ -219,7 +203,12 @@
edits_map=edits_map,
)

return _public_export_result(result)
return ExportResultResponse(
success=result.success,
outputFiles=result.output_files,
error=result.error,
stats=result.stats,
)

except ImportError as e:
raise HTTPException(
Expand Down Expand Up @@ -388,20 +377,22 @@
break

# Send completion event
complete_data = _public_export_result(result).model_dump()
complete_data = {
"success": result.success,
"outputFiles": result.output_files,
"error": result.error,
"stats": result.stats,
}
yield f"event: complete\ndata: {json.dumps(complete_data)}\n\n"

except ImportError:
logger.exception("Export stream unavailable")
payload = {"code": "EXPORT_UNAVAILABLE", "message": "Export is unavailable"}
yield f"event: error\ndata: {json.dumps(payload)}\n\n"
except Exception:
logger.exception("Export stream failed")
payload = {"code": "EXPORT_FAILED", "message": "Export failed"}
yield f"event: error\ndata: {json.dumps(payload)}\n\n"
except ImportError as e:
error_msg = f"Export not available: {e}"
yield f"event: error\ndata: {json.dumps({'error': error_msg})}\n\n"
except Exception as e:
yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n"

return StreamingResponse(
event_generator(),

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.
Stack trace information
flows to this location and may be exposed to an external user.
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
Expand Down
46 changes: 4 additions & 42 deletions data-management/viewer/backend/tests/test_export_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def _make_export_result(success: bool = True, error: str | None = None) -> Magic
result.success = success
result.output_files = ["episode_0.hdf5"]
result.error = error
result.stats = {"total_episodes": 1, "total_frames": 10, "removed_frames": 0, "duration_ms": 25}
result.stats = {"episodes": 1, "frames_written": 10}
return result


Expand Down Expand Up @@ -199,8 +199,7 @@ def test_success_with_full_edits(
data = resp.json()
assert data["success"] is True
assert data["outputFiles"] == ["episode_0.hdf5"]
assert data["stats"]["total_episodes"] == 1
assert data["error"] is None
assert data["stats"]["episodes"] == 1
# Edits should have been parsed into the exporter call.
kwargs = exporter_instance.export_episodes.call_args.kwargs
assert kwargs["episode_indices"] == [0]
Expand Down Expand Up @@ -251,39 +250,6 @@ def test_export_error_returns_500(


class TestExportEpisodesStream:
@pytest.mark.parametrize("suffix", ["", "/stream"])
def test_failed_results_hide_diagnostics(
self,
client: TestClient,
override_service,
dataset_layout,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
suffix: str,
) -> None:
_, _, output_dir = dataset_layout
exporter = MagicMock()
exporter.export_episodes.return_value = _make_export_result(
success=False, error="/srv/data/private: permission denied"
)
_patch_exporter(monkeypatch, MagicMock(return_value=exporter))
response = client.post(
f"/api/datasets/ds-1/export{suffix}",
json={"episodeIndices": [0], "outputPath": str(output_dir), "applyEdits": False},
)
assert response.status_code == 200
payload = (
json.loads(response.text.split("event: complete\ndata: ", 1)[1].split("\n\n", 1)[0])
if suffix
else response.json()
)
assert payload["success"] is False
assert payload["error"] == "Export failed"
assert payload["outputFiles"] == ["episode_0.hdf5"]
assert payload["stats"]["total_episodes"] == 1
assert "/srv/data/private" not in response.text
assert "/srv/data/private" in caplog.text

def test_dataset_not_found_returns_404(self, client: TestClient, override_service) -> None:
override_service.get_dataset = AsyncMock(return_value=None)
resp = client.post(
Expand Down Expand Up @@ -386,9 +352,7 @@ def test_stream_import_error_emits_error_event(
body = "".join(resp.iter_text())

assert "event: error" in body
assert '"code": "EXPORT_UNAVAILABLE"' in body
assert '"message": "Export is unavailable"' in body
assert "missing dep" not in body
assert "Export not available" in body

def test_stream_generic_exception_emits_error_event(
self,
Expand All @@ -412,9 +376,7 @@ def test_stream_generic_exception_emits_error_event(
body = "".join(resp.iter_text())

assert "event: error" in body
assert '"code": "EXPORT_FAILED"' in body
assert '"message": "Export failed"' in body
assert "disk full" not in body
assert "disk full" in body


# ---------------------------------------------------------------------------
Expand Down
11 changes: 0 additions & 11 deletions data-management/viewer/backend/tests/test_hdf5_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,17 +366,6 @@ def test_export_multiple_episodes(self, exporter: HDF5Exporter, hdf5_export_dir:
assert (hdf5_export_dir / f"episode_{ep_idx:06d}.hdf5").exists()
assert (hdf5_export_dir / f"episode_{ep_idx:06d}.meta.json").exists()

def test_failed_batch_preserves_aggregate_statistics(self, exporter: HDF5Exporter) -> None:
result = exporter.export_episodes(episode_indices=[0, 999])

assert result.success is False
assert result.error is not None
assert result.output_files
assert result.stats["total_episodes"] == 2
assert result.stats["total_frames"] == 10
assert result.stats["removed_frames"] == 0
assert result.stats["duration_ms"] >= 0

def test_export_with_frame_insertion(self, exporter: HDF5Exporter, hdf5_export_dir: Path):
edits = EpisodeEditOperations(
dataset_id="test",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,20 @@ import {
} from '../ai-analysis'

vi.mock('@/lib/api-client', () => ({
apiRequest: vi.fn(),
handleResponse: vi.fn(),
mutationHeaders: vi.fn(),
}))

const { apiRequest, handleResponse } = await import('@/lib/api-client')
const mockApiRequest = vi.mocked(apiRequest)
const { handleResponse, mutationHeaders } = await import('@/lib/api-client')
const mockHandleResponse = vi.mocked(handleResponse)
const mockMutationHeaders = vi.mocked(mutationHeaders)
const mockFetch = vi.fn()

beforeEach(() => {
mockFetch.mockReset()
mockApiRequest.mockReset()
mockHandleResponse.mockReset()
mockApiRequest.mockImplementation(async (path, init) => {
const response = await mockFetch(`/api${path}`, init)
return mockHandleResponse(response)
})
mockMutationHeaders.mockReset()
mockMutationHeaders.mockResolvedValue({ 'X-CSRF-Token': 'test-token' })
vi.stubGlobal('fetch', mockFetch)
})

Expand All @@ -52,16 +49,16 @@ describe('analyzeTrajectory', () => {
const data: TrajectoryData = {
positions: [[0, 0, 0]],
timestamps: [0],
gripperStates: [0],
gripper_states: [0],
}
const metrics: TrajectoryMetrics = {
smoothness: 0.9,
normalizedSmoothness: 0.6,
normalized_smoothness: 0.6,
efficiency: 0.8,
jitter: 0.1,
hesitationCount: 0,
correctionCount: 0,
overallScore: 0.85,
hesitation_count: 0,
correction_count: 0,
overall_score: 0.85,
flags: [],
}
mockFetch.mockResolvedValueOnce(okResponse())
Expand All @@ -70,14 +67,14 @@ describe('analyzeTrajectory', () => {
const result = await analyzeTrajectory(data)

expect(result).toEqual(metrics)
expect(mockMutationHeaders).toHaveBeenCalledTimes(1)
expect(mockFetch).toHaveBeenCalledWith('/api/ai/trajectory-analysis', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
positions: data.positions,
timestamps: data.timestamps,
gripper_states: data.gripperStates,
}),
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': 'test-token',
},
body: JSON.stringify(data),
})
expect(mockHandleResponse).toHaveBeenCalledWith(expect.objectContaining({ ok: true }))
})
Expand All @@ -100,8 +97,8 @@ describe('detectAnomalies', () => {
}
const response: AnomalyDetectionResponse = {
anomalies: [],
totalCount: 0,
severityCounts: { low: 0, medium: 0, high: 0 },
total_count: 0,
severity_counts: { low: 0, medium: 0, high: 0 },
}
mockFetch.mockResolvedValueOnce(okResponse())
mockHandleResponse.mockResolvedValueOnce(response)
Expand All @@ -113,26 +110,26 @@ describe('detectAnomalies', () => {
expect(url).toBe('/api/ai/anomaly-detection')
expect(init).toMatchObject({
method: 'POST',
body: JSON.stringify({
positions: request.positions,
timestamps: request.timestamps,
}),
body: JSON.stringify(request),
})
expect(init.headers).toMatchObject({
'Content-Type': 'application/json',
'X-CSRF-Token': 'test-token',
})
expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' })
})
})

describe('clusterEpisodes', () => {
it('POSTs cluster request and returns the response', async () => {
const request: ClusterRequest = {
trajectories: [[[0, 0, 0]]],
numClusters: 3,
num_clusters: 3,
}
const response: ClusterResponse = {
numClusters: 3,
num_clusters: 3,
assignments: [],
clusterSizes: { '0': 0 },
silhouetteScore: 0.5,
cluster_sizes: { '0': 0 },
silhouette_score: 0.5,
}
mockFetch.mockResolvedValueOnce(okResponse())
mockHandleResponse.mockResolvedValueOnce(response)
Expand All @@ -144,7 +141,7 @@ describe('clusterEpisodes', () => {
'/api/ai/cluster',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ trajectories: request.trajectories, num_clusters: 3 }),
body: JSON.stringify(request),
}),
)
})
Expand All @@ -157,10 +154,10 @@ describe('getAnnotationSuggestion', () => {
timestamps: [0],
}
const suggestion: AnnotationSuggestion = {
taskCompletionRating: 4,
trajectoryQualityScore: 0.9,
suggestedFlags: [],
detectedAnomalies: [],
task_completion_rating: 4,
trajectory_quality_score: 0.9,
suggested_flags: [],
detected_anomalies: [],
confidence: 0.95,
reasoning: 'looks good',
}
Expand All @@ -174,21 +171,18 @@ describe('getAnnotationSuggestion', () => {
'/api/ai/suggest-annotation',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
positions: request.positions,
timestamps: request.timestamps,
}),
body: JSON.stringify(request),
}),
)
})

it('routes every request through the canonical client', async () => {
it('awaits mutationHeaders for every request', async () => {
mockFetch.mockResolvedValue(okResponse())
mockHandleResponse.mockResolvedValue({} as AnnotationSuggestion)

await getAnnotationSuggestion({ positions: [], timestamps: [] })
await getAnnotationSuggestion({ positions: [], timestamps: [] })

expect(mockApiRequest).toHaveBeenCalledTimes(2)
expect(mockMutationHeaders).toHaveBeenCalledTimes(2)
})
})
Loading
Loading