feat: implement section 11 - span export endpoints (JSON, windowed CSV, BIO-tagged CSV)
This commit is contained in:
parent
b5e4d6573e
commit
842a58f12b
2 changed files with 185 additions and 3 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
182
src/app/api/export/spans/route.ts
Normal file
182
src/app/api/export/spans/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue