use api in front

This commit is contained in:
2025-10-29 02:57:10 +01:00
parent 07dd1bc0ff
commit 52723b200a
14 changed files with 900 additions and 263 deletions
+314
View File
@@ -0,0 +1,314 @@
import type {
SteamCollectionItem,
Submission,
PuzzleResponse,
SubmissionFile
} from '../types'
// API Configuration
const API_BASE_URL = '/api'
// API Response Types
interface ApiResponse<T> {
data?: T
error?: string
status: number
}
interface PaginatedResponse<T> {
items: T[]
count: number
}
interface SubmissionStats {
total_submissions: number
total_responses: number
needs_validation: number
validated_submissions: number
validation_rate: number
}
// API Service Class
export class ApiService {
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
})
const data = await response.json()
if (!response.ok) {
return {
error: data.detail || `HTTP ${response.status}`,
status: response.status
}
}
return {
data,
status: response.status
}
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Network error',
status: 0
}
}
}
private async uploadRequest<T>(
endpoint: string,
formData: FormData
): Promise<ApiResponse<T>> {
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
body: formData,
})
const data = await response.json()
if (!response.ok) {
return {
error: data.detail || `HTTP ${response.status}`,
status: response.status
}
}
return {
data,
status: response.status
}
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Network error',
status: 0
}
}
}
// Puzzle endpoints
async getPuzzles(): Promise<ApiResponse<SteamCollectionItem[]>> {
return this.request<SteamCollectionItem[]>('/submissions/puzzles')
}
// Submission endpoints
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
return this.request<PaginatedResponse<Submission>>(
`/submissions/submissions?limit=${limit}&offset=${offset}`
)
}
async getSubmission(id: string): Promise<ApiResponse<Submission>> {
return this.request<Submission>(`/submissions/submissions/${id}`)
}
async createSubmission(
submissionData: {
notes?: string
responses: Array<{
puzzle_id: number
puzzle_name: string
cost?: string
cycles?: string
area?: string
needs_manual_validation?: boolean
ocr_confidence_score?: number
}>
},
files: File[]
): Promise<ApiResponse<Submission>> {
const formData = new FormData()
// Add JSON data
formData.append('data', JSON.stringify(submissionData))
// Add files
files.forEach((file, index) => {
formData.append('files', file)
})
return this.uploadRequest<Submission>('/submissions/submissions', formData)
}
// Admin endpoints (require staff permissions)
async validateResponse(
responseId: number,
validationData: {
validated_cost?: string
validated_cycles?: string
validated_area?: string
}
): Promise<ApiResponse<PuzzleResponse>> {
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate`, {
method: 'PUT',
body: JSON.stringify(validationData),
})
}
async getResponsesNeedingValidation(): Promise<ApiResponse<PuzzleResponse[]>> {
return this.request<PuzzleResponse[]>('/submissions/responses/needs-validation')
}
async validateSubmission(submissionId: string): Promise<ApiResponse<Submission>> {
return this.request<Submission>(`/submissions/submissions/${submissionId}/validate`, {
method: 'POST',
})
}
async deleteSubmission(submissionId: string): Promise<ApiResponse<{ detail: string }>> {
return this.request<{ detail: string }>(`/submissions/submissions/${submissionId}`, {
method: 'DELETE',
})
}
// Statistics endpoint
async getStats(): Promise<ApiResponse<SubmissionStats>> {
return this.request<SubmissionStats>('/submissions/stats')
}
// Health check
async healthCheck(): Promise<ApiResponse<{ status: string; service: string }>> {
return this.request<{ status: string; service: string }>('/health')
}
}
// Singleton instance
export const apiService = new ApiService()
// Helper functions for common operations
export const puzzleHelpers = {
async loadPuzzles(): Promise<SteamCollectionItem[]> {
const response = await apiService.getPuzzles()
if (response.error) {
console.error('Failed to load puzzles:', response.error)
return []
}
return response.data || []
},
findPuzzleByName(puzzles: SteamCollectionItem[], name: string): SteamCollectionItem | null {
if (!name) return null
// Try exact match first
let match = puzzles.find(p =>
p.title.toLowerCase() === name.toLowerCase()
)
if (!match) {
// Try partial match
match = puzzles.find(p =>
p.title.toLowerCase().includes(name.toLowerCase()) ||
name.toLowerCase().includes(p.title.toLowerCase())
)
}
return match || null
}
}
export const submissionHelpers = {
async createFromFiles(
files: SubmissionFile[],
puzzles: SteamCollectionItem[],
notes?: string
): Promise<ApiResponse<Submission>> {
// Group files by detected puzzle
const responsesByPuzzle: Record<string, {
puzzle: SteamCollectionItem | null,
files: SubmissionFile[]
}> = {}
files.forEach(file => {
if (file.ocrData?.puzzle) {
const puzzleName = file.ocrData.puzzle
if (!responsesByPuzzle[puzzleName]) {
responsesByPuzzle[puzzleName] = {
puzzle: puzzleHelpers.findPuzzleByName(puzzles, puzzleName),
files: []
}
}
responsesByPuzzle[puzzleName].files.push(file)
}
})
// Create responses array
const responses = Object.entries(responsesByPuzzle)
.filter(([_, data]) => data.puzzle) // Only include matched puzzles
.map(([puzzleName, data]) => {
// Get OCR data from the first file with complete data
const fileWithOCR = data.files.find(f =>
f.ocrData?.cost || f.ocrData?.cycles || f.ocrData?.area
)
// Check if manual validation is needed
const needsValidation = !fileWithOCR?.ocrData ||
!fileWithOCR.ocrData.cost ||
!fileWithOCR.ocrData.cycles ||
!fileWithOCR.ocrData.area
return {
puzzle_id: data.puzzle!.id,
puzzle_name: puzzleName,
cost: fileWithOCR?.ocrData?.cost,
cycles: fileWithOCR?.ocrData?.cycles,
area: fileWithOCR?.ocrData?.area,
needs_manual_validation: needsValidation,
ocr_confidence_score: needsValidation ? 0.5 : 0.9 // Rough estimate
}
})
if (responses.length === 0) {
return {
error: 'No valid puzzle responses found',
status: 400
}
}
// Extract actual File objects for upload
const fileObjects = files.map(f => f.file)
return apiService.createSubmission({ notes, responses }, fileObjects)
},
async loadSubmissions(limit = 20, offset = 0): Promise<Submission[]> {
const response = await apiService.getSubmissions(limit, offset)
if (response.error) {
console.error('Failed to load submissions:', response.error)
return []
}
return response.data?.items || []
}
}
// Error handling utilities
export const errorHelpers = {
getErrorMessage(error: unknown): string {
if (typeof error === 'string') return error
if (error instanceof Error) return error.message
if (typeof error === 'object' && error !== null && 'detail' in error) {
return String((error as any).detail)
}
return 'An unknown error occurred'
},
isNetworkError(error: unknown): boolean {
return typeof error === 'string' && error.includes('Network')
},
isValidationError(status: number): boolean {
return status === 400
},
isAuthError(status: number): boolean {
return status === 401 || status === 403
}
}
+69 -29
View File
@@ -16,6 +16,7 @@ export interface OCRRegion {
export class OpusMagnumOCRService {
private worker: Tesseract.Worker | null = null;
private availablePuzzleNames: string[] = [];
// Regions based on main.py coordinates (adjusted for web usage)
private readonly regions: Record<string, OCRRegion> = {
@@ -35,6 +36,13 @@ export class OpusMagnumOCRService {
});
}
/**
* Set the list of available puzzle names for better OCR matching
*/
setAvailablePuzzleNames(puzzleNames: string[]): void {
this.availablePuzzleNames = puzzleNames;
}
async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
if (!this.worker) {
await this.initialize();
@@ -123,8 +131,8 @@ export class OpusMagnumOCRService {
// Ensure only digits remain
cleanText = cleanText.replace(/[^0-9]/g, '');
} else if (key === 'puzzle') {
// Post-process puzzle names
cleanText = this.processPuzzleName(cleanText);
// Post-process puzzle names with fuzzy matching
cleanText = this.findBestPuzzleMatch(cleanText);
}
results[key as keyof OpusMagnumData] = cleanText;
@@ -171,39 +179,71 @@ export class OpusMagnumOCRService {
}
}
private processPuzzleName(rawText: string): string {
let processed = rawText.trim();
/**
* Calculate Levenshtein distance between two strings
*/
private levenshteinDistance(str1: string, str2: string): number {
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
// If no dash is present but we have digits, try to insert one
if (!processed.includes('-') && /\d/.test(processed)) {
// Common pattern: "P4141" should become "P41-41"
// Look for patterns like P[digits][digits] where the last part might be a separate number
const match = processed.match(/^([A-Z]+\d+)(\d{1,3})$/);
if (match) {
processed = `${match[1]}-${match[2]}`;
}
// Handle cases like "4141" -> "41-41" (missing P prefix)
else if (/^\d{3,4}$/.test(processed)) {
const mid = Math.floor(processed.length / 2);
processed = `P${processed.slice(0, mid)}-${processed.slice(mid)}`;
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
for (let j = 1; j <= str2.length; j++) {
for (let i = 1; i <= str1.length; i++) {
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[j][i] = Math.min(
matrix[j][i - 1] + 1, // deletion
matrix[j - 1][i] + 1, // insertion
matrix[j - 1][i - 1] + indicator // substitution
);
}
}
// Clean up spacing around dashes
processed = processed.replace(/\s*-\s*/g, '-');
// Ensure proper spacing
processed = processed.replace(/([A-Z])(\d)/g, '$1$2');
processed = processed.replace(/(\d)([A-Z])/g, '$1 $2');
// Add P prefix if missing and starts with digits
if (/^\d/.test(processed) && !processed.startsWith('P')) {
processed = 'P' + processed;
}
return processed;
return matrix[str2.length][str1.length];
}
/**
* Find the best matching puzzle name from available options
*/
private findBestPuzzleMatch(ocrText: string): string {
if (!this.availablePuzzleNames.length) {
return ocrText.trim();
}
const cleanedOcr = ocrText.trim();
// First try exact match (case insensitive)
const exactMatch = this.availablePuzzleNames.find(
name => name.toLowerCase() === cleanedOcr.toLowerCase()
);
if (exactMatch) return exactMatch;
// Then try fuzzy matching
let bestMatch = cleanedOcr;
let bestScore = Infinity;
for (const puzzleName of this.availablePuzzleNames) {
// Calculate similarity scores
const distance = this.levenshteinDistance(
cleanedOcr.toLowerCase(),
puzzleName.toLowerCase()
);
// Normalize by length to get a similarity ratio
const maxLength = Math.max(cleanedOcr.length, puzzleName.length);
const similarity = 1 - (distance / maxLength);
// Consider it a good match if similarity is above 70%
if (similarity > 0.7 && distance < bestScore) {
bestScore = distance;
bestMatch = puzzleName;
}
}
return bestMatch;
}
async terminate(): Promise<void> {
if (this.worker) {
await this.worker.terminate();