From bb1b6d573f1233de046a540e37a9ef26e03f3825 Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Sun, 15 Feb 2026 14:35:31 +0100 Subject: [PATCH] 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 --- ...d_model_prediction_to_span_annotations.sql | 11 ++ openspec/changes/candle-backend/tasks.md | 8 +- src/app/api/span-annotations/export/route.ts | 126 ++++++++++++++++++ src/app/api/span-annotations/route.ts | 4 + src/lib/db/schema.ts | 2 + 5 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 drizzle/0005_add_source_and_model_prediction_to_span_annotations.sql create mode 100644 src/app/api/span-annotations/export/route.ts diff --git a/drizzle/0005_add_source_and_model_prediction_to_span_annotations.sql b/drizzle/0005_add_source_and_model_prediction_to_span_annotations.sql new file mode 100644 index 0000000..40cc47a --- /dev/null +++ b/drizzle/0005_add_source_and_model_prediction_to_span_annotations.sql @@ -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; diff --git a/openspec/changes/candle-backend/tasks.md b/openspec/changes/candle-backend/tasks.md index 5b04728..695af23 100644 --- a/openspec/changes/candle-backend/tasks.md +++ b/openspec/changes/candle-backend/tasks.md @@ -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 diff --git a/src/app/api/span-annotations/export/route.ts b/src/app/api/span-annotations/export/route.ts new file mode 100644 index 0000000..d0f5ef4 --- /dev/null +++ b/src/app/api/span-annotations/export/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/span-annotations/route.ts b/src/app/api/span-annotations/route.ts index dac3602..9bd763e 100644 --- a/src/app/api/span-annotations/route.ts +++ b/src/app/api/span-annotations/route.ts @@ -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(); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index c69edbb..34f8f5a 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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(), });