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:
parent
205021e810
commit
bb1b6d573f
5 changed files with 147 additions and 4 deletions
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
126
src/app/api/span-annotations/export/route.ts
Normal file
126
src/app/api/span-annotations/export/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue