better re-processing OCR for admin

This commit is contained in:
Loïc Gremaud 2025-11-23 22:33:23 +01:00
parent 8f88548a59
commit 9ee45463a8
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
12 changed files with 411 additions and 320 deletions

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed, defineProps } from "vue";
import { ref, onMounted, computed } from "vue";
import PuzzleCard from "@/components/PuzzleCard.vue";
import SubmissionForm from "@/components/SubmissionForm.vue";
import AdminPanel from "@/components/AdminPanel.vue";
@ -8,6 +8,7 @@ import { usePuzzlesStore } from "@/stores/puzzles";
import { useSubmissionsStore } from "@/stores/submissions";
import type { PuzzleResponse, UserInfo } from "@/types";
import { useCountdown } from "@vueuse/core";
import { storeToRefs } from "pinia";
const props = defineProps<{
collectionTitle: string;
@ -15,10 +16,13 @@ const props = defineProps<{
collectionDescription: string;
}>();
// Pinia stores
const puzzlesStore = usePuzzlesStore();
const submissionsStore = useSubmissionsStore();
const { submissions, isSubmissionModalOpen } = storeToRefs(submissionsStore);
const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
submissionsStore;
// Local state
const userInfo = ref<UserInfo | null>(null);
const isLoading = ref(true);
@ -32,7 +36,7 @@ const isSuperuser = computed(() => {
// Computed property to get responses grouped by puzzle
const responsesByPuzzle = computed(() => {
const grouped: Record<number, PuzzleResponse[]> = {};
submissionsStore.submissions.forEach((submission) => {
submissions.value.forEach((submission) => {
submission.responses.forEach((response) => {
// Handle both number and object types for puzzle field
if (!grouped[response.puzzle]) {
@ -68,8 +72,8 @@ async function initialize() {
// Load existing submissions using store
console.log("Loading submissions...");
await submissionsStore.loadSubmissions();
console.log("Submissions loaded:", submissionsStore.submissions.length);
await loadSubmissions();
console.log("Submissions loaded:", submissions.value.length);
console.log("Data load complete!");
} catch (err) {
@ -95,52 +99,6 @@ onMounted(async () => {
await initialize();
});
const handleSubmission = async (submissionData: {
files: any[];
notes?: string;
manualValidationRequested?: boolean;
}) => {
try {
isLoading.value = true;
error.value = "";
// Create submission via store
const submission = await submissionsStore.createSubmission(
submissionData.files,
submissionData.notes,
submissionData.manualValidationRequested,
);
// Show success message
if (submission) {
const puzzleNames = submission.responses
.map((r) => r.puzzle_name)
.join(", ");
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`);
} else {
alert("Submission created successfully!");
}
// Close modal
submissionsStore.closeSubmissionModal();
} catch (err) {
const errorMessage = errorHelpers.getErrorMessage(err);
error.value = errorMessage;
alert(`Submission failed: ${errorMessage}`);
console.error("Submission error:", err);
} finally {
isLoading.value = false;
}
};
const openSubmissionModal = () => {
submissionsStore.openSubmissionModal();
};
const closeSubmissionModal = () => {
submissionsStore.closeSubmissionModal();
};
// Function to match puzzle name from OCR to actual puzzle
const findPuzzleByName = (ocrPuzzleName: string) => {
return puzzlesStore.findPuzzleByName(ocrPuzzleName);
@ -175,7 +133,10 @@ const reloadPage = () => {
</div>
<div v-else class="text-sm text-base-content/70">Not logged in</div>
<div class="flex flex-col items-end gap-2">
<a href="/admin" class="btn btn-xs btn-warning"> Admin panel </a>
<a href="/api/docs" class="btn btn-xs">API docs</a>
</div>
<div class="flex flex-col items-end gap-2">
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
</div>
</div>
</div>
@ -263,7 +224,7 @@ const reloadPage = () => {
</div>
<!-- Submission Modal -->
<div v-if="submissionsStore.isSubmissionModalOpen" class="modal modal-open">
<div v-if="isSubmissionModalOpen" class="modal modal-open">
<div class="modal-box max-w-6xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">Submit Solution</h3>
@ -278,7 +239,6 @@ const reloadPage = () => {
<SubmissionForm
:puzzles="puzzlesStore.puzzles"
:find-puzzle-by-name="findPuzzleByName"
@submit="handleSubmission"
/>
</div>
<div class="modal-backdrop" @click="closeSubmissionModal"></div>

View File

@ -112,11 +112,19 @@
<td>
<button
@click="openValidationModal(response)"
class="btn btn-sm btn-primary"
class="btn btn-sm btn-primary mr-2"
>
<i class="mdi mdi-check-circle mr-1"></i>
Validate
</button>
<button
v-if="response.id"
@click="autoValidation(response.id)"
class="btn btn-sm btn-warning"
>
<i class="mdi mdi-check-circle mr-1"></i>
Auto Validation
</button>
</td>
</tr>
</tbody>
@ -143,6 +151,10 @@
<img :src="file.file_url" />
</div>
<div class="mockup-code w-full">
<pre><code>{{ validationModal}}</code></pre>
</div>
<div v-if="validationModal.response" class="space-y-4">
<div class="alert alert-info">
<i class="mdi mdi-information-outline"></i>
@ -339,6 +351,21 @@ const closeValidationModal = () => {
};
};
const autoValidation = async (id: number) => {
const { data } = await apiService.autoValidateResponses(id);
console.log(data);
if (data && !data.needs_manual_validation) {
// Remove from validation list
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
(r) => r.id !== id,
);
console.log(stats.value);
stats.value.needs_validation -= 1;
console.log(stats.value);
}
};
const submitValidation = async () => {
if (!validationModal.value.response?.id) return;

View File

@ -22,7 +22,7 @@
@change="handleFileSelect"
/>
<div v-if="files.length === 0" class="space-y-4">
<div v-if="submissionFiles.length === 0" class="space-y-4">
<div
class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center"
>
@ -46,7 +46,7 @@
<div v-else class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4">
<div
v-for="(file, index) in files"
v-for="(file, index) in submissionFiles"
:key="index"
class="relative group"
>
@ -105,7 +105,7 @@
</span>
</div>
<button
@click="retryOCR(file)"
@click="processOCR(file)"
class="btn btn-xs btn-ghost"
title="Retry OCR"
>
@ -226,47 +226,16 @@
import { ref, watch, nextTick } from "vue";
import { ocrService } from "@/services/ocrService";
import { usePuzzlesStore } from "@/stores/puzzles";
import type { SubmissionFile, SteamCollectionItem } from "@/types";
interface Props {
modelValue: SubmissionFile[];
puzzles?: SteamCollectionItem[];
}
interface Emits {
"update:modelValue": [files: SubmissionFile[]];
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
import { useUploadsStore } from "@/stores/uploads";
import type { SubmissionFile } from "@/types";
// Pinia store
const puzzlesStore = usePuzzlesStore();
const { submissionFiles, processOCR } = useUploadsStore();
const fileInput = ref<HTMLInputElement>();
const isDragOver = ref(false);
const error = ref("");
const files = ref<SubmissionFile[]>([]);
const isProcessingOCR = ref(false);
// Watch for external changes to modelValue
watch(
() => props.modelValue,
(newFiles) => {
files.value = newFiles;
},
{ immediate: true },
);
// Watch for internal changes and emit
watch(
files,
(newFiles) => {
emit("update:modelValue", newFiles);
},
{ deep: true },
);
// Watch for puzzle changes and update OCR service
watch(
@ -317,7 +286,7 @@ const processFiles = async (newFiles: File[]) => {
ocrData: undefined,
};
files.value.push(submissionFile);
submissionFiles.push(submissionFile);
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
if (isOpusMagnumImage(file)) {
@ -357,7 +326,7 @@ const createPreview = (file: File): Promise<string> => {
};
const removeFile = (index: number) => {
files.value.splice(index, 1);
submissionFiles.splice(index, 1);
};
const formatFileSize = (bytes: number): string => {
@ -375,58 +344,6 @@ const isOpusMagnumImage = (file: File): boolean => {
return file.type.startsWith("image/") && file.size > 50000; // > 50KB likely screenshot
};
const processOCR = async (submissionFile: SubmissionFile) => {
while (isProcessingOCR.value) {
console.log("OCR is already processing, waiting 500ms...");
await new Promise((res) => setTimeout(res, 500));
}
isProcessingOCR.value = true;
// Find the file in the reactive array to ensure proper reactivity
const fileIndex = files.value.findIndex(
(f) => f.file === submissionFile.file,
);
if (fileIndex === -1) return;
// Update the reactive array directly
files.value[fileIndex].ocrProcessing = true;
files.value[fileIndex].ocrError = undefined;
files.value[fileIndex].ocrData = undefined;
try {
console.log("Starting OCR processing for:", submissionFile.file.name);
await ocrService.initialize();
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file);
console.log("OCR completed:", ocrData);
// Force reactivity update
await nextTick();
files.value[fileIndex].ocrData = ocrData;
// Check if puzzle confidence is below 80% and needs manual selection
if (ocrData.confidence.puzzle < 0.8) {
files.value[fileIndex].needsManualPuzzleSelection = true;
console.log(
`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`,
);
} else {
files.value[fileIndex].needsManualPuzzleSelection = false;
}
await nextTick();
} catch (error) {
console.error("OCR processing failed:", error);
files.value[fileIndex].ocrError = "Failed to extract puzzle data";
} finally {
files.value[fileIndex].ocrProcessing = false;
}
isProcessingOCR.value = false;
};
const retryOCR = (submissionFile: SubmissionFile) => {
processOCR(submissionFile);
};
const getConfidenceBadgeClass = (confidence: number): string => {
if (confidence >= 0.8) return "badge-success";
if (confidence >= 0.6) return "badge-warning";
@ -435,16 +352,16 @@ const getConfidenceBadgeClass = (confidence: number): string => {
const onManualPuzzleSelection = (submissionFile: SubmissionFile) => {
// Find the file in the reactive array
const fileIndex = files.value.findIndex(
const fileIndex = submissionFiles.findIndex(
(f) => f.file === submissionFile.file,
);
if (fileIndex === -1) return;
// Clear the manual selection requirement once user has selected
if (files.value[fileIndex].manualPuzzleSelection) {
files.value[fileIndex].needsManualPuzzleSelection = false;
if (submissionFiles[fileIndex].manualPuzzleSelection) {
submissionFiles[fileIndex].needsManualPuzzleSelection = false;
console.log(
`Manual puzzle selection: ${submissionFile.file.name} -> ${files.value[fileIndex].manualPuzzleSelection}`,
`Manual puzzle selection: ${submissionFile.file.name} -> ${submissionFiles[fileIndex].manualPuzzleSelection}`,
);
}
};

View File

@ -1,21 +1,35 @@
<template>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300">
<div
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300"
>
<div class="card-body">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="card-title text-lg font-bold">{{ puzzle.title }}</h3>
<p class="text-sm text-base-content/70 mb-2">by {{ puzzle.author_name }}</p>
<p class="text-sm text-base-content/70 mb-2">
by {{ puzzle.author_name }}
</p>
<div class="flex items-center gap-2 mb-3">
<div class="badge badge-primary badge-sm">{{ puzzle.steam_item_id }}</div>
<div class="badge badge-ghost badge-sm">Order: {{ puzzle.order_index + 1 }}</div>
<div class="badge badge-primary badge-sm">
{{ puzzle.steam_item_id }}
</div>
<div class="badge badge-ghost badge-sm">
Order: {{ puzzle.order_index + 1 }}
</div>
</div>
<p v-if="puzzle.description" class="text-sm text-base-content/80 mb-4 line-clamp-2">
<p
v-if="puzzle.description"
class="text-sm text-base-content/80 mb-4"
>
{{ puzzle.description }}
</p>
<div v-if="puzzle.tags && puzzle.tags.length > 0" class="flex flex-wrap gap-1 mb-4">
<div
v-if="puzzle.tags && puzzle.tags.length > 0"
class="flex flex-wrap gap-1 mb-4"
>
<span
v-for="tag in puzzle.tags.slice(0, 3)"
:key="tag"
@ -23,7 +37,10 @@
>
{{ tag }}
</span>
<span v-if="puzzle.tags.length > 3" class="badge badge-outline badge-xs">
<span
v-if="puzzle.tags.length > 3"
class="badge badge-outline badge-xs"
>
+{{ puzzle.tags.length - 3 }} more
</span>
</div>
@ -45,7 +62,9 @@
<!-- Responses Table -->
<div v-if="responses && responses.length > 0" class="mt-6">
<div class="divider">
<span class="text-sm font-medium">Solutions ({{ responses.length }})</span>
<span class="text-sm font-medium"
>Solutions ({{ responses.length }})</span
>
</div>
<div>
@ -59,32 +78,59 @@
</tr>
</thead>
<tbody>
<tr v-for="response in responses" :key="response.id" class="hover">
<tr
v-for="response in responses"
:key="response.id"
class="hover"
>
<td>
<span v-if="response.final_cost || response.cost" class="badge badge-success badge-xs">
<span
v-if="response.final_cost || response.cost"
class="badge badge-success badge-xs"
>
{{ response.final_cost || response.cost }}
</span>
<span v-else class="text-base-content/50">-</span>
</td>
<td>
<span v-if="response.final_cycles || response.cycles" class="badge badge-info badge-xs">
<span
v-if="response.final_cycles || response.cycles"
class="badge badge-info badge-xs"
>
{{ response.final_cycles || response.cycles }}
</span>
<span v-else class="text-base-content/50">-</span>
</td>
<td>
<span v-if="response.final_area || response.area" class="badge badge-warning badge-xs">
<span
v-if="response.final_area || response.area"
class="badge badge-warning badge-xs"
>
{{ response.final_area || response.area }}
</span>
<span v-else class="text-base-content/50">-</span>
</td>
<td>
<div class="flex items-center gap-1">
<span class="badge badge-ghost badge-xs">{{ response.files?.length || 0 }}</span>
<div v-if="response.files?.length" class="tooltip" :data-tip="response.files.map(f => f.original_filename || f.file?.name).join(', ')">
<span class="badge badge-ghost badge-xs">{{
response.files?.length || 0
}}</span>
<div
v-if="response.files?.length"
class="tooltip"
:data-tip="
response.files
.map((f) => f.original_filename || f.file?.name)
.join(', ')
"
>
<i class="mdi mdi-information-outline text-xs"></i>
</div>
<div v-if="response.needs_manual_validation" class="tooltip" data-tip="Needs manual validation">
<div
v-if="response.needs_manual_validation"
class="tooltip"
data-tip="Needs manual validation"
>
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
</div>
</div>
@ -96,33 +142,31 @@
</div>
<!-- No responses state -->
<div v-else class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg">
<div
v-else
class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg hover:border-primary transition-colors duration-300 cursor-pointer"
@click="openSubmissionModal"
>
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
<p class="text-xs text-base-content/40">Upload solutions using the submit button</p>
<p class="text-xs text-base-content/40">
Upload solutions using the submit button
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { SteamCollectionItem, PuzzleResponse } from '@/types'
import type { SteamCollectionItem, PuzzleResponse } from "@/types";
import { useSubmissionsStore } from "@/stores/submissions";
interface Props {
puzzle: SteamCollectionItem
responses?: PuzzleResponse[]
puzzle: SteamCollectionItem;
responses?: PuzzleResponse[];
}
defineProps<Props>()
defineProps<Props>();
// Utility functions removed - not used in template
const { openSubmissionModal } = useSubmissionsStore();
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@ -33,21 +33,29 @@
</div>
<!-- File Upload -->
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
<FileUpload />
<!-- Manual Selection Warning -->
<div
v-if="filesNeedingManualSelection.length > 0"
v-if="submissionFilesNeedingManualSelection.length > 0"
class="alert alert-warning"
>
<i class="mdi mdi-alert-circle text-xl"></i>
<div class="flex-1">
<div class="font-bold">Manual Puzzle Selection Required</div>
<div class="text-sm">
{{ filesNeedingManualSelection.length }} file(s) have low OCR
confidence for puzzle names. Please select the correct puzzle for
each file before submitting.
{{ submissionFilesNeedingManualSelection.length }} file(s) have
low OCR confidence for puzzle names. Please select the correct
puzzle for each file before submitting.
</div>
<button
class="btn mt-3 w-full"
@click="processLowConfidenceOCRFiles"
>
<span class="mdi mdi-reload text-2xl"></span>
Retry OCR on low confidence puzzle
</button>
</div>
</div>
@ -74,6 +82,7 @@
type="checkbox"
v-model="manualValidationRequested"
class="checkbox checkbox-primary"
:disabled="hasLowConfidence"
/>
<div class="flex-1">
<span class="label-text font-medium"
@ -100,8 +109,8 @@
class="loading loading-spinner loading-sm"
></span>
<span v-if="isSubmitting">Submitting...</span>
<span v-else-if="filesNeedingManualSelection.length > 0">
Select Puzzles ({{ filesNeedingManualSelection.length }}
<span v-else-if="submissionFilesNeedingManualSelection.length > 0">
Select Puzzles ({{ submissionFilesNeedingManualSelection.length }}
remaining)
</span>
<span v-else>Submit Solution</span>
@ -116,26 +125,26 @@
import { ref, computed, watch } from "vue";
import FileUpload from "@/components/FileUpload.vue";
import type { SteamCollectionItem, SubmissionFile } from "@/types";
import { useUploadsStore } from "@/stores/uploads";
import { useSubmissionsStore } from "@/stores/submissions";
import { storeToRefs } from "pinia";
interface Props {
puzzles: SteamCollectionItem[];
findPuzzleByName: (name: string) => SteamCollectionItem | null;
}
interface Emits {
submit: [
submissionData: {
files: SubmissionFile[];
notes?: string;
manualValidationRequested?: boolean;
},
];
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const submissionFiles = ref<SubmissionFile[]>([]);
const uploadsStore = useUploadsStore();
const {
submissionFiles,
hasLowConfidence,
submissionFilesNeedingManualSelection,
} = storeToRefs(uploadsStore);
const { clearFiles, processLowConfidenceOCRFiles } = uploadsStore;
const { handleSubmission } = useSubmissionsStore();
const notes = ref("");
const manualValidationRequested = ref(false);
const isSubmitting = ref(false);
@ -151,6 +160,12 @@ const canSubmit = computed(() => {
return hasFiles && !isSubmitting.value && noManualSelectionNeeded;
});
watch(hasLowConfidence, (newValue) => {
if (newValue) {
manualValidationRequested.value = true;
}
});
// Group files by detected puzzle
const responsesByPuzzle = computed(() => {
const grouped: Record<
@ -176,52 +191,22 @@ const responsesByPuzzle = computed(() => {
return grouped;
});
// Count files that need manual puzzle selection
const filesNeedingManualSelection = computed(() => {
return submissionFiles.value.filter(
(file) => file.needsManualPuzzleSelection,
);
});
// Check if any OCR confidence is below 50%
const hasLowConfidence = computed(() => {
return submissionFiles.value.some((file) => {
if (!file.ocrData?.confidence) return false;
return (
file.ocrData.confidence.cost < 0.5 ||
file.ocrData.confidence.cycles < 0.5 ||
file.ocrData.confidence.area < 0.5
);
});
});
// Auto-check manual validation when confidence is low
watch(
hasLowConfidence,
(newValue) => {
console.log(hasLowConfidence.value, newValue);
if (newValue && !manualValidationRequested.value) {
manualValidationRequested.value = true;
}
},
{ immediate: true },
);
const handleSubmit = async () => {
if (!canSubmit.value) return;
isSubmitting.value = true;
try {
// Emit the files and notes for the parent to handle API submission
emit("submit", {
// Emit the files and notes for the store to handle API submission
handleSubmission({
files: submissionFiles.value,
notes: notes.value.trim() || undefined,
manualValidationRequested: manualValidationRequested.value,
manualValidationRequested:
hasLowConfidence.value || manualValidationRequested.value,
});
// Reset form
submissionFiles.value = [];
clearFiles();
notes.value = "";
manualValidationRequested.value = false;
} catch (error) {

View File

@ -235,7 +235,7 @@ export class OpusMagnumOCRService {
cost: confidenceScores.cost || 0,
cycles: confidenceScores.cycles || 0,
area: confidenceScores.area || 0,
overall: overallConfidence
overall: overallConfidence,
}
});
} catch (error) {
@ -314,7 +314,7 @@ export class OpusMagnumOCRService {
// Strategy 2: Substring match (either direction)
const substringMatch = this.availablePuzzleNames.find(
name => name.toLowerCase().includes(cleanedOcr.toLowerCase()) ||
cleanedOcr.toLowerCase().includes(name.toLowerCase())
cleanedOcr.toLowerCase().includes(name.toLowerCase())
);
if (substringMatch) return substringMatch;

View File

@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { Submission, SubmissionFile } from '@/types'
import { submissionHelpers } from '@/services/apiService'
import { usePuzzlesStore } from '@/stores/puzzles'
import { errorHelpers } from "@/services/apiService";
export const useSubmissionsStore = defineStore('submissions', () => {
// State
@ -83,6 +84,49 @@ export const useSubmissionsStore = defineStore('submissions', () => {
await loadSubmissions()
}
const handleSubmission = async (submissionData: {
files: any[];
notes?: string;
manualValidationRequested?: boolean;
}) => {
try {
isLoading.value = true;
error.value = "";
// Create submission via store
const submission = await createSubmission(
submissionData.files,
submissionData.notes,
submissionData.manualValidationRequested,
);
// Show success message
if (submission) {
const puzzleNames = submission.responses
.map((r) => r.puzzle_name)
.join(", ");
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`);
} else {
alert("Submission created successfully!");
}
// Close modal
closeSubmissionModal();
} catch (err) {
const errorMessage = errorHelpers.getErrorMessage(err);
error.value = errorMessage;
alert(`Submission failed: ${errorMessage}`);
console.error("Submission error:", err);
} finally {
isLoading.value = false;
}
}
return {
// State
submissions,
@ -95,6 +139,7 @@ export const useSubmissionsStore = defineStore('submissions', () => {
createSubmission,
openSubmissionModal,
closeSubmissionModal,
refreshSubmissions
refreshSubmissions,
handleSubmission
}
})

View File

@ -0,0 +1,103 @@
import { SubmissionFile } from '@/types'
import { defineStore } from 'pinia'
import { ref, nextTick, computed } from "vue";
import { ocrService } from "@/services/ocrService";
const CONFIDENCE_VALUE = 0.8;
export const useUploadsStore = defineStore('uploads', () => {
const submissionFiles = ref<SubmissionFile[]>([])
const isProcessingOCR = computed(() =>
submissionFiles.value.some(item => item.ocrProcessing)
);
const hasLowConfidence = computed(() =>
submissionFiles.value.some(file => {
return isLowConfidence(file)
})
)
const submissionFilesNeedingManualSelection = computed(() => {
return submissionFiles.value.filter(file => file.needsManualPuzzleSelection)
})
const isLowConfidence = (file: SubmissionFile) => {
if (!file.ocrData?.confidence) return false;
return (
file.ocrData.confidence.cost < CONFIDENCE_VALUE ||
file.ocrData.confidence.cycles < CONFIDENCE_VALUE ||
file.ocrData.confidence.area < CONFIDENCE_VALUE
)
}
const processOCR = async (submissionFile: SubmissionFile) => {
while (isProcessingOCR.value) {
const waitingTimeMs = Math.floor(Math.random() * 400) + 100;
console.log(`OCR is already processing, waiting ${waitingTimeMs}ms...`);
await new Promise((res) => setTimeout(res, waitingTimeMs));
}
const index = submissionFiles.value.indexOf(submissionFile)
// Update the reactive array directly
submissionFiles.value[index].ocrProcessing = true;
submissionFiles.value[index].ocrError = undefined;
submissionFiles.value[index].ocrData = undefined;
try {
console.log("Starting OCR processing for:", submissionFile.file.name);
await ocrService.initialize();
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file);
console.log("OCR completed:", ocrData);
// Force reactivity update
await nextTick();
submissionFiles.value[index].ocrData = ocrData;
// Check if puzzle confidence is below CONFIDENCE_VALUE and needs manual selection
if (ocrData.confidence.puzzle < CONFIDENCE_VALUE) {
submissionFiles.value[index].needsManualPuzzleSelection = true;
console.log(
`Low puzzle confidence (${Math.round(ocrData.confidence.puzzle * 100)}%) for ${submissionFile.file.name}, requiring manual selection`,
);
} else {
submissionFiles.value[index].needsManualPuzzleSelection = false;
}
await nextTick();
} catch (error) {
console.error("OCR processing failed:", error);
submissionFiles.value[index].ocrError = "Failed to extract puzzle data";
} finally {
submissionFiles.value[index].ocrProcessing = false;
}
};
const processLowConfidenceOCRFiles = async () => {
const files = submissionFiles.value.filter(file => isLowConfidence(file))
for (const file of files) {
processOCR(file)
}
}
const clearFiles = () => {
submissionFiles.value = []
}
return {
submissionFiles,
submissionFilesNeedingManualSelection,
processOCR,
processLowConfidenceOCRFiles,
clearFiles,
// computed
isProcessingOCR,
hasLowConfidence,
CONFIDENCE_VALUE
}
})

View File

@ -68,7 +68,7 @@ def create_submission(
try:
with transaction.atomic():
# Check if any confidence score is below 50% to auto-request validation
# Check if any confidence score is below 80% to auto-request validation
auto_request_validation = any(
(
response_data.ocr_confidence_cost is not None

View File

@ -497,20 +497,15 @@ def verify_and_validate_ocr_date_for_submission(file: SubmissionFile):
)
valid_count = 0
if r.cost == ocr_data[1]:
r.validated_cost = r.cost
valid_count += 1
for index, field in enumerate(["cost", "cycles", "area"]):
value = getattr(r, field, -1)
if r.cycles == ocr_data[2]:
r.validated_cycles = r.cycles
valid_count += 1
if value == ocr_data[index + 1]:
setattr(r, f"validated_{field}", value)
valid_count += 1
if r.area == ocr_data[3]:
r.validated_area = r.area
valid_count += 1
else:
setattr(r, field, ocr_data[index + 1])
if valid_count == 3:
r.needs_manual_validation = False
if valid_count:
r.save()
r.needs_manual_validation = valid_count != 3
r.save()

View File

@ -21,6 +21,7 @@ build-backend = "setuptools.build_meta"
[dependency-groups]
dev = [
"django-extensions>=4.1",
"django-stubs>=5.2.7",
"django-stubs-ext>=5.2.7",
"django-types>=0.22.0",

14
uv.lock
View File

@ -185,6 +185,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
]
[[package]]
name = "django-extensions"
version = "4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/b3/ed0f54ed706ec0b54fd251cc0364a249c6cd6c6ec97f04dc34be5e929eac/django_extensions-4.1.tar.gz", hash = "sha256:7b70a4d28e9b840f44694e3f7feb54f55d495f8b3fa6c5c0e5e12bcb2aa3cdeb", size = 283078, upload-time = "2025-04-11T01:15:39.617Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
]
[[package]]
name = "django-ninja"
version = "1.4.5"
@ -504,6 +516,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "django-extensions" },
{ name = "django-stubs" },
{ name = "django-stubs-ext" },
{ name = "django-types" },
@ -530,6 +543,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "django-extensions", specifier = ">=4.1" },
{ name = "django-stubs", specifier = ">=5.2.7" },
{ name = "django-stubs-ext", specifier = ">=5.2.7" },
{ name = "django-types", specifier = ">=0.22.0" },