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

View File

@ -112,11 +112,19 @@
<td> <td>
<button <button
@click="openValidationModal(response)" @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> <i class="mdi mdi-check-circle mr-1"></i>
Validate Validate
</button> </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> </td>
</tr> </tr>
</tbody> </tbody>
@ -143,6 +151,10 @@
<img :src="file.file_url" /> <img :src="file.file_url" />
</div> </div>
<div class="mockup-code w-full">
<pre><code>{{ validationModal}}</code></pre>
</div>
<div v-if="validationModal.response" class="space-y-4"> <div v-if="validationModal.response" class="space-y-4">
<div class="alert alert-info"> <div class="alert alert-info">
<i class="mdi mdi-information-outline"></i> <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 () => { const submitValidation = async () => {
if (!validationModal.value.response?.id) return; if (!validationModal.value.response?.id) return;

View File

@ -22,7 +22,7 @@
@change="handleFileSelect" @change="handleFileSelect"
/> />
<div v-if="files.length === 0" class="space-y-4"> <div v-if="submissionFiles.length === 0" class="space-y-4">
<div <div
class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center" 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 v-else class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4">
<div <div
v-for="(file, index) in files" v-for="(file, index) in submissionFiles"
:key="index" :key="index"
class="relative group" class="relative group"
> >
@ -105,7 +105,7 @@
</span> </span>
</div> </div>
<button <button
@click="retryOCR(file)" @click="processOCR(file)"
class="btn btn-xs btn-ghost" class="btn btn-xs btn-ghost"
title="Retry OCR" title="Retry OCR"
> >
@ -226,47 +226,16 @@
import { ref, watch, nextTick } from "vue"; import { ref, watch, nextTick } from "vue";
import { ocrService } from "@/services/ocrService"; import { ocrService } from "@/services/ocrService";
import { usePuzzlesStore } from "@/stores/puzzles"; import { usePuzzlesStore } from "@/stores/puzzles";
import type { SubmissionFile, SteamCollectionItem } from "@/types"; import { useUploadsStore } from "@/stores/uploads";
import type { SubmissionFile } from "@/types";
interface Props {
modelValue: SubmissionFile[];
puzzles?: SteamCollectionItem[];
}
interface Emits {
"update:modelValue": [files: SubmissionFile[]];
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Pinia store // Pinia store
const puzzlesStore = usePuzzlesStore(); const puzzlesStore = usePuzzlesStore();
const { submissionFiles, processOCR } = useUploadsStore();
const fileInput = ref<HTMLInputElement>(); const fileInput = ref<HTMLInputElement>();
const isDragOver = ref(false); const isDragOver = ref(false);
const error = ref(""); 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 for puzzle changes and update OCR service
watch( watch(
@ -317,7 +286,7 @@ const processFiles = async (newFiles: File[]) => {
ocrData: undefined, ocrData: undefined,
}; };
files.value.push(submissionFile); submissionFiles.push(submissionFile);
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity) // Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
if (isOpusMagnumImage(file)) { if (isOpusMagnumImage(file)) {
@ -357,7 +326,7 @@ const createPreview = (file: File): Promise<string> => {
}; };
const removeFile = (index: number) => { const removeFile = (index: number) => {
files.value.splice(index, 1); submissionFiles.splice(index, 1);
}; };
const formatFileSize = (bytes: number): string => { 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 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 => { const getConfidenceBadgeClass = (confidence: number): string => {
if (confidence >= 0.8) return "badge-success"; if (confidence >= 0.8) return "badge-success";
if (confidence >= 0.6) return "badge-warning"; if (confidence >= 0.6) return "badge-warning";
@ -435,16 +352,16 @@ const getConfidenceBadgeClass = (confidence: number): string => {
const onManualPuzzleSelection = (submissionFile: SubmissionFile) => { const onManualPuzzleSelection = (submissionFile: SubmissionFile) => {
// Find the file in the reactive array // Find the file in the reactive array
const fileIndex = files.value.findIndex( const fileIndex = submissionFiles.findIndex(
(f) => f.file === submissionFile.file, (f) => f.file === submissionFile.file,
); );
if (fileIndex === -1) return; if (fileIndex === -1) return;
// Clear the manual selection requirement once user has selected // Clear the manual selection requirement once user has selected
if (files.value[fileIndex].manualPuzzleSelection) { if (submissionFiles[fileIndex].manualPuzzleSelection) {
files.value[fileIndex].needsManualPuzzleSelection = false; submissionFiles[fileIndex].needsManualPuzzleSelection = false;
console.log( 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> <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="card-body">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<h3 class="card-title text-lg font-bold">{{ puzzle.title }}</h3> <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="flex items-center gap-2 mb-3">
<div class="badge badge-primary badge-sm">{{ puzzle.steam_item_id }}</div> <div class="badge badge-primary badge-sm">
<div class="badge badge-ghost badge-sm">Order: {{ puzzle.order_index + 1 }}</div> {{ puzzle.steam_item_id }}
</div>
<div class="badge badge-ghost badge-sm">
Order: {{ puzzle.order_index + 1 }}
</div>
</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 }} {{ puzzle.description }}
</p> </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 <span
v-for="tag in puzzle.tags.slice(0, 3)" v-for="tag in puzzle.tags.slice(0, 3)"
:key="tag" :key="tag"
@ -23,7 +37,10 @@
> >
{{ tag }} {{ tag }}
</span> </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 +{{ puzzle.tags.length - 3 }} more
</span> </span>
</div> </div>
@ -45,7 +62,9 @@
<!-- Responses Table --> <!-- Responses Table -->
<div v-if="responses && responses.length > 0" class="mt-6"> <div v-if="responses && responses.length > 0" class="mt-6">
<div class="divider"> <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>
<div> <div>
@ -59,32 +78,59 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="response in responses" :key="response.id" class="hover"> <tr
v-for="response in responses"
:key="response.id"
class="hover"
>
<td> <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 }} {{ response.final_cost || response.cost }}
</span> </span>
<span v-else class="text-base-content/50">-</span> <span v-else class="text-base-content/50">-</span>
</td> </td>
<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 }} {{ response.final_cycles || response.cycles }}
</span> </span>
<span v-else class="text-base-content/50">-</span> <span v-else class="text-base-content/50">-</span>
</td> </td>
<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 }} {{ response.final_area || response.area }}
</span> </span>
<span v-else class="text-base-content/50">-</span> <span v-else class="text-base-content/50">-</span>
</td> </td>
<td> <td>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="badge badge-ghost badge-xs">{{ response.files?.length || 0 }}</span> <span class="badge badge-ghost badge-xs">{{
<div v-if="response.files?.length" class="tooltip" :data-tip="response.files.map(f => f.original_filename || f.file?.name).join(', ')"> 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> <i class="mdi mdi-information-outline text-xs"></i>
</div> </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> <i class="mdi mdi-alert-circle text-xs text-warning"></i>
</div> </div>
</div> </div>
@ -96,33 +142,31 @@
</div> </div>
<!-- No responses state --> <!-- 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> <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-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> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { SteamCollectionItem, PuzzleResponse } from '@/types' import type { SteamCollectionItem, PuzzleResponse } from "@/types";
import { useSubmissionsStore } from "@/stores/submissions";
interface Props { interface Props {
puzzle: SteamCollectionItem puzzle: SteamCollectionItem;
responses?: PuzzleResponse[] responses?: PuzzleResponse[];
} }
defineProps<Props>() defineProps<Props>();
// Utility functions removed - not used in template const { openSubmissionModal } = useSubmissionsStore();
</script> </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> </div>
<!-- File Upload --> <!-- File Upload -->
<FileUpload v-model="submissionFiles" :puzzles="puzzles" /> <FileUpload />
<!-- Manual Selection Warning --> <!-- Manual Selection Warning -->
<div <div
v-if="filesNeedingManualSelection.length > 0" v-if="submissionFilesNeedingManualSelection.length > 0"
class="alert alert-warning" class="alert alert-warning"
> >
<i class="mdi mdi-alert-circle text-xl"></i> <i class="mdi mdi-alert-circle text-xl"></i>
<div class="flex-1"> <div class="flex-1">
<div class="font-bold">Manual Puzzle Selection Required</div> <div class="font-bold">Manual Puzzle Selection Required</div>
<div class="text-sm"> <div class="text-sm">
{{ filesNeedingManualSelection.length }} file(s) have low OCR {{ submissionFilesNeedingManualSelection.length }} file(s) have
confidence for puzzle names. Please select the correct puzzle for low OCR confidence for puzzle names. Please select the correct
each file before submitting. puzzle for each file before submitting.
</div> </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>
</div> </div>
@ -74,6 +82,7 @@
type="checkbox" type="checkbox"
v-model="manualValidationRequested" v-model="manualValidationRequested"
class="checkbox checkbox-primary" class="checkbox checkbox-primary"
:disabled="hasLowConfidence"
/> />
<div class="flex-1"> <div class="flex-1">
<span class="label-text font-medium" <span class="label-text font-medium"
@ -100,8 +109,8 @@
class="loading loading-spinner loading-sm" class="loading loading-spinner loading-sm"
></span> ></span>
<span v-if="isSubmitting">Submitting...</span> <span v-if="isSubmitting">Submitting...</span>
<span v-else-if="filesNeedingManualSelection.length > 0"> <span v-else-if="submissionFilesNeedingManualSelection.length > 0">
Select Puzzles ({{ filesNeedingManualSelection.length }} Select Puzzles ({{ submissionFilesNeedingManualSelection.length }}
remaining) remaining)
</span> </span>
<span v-else>Submit Solution</span> <span v-else>Submit Solution</span>
@ -116,26 +125,26 @@
import { ref, computed, watch } from "vue"; import { ref, computed, watch } from "vue";
import FileUpload from "@/components/FileUpload.vue"; import FileUpload from "@/components/FileUpload.vue";
import type { SteamCollectionItem, SubmissionFile } from "@/types"; import type { SteamCollectionItem, SubmissionFile } from "@/types";
import { useUploadsStore } from "@/stores/uploads";
import { useSubmissionsStore } from "@/stores/submissions";
import { storeToRefs } from "pinia";
interface Props { interface Props {
puzzles: SteamCollectionItem[]; puzzles: SteamCollectionItem[];
findPuzzleByName: (name: string) => SteamCollectionItem | null; findPuzzleByName: (name: string) => SteamCollectionItem | null;
} }
interface Emits {
submit: [
submissionData: {
files: SubmissionFile[];
notes?: string;
manualValidationRequested?: boolean;
},
];
}
const props = defineProps<Props>(); 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 notes = ref("");
const manualValidationRequested = ref(false); const manualValidationRequested = ref(false);
const isSubmitting = ref(false); const isSubmitting = ref(false);
@ -151,6 +160,12 @@ const canSubmit = computed(() => {
return hasFiles && !isSubmitting.value && noManualSelectionNeeded; return hasFiles && !isSubmitting.value && noManualSelectionNeeded;
}); });
watch(hasLowConfidence, (newValue) => {
if (newValue) {
manualValidationRequested.value = true;
}
});
// Group files by detected puzzle // Group files by detected puzzle
const responsesByPuzzle = computed(() => { const responsesByPuzzle = computed(() => {
const grouped: Record< const grouped: Record<
@ -176,52 +191,22 @@ const responsesByPuzzle = computed(() => {
return grouped; 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 () => { const handleSubmit = async () => {
if (!canSubmit.value) return; if (!canSubmit.value) return;
isSubmitting.value = true; isSubmitting.value = true;
try { try {
// Emit the files and notes for the parent to handle API submission // Emit the files and notes for the store to handle API submission
emit("submit", { handleSubmission({
files: submissionFiles.value, files: submissionFiles.value,
notes: notes.value.trim() || undefined, notes: notes.value.trim() || undefined,
manualValidationRequested: manualValidationRequested.value, manualValidationRequested:
hasLowConfidence.value || manualValidationRequested.value,
}); });
// Reset form // Reset form
submissionFiles.value = []; clearFiles();
notes.value = ""; notes.value = "";
manualValidationRequested.value = false; manualValidationRequested.value = false;
} catch (error) { } catch (error) {

View File

@ -235,7 +235,7 @@ export class OpusMagnumOCRService {
cost: confidenceScores.cost || 0, cost: confidenceScores.cost || 0,
cycles: confidenceScores.cycles || 0, cycles: confidenceScores.cycles || 0,
area: confidenceScores.area || 0, area: confidenceScores.area || 0,
overall: overallConfidence overall: overallConfidence,
} }
}); });
} catch (error) { } catch (error) {

View File

@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { Submission, SubmissionFile } from '@/types' import type { Submission, SubmissionFile } from '@/types'
import { submissionHelpers } from '@/services/apiService' import { submissionHelpers } from '@/services/apiService'
import { usePuzzlesStore } from '@/stores/puzzles' import { usePuzzlesStore } from '@/stores/puzzles'
import { errorHelpers } from "@/services/apiService";
export const useSubmissionsStore = defineStore('submissions', () => { export const useSubmissionsStore = defineStore('submissions', () => {
// State // State
@ -83,6 +84,49 @@ export const useSubmissionsStore = defineStore('submissions', () => {
await loadSubmissions() 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 { return {
// State // State
submissions, submissions,
@ -95,6 +139,7 @@ export const useSubmissionsStore = defineStore('submissions', () => {
createSubmission, createSubmission,
openSubmissionModal, openSubmissionModal,
closeSubmissionModal, 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: try:
with transaction.atomic(): 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( auto_request_validation = any(
( (
response_data.ocr_confidence_cost is not None 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 valid_count = 0
if r.cost == ocr_data[1]: for index, field in enumerate(["cost", "cycles", "area"]):
r.validated_cost = r.cost value = getattr(r, field, -1)
if value == ocr_data[index + 1]:
setattr(r, f"validated_{field}", value)
valid_count += 1 valid_count += 1
if r.cycles == ocr_data[2]: else:
r.validated_cycles = r.cycles setattr(r, field, ocr_data[index + 1])
valid_count += 1
if r.area == ocr_data[3]: r.needs_manual_validation = valid_count != 3
r.validated_area = r.area
valid_count += 1
if valid_count == 3:
r.needs_manual_validation = False
if valid_count:
r.save() r.save()

View File

@ -21,6 +21,7 @@ build-backend = "setuptools.build_meta"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"django-extensions>=4.1",
"django-stubs>=5.2.7", "django-stubs>=5.2.7",
"django-stubs-ext>=5.2.7", "django-stubs-ext>=5.2.7",
"django-types>=0.22.0", "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" }, { 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]] [[package]]
name = "django-ninja" name = "django-ninja"
version = "1.4.5" version = "1.4.5"
@ -504,6 +516,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "django-extensions" },
{ name = "django-stubs" }, { name = "django-stubs" },
{ name = "django-stubs-ext" }, { name = "django-stubs-ext" },
{ name = "django-types" }, { name = "django-types" },
@ -530,6 +543,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "django-extensions", specifier = ">=4.1" },
{ name = "django-stubs", specifier = ">=5.2.7" }, { name = "django-stubs", specifier = ">=5.2.7" },
{ name = "django-stubs-ext", specifier = ">=5.2.7" }, { name = "django-stubs-ext", specifier = ">=5.2.7" },
{ name = "django-types", specifier = ">=0.22.0" }, { name = "django-types", specifier = ">=0.22.0" },