feat(api): add span annotation export and feedback loop support

- Add GET /api/span-annotations/export endpoint for ML pipeline JSON/CSV export
- Add source and model_prediction fields to span_annotations schema
- Update POST endpoint to accept source (human/model/human_correction) and model_prediction metadata
- Support negative annotations (label 'O' for user corrections to model predictions)
- Create migration 0005 for new schema fields

Completes tasks 8.1-8.4 of candle-backend change
This commit is contained in:
Marko Djordjevic 2026-02-15 14:35:31 +01:00
parent 205021e810
commit bb1b6d573f
5 changed files with 147 additions and 4 deletions

View file

@ -0,0 +1,11 @@
-- Migration: Add source and model_prediction fields to span_annotations
-- Supports tracking annotation origin and model feedback loop
-- Add source field (defaults to 'human')
-- Possible values: 'human', 'model', 'human_correction'
ALTER TABLE `span_annotations` ADD COLUMN `source` TEXT NOT NULL DEFAULT 'human';
-- Add model_prediction field (nullable JSON)
-- Stores model prediction metadata when user confirms/corrects a prediction
-- Example: {"label": "bull_flag", "confidence": 0.85, "model_version": "xgb_v1"}
ALTER TABLE `span_annotations` ADD COLUMN `model_prediction` TEXT;

View file

@ -71,10 +71,10 @@
## 8. Span Annotation Export & Feedback
- [ ] 8.1 Create `src/app/api/span-annotations/export/route.ts` — GET endpoint exporting span annotations as JSON in ML pipeline format
- [ ] 8.2 Add `source` and `model_prediction` fields to span annotation schema (Drizzle migration) — source defaults to "human", model_prediction is nullable JSON
- [ ] 8.3 Update span annotation POST endpoint to accept optional `source` and `model_prediction` fields
- [ ] 8.4 Support negative annotations — span with label "O", source "human_correction", and model_prediction metadata
- [x] 8.1 Create `src/app/api/span-annotations/export/route.ts` — GET endpoint exporting span annotations as JSON in ML pipeline format
- [x] 8.2 Add `source` and `model_prediction` fields to span annotation schema (Drizzle migration) — source defaults to "human", model_prediction is nullable JSON
- [x] 8.3 Update span annotation POST endpoint to accept optional `source` and `model_prediction` fields
- [x] 8.4 Support negative annotations — span with label "O", source "human_correction", and model_prediction metadata
## 9. Prediction UI — State & Controls

View file

@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { spanAnnotations, charts } from '@/lib/db/schema';
import { eq, desc } from 'drizzle-orm';
/**
* GET /api/span-annotations/export
*
* Export span annotations in ML pipeline format (JSON).
*
* Query params:
* - chartId: (optional) specific chart ID. If omitted, uses most recent chart.
* - format: (optional) 'json' (default) or 'csv'
*
* ML Pipeline JSON format:
* {
* "annotations": [
* {
* "id": 1,
* "start_time": "2024-01-01T00:00:00Z",
* "end_time": "2024-01-01T01:00:00Z",
* "label": "bull_flag",
* "confidence": 4,
* "notes": "Clear pattern"
* }
* ]
* }
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
let chartId = searchParams.get('chartId');
const format = searchParams.get('format') || 'json';
// Fall back to most recent chart if no chartId provided
if (!chartId) {
const latest = await db
.select({ id: charts.id })
.from(charts)
.orderBy(desc(charts.created_at))
.limit(1);
if (latest.length === 0) {
// No charts found - return empty export
if (format === 'json') {
return NextResponse.json({ annotations: [] });
} else {
return new NextResponse('id,start_time,end_time,label,confidence,outcome,notes\n', {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="span_annotations.csv"',
},
});
}
}
chartId = String(latest[0].id);
}
const chartIdNum = parseInt(chartId, 10);
// Fetch all span annotations for the chart
const spans = await db
.select()
.from(spanAnnotations)
.where(eq(spanAnnotations.chart_id, chartIdNum))
.orderBy(spanAnnotations.start_time);
if (format === 'json') {
// Convert to ML pipeline format
const annotations = spans.map(span => ({
id: span.id,
start_time: new Date(span.start_time * 1000).toISOString(),
end_time: new Date(span.end_time * 1000).toISOString(),
label: span.label,
confidence: span.confidence,
outcome: span.outcome,
notes: span.notes,
sub_spans: span.sub_spans ? JSON.parse(span.sub_spans) : null,
color: span.color,
source: span.source,
model_prediction: span.model_prediction ? JSON.parse(span.model_prediction) : null,
created_at: new Date(span.created_at * 1000).toISOString(),
}));
return NextResponse.json({
annotations,
chart_id: chartIdNum,
export_timestamp: new Date().toISOString(),
total_count: annotations.length,
});
} else if (format === 'csv') {
// CSV export
const csvRows = ['id,start_time,end_time,label,confidence,outcome,notes'];
for (const span of spans) {
const startTime = new Date(span.start_time * 1000).toISOString();
const endTime = new Date(span.end_time * 1000).toISOString();
const notes = span.notes ? `"${span.notes.replace(/"/g, '""')}"` : '';
csvRows.push(
`${span.id},${startTime},${endTime},${span.label},${span.confidence || ''},${span.outcome || ''},${notes}`
);
}
const csvContent = csvRows.join('\n');
return new NextResponse(csvContent, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="span_annotations.csv"',
},
});
} else {
return NextResponse.json(
{ error: 'Invalid format parameter. Use "json" or "csv".' },
{ status: 400 }
);
}
} catch (error: any) {
console.error('Error exporting span annotations:', error);
return NextResponse.json(
{ error: error.message || 'Failed to export span annotations' },
{ status: 500 }
);
}
}

View file

@ -46,6 +46,8 @@ export async function POST(request: NextRequest) {
notes,
sub_spans,
color,
source,
model_prediction,
} = body;
if (!chart_id || start_time === undefined || end_time === undefined || !label) {
@ -71,6 +73,8 @@ export async function POST(request: NextRequest) {
notes: notes || null,
sub_spans: sub_spans ? JSON.stringify(sub_spans) : null,
color: color || '#2196F3',
source: source || 'human', // 'human', 'model', or 'human_correction'
model_prediction: model_prediction ? JSON.stringify(model_prediction) : null,
created_at: Math.floor(Date.now() / 1000),
})
.returning();

View file

@ -61,5 +61,7 @@ export const spanAnnotations = sqliteTable('span_annotations', {
notes: text('notes'), // free-text, nullable
sub_spans: text('sub_spans'), // JSON array of sub-span objects, nullable
color: text('color').notNull().default('#2196F3'), // hex color
source: text('source').notNull().default('human'), // 'human'|'model'|'human_correction'
model_prediction: text('model_prediction'), // JSON metadata when confirming/correcting predictions
created_at: integer('created_at').notNull(),
});