diff --git a/openspec/changes/span-annotation/tasks.md b/openspec/changes/span-annotation/tasks.md index 46ff32c..74582db 100644 --- a/openspec/changes/span-annotation/tasks.md +++ b/openspec/changes/span-annotation/tasks.md @@ -79,9 +79,9 @@ ## 11. Export Endpoints -- [ ] 11.1 Create `GET /api/export/spans?chartId=X&format=json` — Raw Annotations JSON export with full metadata -- [ ] 11.2 Create `GET /api/export/spans?chartId=X&format=windowed` — Windowed Classification CSV with flattened OHLCV columns and configurable `context_padding` (default 10) -- [ ] 11.3 Create `GET /api/export/spans?chartId=X&format=bio` — BIO-tagged CSV with one row per candle, B-{label}/I-{label}/O tagging, multi-label columns for overlapping spans +- [x] 11.1 Create `GET /api/export/spans?chartId=X&format=json` — Raw Annotations JSON export with full metadata +- [x] 11.2 Create `GET /api/export/spans?chartId=X&format=windowed` — Windowed Classification CSV with flattened OHLCV columns and configurable `context_padding` (default 10) +- [x] 11.3 Create `GET /api/export/spans?chartId=X&format=bio` — BIO-tagged CSV with one row per candle, B-{label}/I-{label}/O tagging, multi-label columns for overlapping spans ## 12. Integration Testing & Polish diff --git a/src/app/api/export/spans/route.ts b/src/app/api/export/spans/route.ts new file mode 100644 index 0000000..2209b5b --- /dev/null +++ b/src/app/api/export/spans/route.ts @@ -0,0 +1,182 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { spanAnnotations, candles } from '@/db/schema'; +import { eq, and } from 'drizzle-orm'; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const chartId = searchParams.get('chartId'); + const format = searchParams.get('format') || 'json'; + const contextPadding = parseInt(searchParams.get('context_padding') || '10', 10); + + if (!chartId) { + return NextResponse.json({ error: 'chartId is required' }, { status: 400 }); + } + + const chartIdNum = parseInt(chartId, 10); + + // Fetch span annotations for the chart + const spans = await db + .select() + .from(spanAnnotations) + .where(eq(spanAnnotations.chart_id, chartIdNum)) + .orderBy(spanAnnotations.start_time); + + // Fetch candles for the chart + const chartCandles = await db + .select() + .from(candles) + .where(eq(candles.chart_id, chartIdNum)) + .orderBy(candles.time); + + if (format === 'json') { + // Raw Annotations JSON export + const exportData = { + chart_id: chartIdNum, + export_date: new Date().toISOString(), + total_spans: spans.length, + spans: spans.map((span) => ({ + id: span.id, + start_time: span.start_time, + end_time: span.end_time, + label: span.label, + confidence: span.confidence, + outcome: span.outcome, + notes: span.notes, + sub_spans: span.sub_spans ? JSON.parse(span.sub_spans as string) : null, + color: span.color, + created_at: span.created_at, + })), + summary: { + labels: spans.reduce((acc, span) => { + acc[span.label] = (acc[span.label] || 0) + 1; + return acc; + }, {} as Record), + }, + }; + + return NextResponse.json(exportData, { + headers: { + 'Content-Disposition': `attachment; filename="span-annotations-${chartId}.json"`, + }, + }); + } else if (format === 'windowed') { + // Windowed Classification CSV + const csvRows: string[] = [ + 'span_id,label,confidence,outcome,start_time,end_time,context_start,context_end,' + + 'candle_time,open,high,low,close,position', + ]; + + for (const span of spans) { + // Find candles in the span range + const spanCandles = chartCandles.filter( + (c) => c.time >= span.start_time && c.time <= span.end_time + ); + + // Add context padding + const contextStart = span.start_time - contextPadding * 60; // Assuming 1-minute candles + const contextEnd = span.end_time + contextPadding * 60; + + const contextCandles = chartCandles.filter( + (c) => c.time >= contextStart && c.time <= contextEnd + ); + + // Create one row per candle in the context window + contextCandles.forEach((candle, idx) => { + let position = 'context'; + if (candle.time >= span.start_time && candle.time <= span.end_time) { + position = 'span'; + } + + csvRows.push( + [ + span.id, + span.label, + span.confidence || '', + span.outcome || '', + span.start_time, + span.end_time, + contextStart, + contextEnd, + candle.time, + candle.open, + candle.high, + candle.low, + candle.close, + position, + ].join(',') + ); + }); + } + + const csvContent = csvRows.join('\n'); + + return new NextResponse(csvContent, { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="span-annotations-windowed-${chartId}.csv"`, + }, + }); + } else if (format === 'bio') { + // BIO-tagged CSV + // One row per candle, with BIO tags for each label type + + // Get all unique labels + const labelTypes = Array.from(new Set(spans.map((s) => s.label))); + + // Create header + const header = ['time', 'open', 'high', 'low', 'close']; + labelTypes.forEach((label) => { + header.push(`bio_${label}`); + }); + + const csvRows: string[] = [header.join(',')]; + + // Process each candle + chartCandles.forEach((candle) => { + const row = [ + candle.time.toString(), + candle.open.toString(), + candle.high.toString(), + candle.low.toString(), + candle.close.toString(), + ]; + + // For each label type, determine BIO tag + labelTypes.forEach((label) => { + const spansWithLabel = spans.filter((s) => s.label === label); + + let bioTag = 'O'; // Outside by default + + for (const span of spansWithLabel) { + if (candle.time >= span.start_time && candle.time <= span.end_time) { + // Check if this is the first candle in the span + const isFirst = candle.time === span.start_time; + bioTag = isFirst ? `B-${label}` : `I-${label}`; + break; // Use first matching span + } + } + + row.push(bioTag); + }); + + csvRows.push(row.join(',')); + }); + + const csvContent = csvRows.join('\n'); + + return new NextResponse(csvContent, { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="span-annotations-bio-${chartId}.csv"`, + }, + }); + } else { + return NextResponse.json({ error: 'Invalid format. Use json, windowed, or bio' }, { status: 400 }); + } + } catch (error) { + console.error('Export error:', error); + return NextResponse.json({ error: 'Failed to export span annotations' }, { status: 500 }); + } +}