photos/src/app/api/classify/batch/route.ts
Michael Mainguy a204168c00 Add automatic API documentation system with OpenAPI 3.0 spec
## Features Added:
- **Automatic Documentation Generation**: Uses next-swagger-doc to scan API routes
- **Interactive Swagger UI**: Try-it-out functionality for testing endpoints
- **OpenAPI 3.0 Specification**: Industry-standard API documentation format
- **Comprehensive Schemas**: Type definitions for all request/response objects

## New Documentation System:
- `/docs` - Interactive Swagger UI documentation page
- `/api/docs` - OpenAPI specification JSON endpoint
- `src/lib/swagger.ts` - Documentation configuration and schemas
- Complete JSDoc examples for batch classification endpoint

## Documentation Features:
- Real-time API testing from documentation interface
- Detailed request/response examples and schemas
- Parameter validation and error response documentation
- Organized by tags (Classification, Captioning, Tags, etc.)
- Dark/light mode support with loading states

## AI Roadmap & Guides:
- `AIROADMAP.md` - Comprehensive roadmap for future AI enhancements
- `API_DOCUMENTATION.md` - Complete guide for maintaining documentation

## Benefits:
- Documentation stays automatically synchronized with code changes
- No separate docs to maintain - generated from JSDoc comments
- Professional API documentation for integration and development
- Export capabilities for Postman, Insomnia, and other tools

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 17:21:53 -05:00

378 lines
13 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { photoService } from '@/lib/photo-service'
import { imageClassifier } from '@/lib/image-classifier'
import { existsSync } from 'fs'
interface BatchClassifyRequest {
directory?: string
limit?: number
offset?: number
minConfidence?: number
onlyUntagged?: boolean
dryRun?: boolean
customLabels?: string[]
maxResults?: number
comprehensive?: boolean // Use both ViT + CLIP for more tags
categories?: {
general?: string[]
time?: string[]
weather?: string[]
subjects?: string[]
locations?: string[]
style?: string[]
seasons?: string[]
}
}
/**
* @swagger
* /api/classify/batch:
* post:
* summary: Batch classify photos using AI models
* description: Process multiple photos with AI classification using ViT for objects and optionally CLIP for artistic/style concepts. Supports comprehensive mode for maximum tag diversity.
* tags: [Classification]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/BatchClassifyRequest'
* examples:
* basic:
* summary: Basic batch classification
* value:
* limit: 10
* minConfidence: 0.3
* onlyUntagged: true
* comprehensive:
* summary: Comprehensive mode with dual models
* value:
* limit: 5
* comprehensive: true
* minConfidence: 0.05
* maxResults: 25
* onlyUntagged: true
* responses:
* 200:
* description: Classification results with summary statistics
* content:
* application/json:
* schema:
* type: object
* properties:
* summary:
* type: object
* properties:
* processed: { type: 'integer', description: 'Number of photos processed' }
* successful: { type: 'integer', description: 'Number of successful classifications' }
* failed: { type: 'integer', description: 'Number of failed classifications' }
* totalTagsAdded: { type: 'integer', description: 'Total tags added to database' }
* config: { $ref: '#/components/schemas/ClassifierConfig' }
* results:
* type: array
* items:
* type: object
* properties:
* photoId: { type: 'string' }
* filename: { type: 'string' }
* tagsAdded: { type: 'integer' }
* topTags:
* type: array
* items:
* $ref: '#/components/schemas/ClassificationResult'
* hasMore: { type: 'boolean', description: 'Whether more photos are available to process' }
* 400:
* description: Invalid request parameters
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error during classification
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export async function POST(request: NextRequest) {
try {
const body: BatchClassifyRequest = await request.json()
const {
directory,
limit = 50, // Process 50 photos at a time to avoid memory issues
offset = 0,
minConfidence = 0.3,
onlyUntagged = false,
dryRun = false,
customLabels,
maxResults,
comprehensive = false,
categories
} = body
// Build classifier configuration for this batch
const classifierConfig = {
minConfidence: comprehensive ? 0.05 : minConfidence, // Lower threshold for comprehensive mode
maxResults: comprehensive ? 25 : (maxResults || 10), // More results for comprehensive mode
customLabels,
categories
}
// Initialize classifier(s)
if (comprehensive) {
console.log('[BATCH CLASSIFY] Initializing comprehensive classifiers (ViT + CLIP)...')
await Promise.all([
imageClassifier.initialize(),
imageClassifier.initializeZeroShot()
])
} else {
console.log('[BATCH CLASSIFY] Initializing image classifier...')
await imageClassifier.initialize()
}
// Get photos to process
console.log('[BATCH CLASSIFY] Getting photos to classify...')
let photos = photoService.getPhotos({
directory,
limit,
offset,
sortBy: 'created_at',
sortOrder: 'ASC'
})
// Filter to only untagged photos if requested
if (onlyUntagged) {
photos = photos.filter(photo => {
const tags = photoService.getPhotoTags(photo.id)
return tags.length === 0
})
}
console.log(`[BATCH CLASSIFY] Processing ${photos.length} photos...`)
const results = []
let processed = 0
let successful = 0
let failed = 0
let totalTagsAdded = 0
for (const photo of photos) {
try {
processed++
console.log(`[BATCH CLASSIFY] Processing ${processed}/${photos.length}: ${photo.filename}`)
// Try to get cached thumbnail first, fall back to file path
let imageSource: string | Buffer = photo.filepath
let usingThumbnail = false
const thumbnailBlob = photoService.getCachedThumbnail(photo.id, 200) // Use 200px thumbnail
if (thumbnailBlob) {
console.log(`[BATCH CLASSIFY] Using cached thumbnail: ${photo.filename}`)
imageSource = thumbnailBlob
usingThumbnail = true
} else {
// Check if file exists for fallback
if (!existsSync(photo.filepath)) {
console.warn(`[BATCH CLASSIFY] File not found and no thumbnail: ${photo.filepath}`)
failed++
continue
}
console.log(`[BATCH CLASSIFY] Using original file (no thumbnail): ${photo.filename}`)
}
// Get existing tags if any
const existingTags = photoService.getPhotoTags(photo.id)
// Classify the image with fallback handling
let classifications
try {
if (comprehensive) {
const comprehensiveResult = await imageClassifier.classifyImageComprehensive(imageSource, classifierConfig)
classifications = comprehensiveResult.combinedResults
console.log(`[BATCH CLASSIFY] Comprehensive: ${comprehensiveResult.objectClassification.length} object + ${comprehensiveResult.styleClassification.length} style tags`)
} else {
classifications = await imageClassifier.classifyImage(imageSource, customLabels, classifierConfig)
}
} catch (error) {
if (usingThumbnail && existsSync(photo.filepath)) {
console.warn(`[BATCH CLASSIFY] Thumbnail failed for ${photo.filename}, falling back to original file:`, error)
try {
// Fallback to original file
if (comprehensive) {
const comprehensiveResult = await imageClassifier.classifyImageComprehensive(photo.filepath, classifierConfig)
classifications = comprehensiveResult.combinedResults
} else {
classifications = await imageClassifier.classifyImage(photo.filepath, customLabels, classifierConfig)
}
console.log(`[BATCH CLASSIFY] Fallback to original file successful: ${photo.filename}`)
} catch (fallbackError) {
console.error(`[BATCH CLASSIFY] Both thumbnail and original file failed for ${photo.filename}:`, fallbackError)
throw fallbackError
}
} else {
throw error
}
}
// Filter by confidence and exclude existing tags
// Note: classifications are already filtered by confidence in classifyImage
const existingTagNames = existingTags.map(tag => tag.name.toLowerCase())
const newTags = classifications
.filter(result => !existingTagNames.includes(result.label.toLowerCase()))
.map(result => ({ name: result.label, confidence: result.score }))
if (dryRun) {
// Just log what would be added
results.push({
photoId: photo.id,
filename: photo.filename,
existingTags: existingTags.length,
newClassifications: newTags,
wouldAdd: newTags.length
})
} else {
// Actually add the tags
const addedCount = photoService.addPhotoTags(photo.id, newTags)
totalTagsAdded += addedCount
results.push({
photoId: photo.id,
filename: photo.filename,
existingTags: existingTags.length,
classificationsFound: classifications.length,
newTagsAdded: addedCount,
topTags: newTags.slice(0, 5) // Show top 5 tags for logging
})
}
successful++
// Log progress every 10 photos
if (processed % 10 === 0) {
console.log(`[BATCH CLASSIFY] Progress: ${processed}/${photos.length} (${successful} successful, ${failed} failed)`)
}
} catch (error) {
console.error(`[BATCH CLASSIFY] Error processing ${photo.filename}:`, error)
failed++
results.push({
photoId: photo.id,
filename: photo.filename,
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
console.log(`[BATCH CLASSIFY] Completed: ${successful} successful, ${failed} failed, ${totalTagsAdded} total tags added`)
return NextResponse.json({
summary: {
processed,
successful,
failed,
totalTagsAdded,
config: classifierConfig,
dryRun,
onlyUntagged
},
results: dryRun ? results : results.slice(0, 10), // Limit results in response for performance
hasMore: photos.length === limit // Indicate if there might be more photos to process
})
} catch (error) {
console.error('[BATCH CLASSIFY] Error:', error)
return NextResponse.json(
{ error: 'Failed to batch classify images' },
{ status: 500 }
)
}
}
/**
* @swagger
* /api/classify/batch:
* get:
* summary: Get classification status and statistics
* description: Returns statistics about photo classification status including total photos, tagged/untagged counts, and most common tags.
* tags: [Classification]
* parameters:
* - in: query
* name: directory
* schema:
* type: string
* description: Optional directory to filter statistics
* example: /Users/photos/2024
* responses:
* 200:
* description: Classification status and statistics
* content:
* application/json:
* schema:
* type: object
* properties:
* total: { type: 'integer', description: 'Total number of photos' }
* tagged: { type: 'integer', description: 'Number of photos with tags' }
* untagged: { type: 'integer', description: 'Number of photos without tags' }
* taggedPercentage: { type: 'integer', description: 'Percentage of photos with tags' }
* topTags:
* type: array
* items:
* type: object
* properties:
* name: { type: 'string', description: 'Tag name' }
* count: { type: 'integer', description: 'Number of photos with this tag' }
* classifierReady: { type: 'boolean', description: 'Whether AI classifier is ready' }
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const directory = searchParams.get('directory') || undefined
// Get total photos
const allPhotos = photoService.getPhotos({ directory })
// Get photos with tags
const photosWithTags = allPhotos.filter(photo => {
const tags = photoService.getPhotoTags(photo.id)
return tags.length > 0
})
// Get most common tags
const tagCounts: { [tagName: string]: number } = {}
allPhotos.forEach(photo => {
const tags = photoService.getPhotoTags(photo.id)
tags.forEach(tag => {
tagCounts[tag.name] = (tagCounts[tag.name] || 0) + 1
})
})
const topTags = Object.entries(tagCounts)
.sort(([,a], [,b]) => b - a)
.slice(0, 20)
.map(([name, count]) => ({ name, count }))
return NextResponse.json({
total: allPhotos.length,
tagged: photosWithTags.length,
untagged: allPhotos.length - photosWithTags.length,
taggedPercentage: Math.round((photosWithTags.length / allPhotos.length) * 100),
topTags,
classifierReady: imageClassifier.isReady()
})
} catch (error) {
console.error('[BATCH CLASSIFY STATUS] Error:', error)
return NextResponse.json(
{ error: 'Failed to get classification status' },
{ status: 500 }
)
}
}