feat: implement section 11 - span export endpoints (JSON, windowed CSV, BIO-tagged CSV)

This commit is contained in:
Marko Djordjevic 2026-02-14 10:14:58 +01:00
parent b5e4d6573e
commit 842a58f12b
2 changed files with 185 additions and 3 deletions

View file

@ -79,9 +79,9 @@
## 11. Export Endpoints ## 11. Export Endpoints
- [ ] 11.1 Create `GET /api/export/spans?chartId=X&format=json` — Raw Annotations JSON export with full metadata - [x] 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) - [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)
- [ ] 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.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 ## 12. Integration Testing & Polish

View file

@ -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<string, number>),
},
};
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 });
}
}