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);
@ -175,7 +133,10 @@ const reloadPage = () => {
</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"> <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> </div>
</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,37 +1,54 @@
<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
<span v-if="puzzle.tags && puzzle.tags.length > 0"
v-for="tag in puzzle.tags.slice(0, 3)" class="flex flex-wrap gap-1 mb-4"
>
<span
v-for="tag in puzzle.tags.slice(0, 3)"
:key="tag" :key="tag"
class="badge badge-outline badge-xs" class="badge badge-outline badge-xs"
> >
{{ 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>
</div> </div>
<div class="flex flex-col items-end gap-2"> <div class="flex flex-col items-end gap-2">
<div class="tooltip" data-tip="View on Steam Workshop"> <div class="tooltip" data-tip="View on Steam Workshop">
<a <a
:href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`" :href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`"
target="_blank" target="_blank"
class="btn btn-ghost btn-sm btn-square" class="btn btn-ghost btn-sm btn-square"
@ -41,13 +58,15 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 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>
<table class="table table-xs"> <table class="table table-xs">
<thead> <thead>
@ -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>
@ -94,35 +140,33 @@
</table> </table>
</div> </div>
</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

@ -35,7 +35,7 @@ export class OpusMagnumOCRService {
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (this.worker) return; if (this.worker) return;
this.worker = await createWorker('eng'); this.worker = await createWorker('eng');
await this.worker.setParameters({ await this.worker.setParameters({
tessedit_ocr_engine_mode: '3', tessedit_ocr_engine_mode: '3',
@ -62,29 +62,29 @@ export class OpusMagnumOCRService {
await this.worker.setParameters({ await this.worker.setParameters({
// Disable all system dictionaries to prevent interference // Disable all system dictionaries to prevent interference
load_system_dawg: '0', load_system_dawg: '0',
load_freq_dawg: '0', load_freq_dawg: '0',
load_punc_dawg: '0', load_punc_dawg: '0',
load_number_dawg: '0', load_number_dawg: '0',
load_unambig_dawg: '0', load_unambig_dawg: '0',
load_bigram_dawg: '0', load_bigram_dawg: '0',
load_fixed_length_dawgs: '0', load_fixed_length_dawgs: '0',
// Use only characters from our puzzle names // Use only characters from our puzzle names
tessedit_char_whitelist: this.getPuzzleCharacterSet(), tessedit_char_whitelist: this.getPuzzleCharacterSet(),
// Optimize for single words/short phrases // Optimize for single words/short phrases
tessedit_pageseg_mode: 8 as any, // Single word tessedit_pageseg_mode: 8 as any, // Single word
// Increase penalties for non-dictionary words // Increase penalties for non-dictionary words
segment_penalty_dict_nonword: '2.0', segment_penalty_dict_nonword: '2.0',
segment_penalty_dict_frequent_word: '0.001', segment_penalty_dict_frequent_word: '0.001',
segment_penalty_dict_case_ok: '0.001', segment_penalty_dict_case_ok: '0.001',
segment_penalty_dict_case_bad: '0.1', segment_penalty_dict_case_bad: '0.1',
// Make OCR more conservative about character recognition // Make OCR more conservative about character recognition
classify_enable_learning: '0', classify_enable_learning: '0',
classify_enable_adaptive_matcher: '1', classify_enable_adaptive_matcher: '1',
// Preserve word boundaries // Preserve word boundaries
preserve_interword_spaces: '1' preserve_interword_spaces: '1'
}); });
@ -120,13 +120,13 @@ export class OpusMagnumOCRService {
// Convert file to image element for canvas processing // Convert file to image element for canvas processing
const imageUrl = URL.createObjectURL(imageFile); const imageUrl = URL.createObjectURL(imageFile);
const img = new Image(); const img = new Image();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
img.onload = async () => { img.onload = async () => {
try { try {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!; const ctx = canvas.getContext('2d')!;
canvas.width = img.width; canvas.width = img.width;
canvas.height = img.height; canvas.height = img.height;
ctx.drawImage(img, 0, 0); ctx.drawImage(img, 0, 0);
@ -138,10 +138,10 @@ export class OpusMagnumOCRService {
for (const [key, region] of Object.entries(this.regions)) { for (const [key, region] of Object.entries(this.regions)) {
const regionCanvas = document.createElement('canvas'); const regionCanvas = document.createElement('canvas');
const regionCtx = regionCanvas.getContext('2d')!; const regionCtx = regionCanvas.getContext('2d')!;
regionCanvas.width = region.width; regionCanvas.width = region.width;
regionCanvas.height = region.height; regionCanvas.height = region.height;
// Extract region from main image // Extract region from main image
regionCtx.drawImage( regionCtx.drawImage(
canvas, canvas,
@ -178,7 +178,7 @@ export class OpusMagnumOCRService {
// Perform OCR on the region // Perform OCR on the region
const { data: { text, confidence } } = await this.worker!.recognize(regionCanvas); const { data: { text, confidence } } = await this.worker!.recognize(regionCanvas);
let cleanText = text.trim(); let cleanText = text.trim();
// Store the confidence score for this field // Store the confidence score for this field
confidenceScores[key] = confidence / 100; // Tesseract returns 0-100, we want 0-1 confidenceScores[key] = confidence / 100; // Tesseract returns 0-100, we want 0-1
@ -203,7 +203,7 @@ export class OpusMagnumOCRService {
} else if (key === 'puzzle') { } else if (key === 'puzzle') {
// Post-process puzzle names with aggressive matching to force selection from available puzzles // Post-process puzzle names with aggressive matching to force selection from available puzzles
cleanText = this.findBestPuzzleMatch(cleanText); cleanText = this.findBestPuzzleMatch(cleanText);
// If we still don't have a match and we have available puzzles, force the best match // If we still don't have a match and we have available puzzles, force the best match
if (this.availablePuzzleNames.length > 0 && !this.availablePuzzleNames.includes(cleanText)) { if (this.availablePuzzleNames.length > 0 && !this.availablePuzzleNames.includes(cleanText)) {
const forcedMatch = this.findBestPuzzleMatchForced(cleanText); const forcedMatch = this.findBestPuzzleMatchForced(cleanText);
@ -218,13 +218,13 @@ export class OpusMagnumOCRService {
} }
URL.revokeObjectURL(imageUrl); URL.revokeObjectURL(imageUrl);
// Calculate overall confidence as the average of all field confidences // Calculate overall confidence as the average of all field confidences
const confidenceValues = Object.values(confidenceScores); const confidenceValues = Object.values(confidenceScores);
const overallConfidence = confidenceValues.length > 0 const overallConfidence = confidenceValues.length > 0
? confidenceValues.reduce((sum, conf) => sum + conf, 0) / confidenceValues.length ? confidenceValues.reduce((sum, conf) => sum + conf, 0) / confidenceValues.length
: 0; : 0;
resolve({ resolve({
puzzle: results.puzzle || '', puzzle: results.puzzle || '',
cost: results.cost || '', cost: results.cost || '',
@ -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) {
@ -256,14 +256,14 @@ export class OpusMagnumOCRService {
private preprocessImage(imageData: ImageData): void { private preprocessImage(imageData: ImageData): void {
// Convert to grayscale and invert (similar to cv2.bitwise_not in main.py) // Convert to grayscale and invert (similar to cv2.bitwise_not in main.py)
const data = imageData.data; const data = imageData.data;
for (let i = 0; i < data.length; i += 4) { for (let i = 0; i < data.length; i += 4) {
// Convert to grayscale // Convert to grayscale
const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]); const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
// Invert the grayscale value // Invert the grayscale value
const inverted = 255 - gray; const inverted = 255 - gray;
data[i] = inverted; // Red data[i] = inverted; // Red
data[i + 1] = inverted; // Green data[i + 1] = inverted; // Green
data[i + 2] = inverted; // Blue data[i + 2] = inverted; // Blue
@ -276,10 +276,10 @@ export class OpusMagnumOCRService {
*/ */
private levenshteinDistance(str1: string, str2: string): number { private levenshteinDistance(str1: string, str2: string): number {
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null)); const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i; 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 = 0; j <= str2.length; j++) matrix[j][0] = j;
for (let j = 1; j <= str2.length; j++) { for (let j = 1; j <= str2.length; j++) {
for (let i = 1; i <= str1.length; i++) { for (let i = 1; i <= str1.length; i++) {
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1; const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
@ -290,7 +290,7 @@ export class OpusMagnumOCRService {
); );
} }
} }
return matrix[str2.length][str1.length]; return matrix[str2.length][str1.length];
} }
@ -304,7 +304,7 @@ export class OpusMagnumOCRService {
const cleanedOcr = ocrText.trim(); const cleanedOcr = ocrText.trim();
if (!cleanedOcr) return ''; if (!cleanedOcr) return '';
// Strategy 1: Exact match (case insensitive) // Strategy 1: Exact match (case insensitive)
const exactMatch = this.availablePuzzleNames.find( const exactMatch = this.availablePuzzleNames.find(
name => name.toLowerCase() === cleanedOcr.toLowerCase() name => name.toLowerCase() === cleanedOcr.toLowerCase()
@ -314,31 +314,31 @@ export class OpusMagnumOCRService {
// Strategy 2: Substring match (either direction) // Strategy 2: Substring match (either direction)
const substringMatch = this.availablePuzzleNames.find( const substringMatch = this.availablePuzzleNames.find(
name => name.toLowerCase().includes(cleanedOcr.toLowerCase()) || name => name.toLowerCase().includes(cleanedOcr.toLowerCase()) ||
cleanedOcr.toLowerCase().includes(name.toLowerCase()) cleanedOcr.toLowerCase().includes(name.toLowerCase())
); );
if (substringMatch) return substringMatch; if (substringMatch) return substringMatch;
// Strategy 3: Multiple fuzzy matching approaches // Strategy 3: Multiple fuzzy matching approaches
let bestMatch = cleanedOcr; let bestMatch = cleanedOcr;
let bestScore = 0; let bestScore = 0;
for (const puzzleName of this.availablePuzzleNames) { for (const puzzleName of this.availablePuzzleNames) {
const scores = [ const scores = [
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName), this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName), this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2) this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2)
]; ];
// Use the maximum score from all algorithms // Use the maximum score from all algorithms
const maxScore = Math.max(...scores); const maxScore = Math.max(...scores);
// Lower threshold for better matching - force selection even with moderate confidence // Lower threshold for better matching - force selection even with moderate confidence
if (maxScore > bestScore && maxScore > 0.4) { if (maxScore > bestScore && maxScore > 0.4) {
bestScore = maxScore; bestScore = maxScore;
bestMatch = puzzleName; bestMatch = puzzleName;
} }
} }
// Strategy 4: If no good match found, try character-based matching // Strategy 4: If no good match found, try character-based matching
if (bestScore < 0.6) { if (bestScore < 0.6) {
const charMatch = this.findBestCharacterMatch(cleanedOcr); const charMatch = this.findBestCharacterMatch(cleanedOcr);
@ -346,7 +346,7 @@ export class OpusMagnumOCRService {
bestMatch = charMatch; bestMatch = charMatch;
} }
} }
return bestMatch; return bestMatch;
} }
@ -365,23 +365,23 @@ export class OpusMagnumOCRService {
private calculateJaroWinklerSimilarity(str1: string, str2: string): number { private calculateJaroWinklerSimilarity(str1: string, str2: string): number {
const s1 = str1.toLowerCase(); const s1 = str1.toLowerCase();
const s2 = str2.toLowerCase(); const s2 = str2.toLowerCase();
if (s1 === s2) return 1; if (s1 === s2) return 1;
const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2) - 1; const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2) - 1;
if (matchWindow < 0) return 0; if (matchWindow < 0) return 0;
const s1Matches = new Array(s1.length).fill(false); const s1Matches = new Array(s1.length).fill(false);
const s2Matches = new Array(s2.length).fill(false); const s2Matches = new Array(s2.length).fill(false);
let matches = 0; let matches = 0;
let transpositions = 0; let transpositions = 0;
// Find matches // Find matches
for (let i = 0; i < s1.length; i++) { for (let i = 0; i < s1.length; i++) {
const start = Math.max(0, i - matchWindow); const start = Math.max(0, i - matchWindow);
const end = Math.min(i + matchWindow + 1, s2.length); const end = Math.min(i + matchWindow + 1, s2.length);
for (let j = start; j < end; j++) { for (let j = start; j < end; j++) {
if (s2Matches[j] || s1[i] !== s2[j]) continue; if (s2Matches[j] || s1[i] !== s2[j]) continue;
s1Matches[i] = true; s1Matches[i] = true;
@ -390,9 +390,9 @@ export class OpusMagnumOCRService {
break; break;
} }
} }
if (matches === 0) return 0; if (matches === 0) return 0;
// Count transpositions // Count transpositions
let k = 0; let k = 0;
for (let i = 0; i < s1.length; i++) { for (let i = 0; i < s1.length; i++) {
@ -401,16 +401,16 @@ export class OpusMagnumOCRService {
if (s1[i] !== s2[k]) transpositions++; if (s1[i] !== s2[k]) transpositions++;
k++; k++;
} }
const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3; const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
// Jaro-Winkler bonus for common prefix // Jaro-Winkler bonus for common prefix
let prefix = 0; let prefix = 0;
for (let i = 0; i < Math.min(s1.length, s2.length, 4); i++) { for (let i = 0; i < Math.min(s1.length, s2.length, 4); i++) {
if (s1[i] === s2[i]) prefix++; if (s1[i] === s2[i]) prefix++;
else break; else break;
} }
return jaro + (0.1 * prefix * (1 - jaro)); return jaro + (0.1 * prefix * (1 - jaro));
} }
@ -420,24 +420,24 @@ export class OpusMagnumOCRService {
private calculateNGramSimilarity(str1: string, str2: string, n: number): number { private calculateNGramSimilarity(str1: string, str2: string, n: number): number {
const s1 = str1.toLowerCase(); const s1 = str1.toLowerCase();
const s2 = str2.toLowerCase(); const s2 = str2.toLowerCase();
if (s1 === s2) return 1; if (s1 === s2) return 1;
if (s1.length < n || s2.length < n) return 0; if (s1.length < n || s2.length < n) return 0;
const ngrams1 = new Set<string>(); const ngrams1 = new Set<string>();
const ngrams2 = new Set<string>(); const ngrams2 = new Set<string>();
for (let i = 0; i <= s1.length - n; i++) { for (let i = 0; i <= s1.length - n; i++) {
ngrams1.add(s1.substr(i, n)); ngrams1.add(s1.substr(i, n));
} }
for (let i = 0; i <= s2.length - n; i++) { for (let i = 0; i <= s2.length - n; i++) {
ngrams2.add(s2.substr(i, n)); ngrams2.add(s2.substr(i, n));
} }
const intersection = new Set([...ngrams1].filter(x => ngrams2.has(x))); const intersection = new Set([...ngrams1].filter(x => ngrams2.has(x)));
const union = new Set([...ngrams1, ...ngrams2]); const union = new Set([...ngrams1, ...ngrams2]);
return intersection.size / union.size; return intersection.size / union.size;
} }
@ -447,7 +447,7 @@ export class OpusMagnumOCRService {
private findBestCharacterMatch(ocrText: string): string | null { private findBestCharacterMatch(ocrText: string): string | null {
let bestMatch = null; let bestMatch = null;
let bestScore = 0; let bestScore = 0;
for (const puzzleName of this.availablePuzzleNames) { for (const puzzleName of this.availablePuzzleNames) {
const score = this.calculateCharacterFrequencyScore(ocrText.toLowerCase(), puzzleName.toLowerCase()); const score = this.calculateCharacterFrequencyScore(ocrText.toLowerCase(), puzzleName.toLowerCase());
if (score > bestScore && score > 0.3) { if (score > bestScore && score > 0.3) {
@ -455,7 +455,7 @@ export class OpusMagnumOCRService {
bestMatch = puzzleName; bestMatch = puzzleName;
} }
} }
return bestMatch; return bestMatch;
} }
@ -465,26 +465,26 @@ export class OpusMagnumOCRService {
private calculateCharacterFrequencyScore(str1: string, str2: string): number { private calculateCharacterFrequencyScore(str1: string, str2: string): number {
const freq1 = new Map<string, number>(); const freq1 = new Map<string, number>();
const freq2 = new Map<string, number>(); const freq2 = new Map<string, number>();
for (const char of str1) { for (const char of str1) {
freq1.set(char, (freq1.get(char) || 0) + 1); freq1.set(char, (freq1.get(char) || 0) + 1);
} }
for (const char of str2) { for (const char of str2) {
freq2.set(char, (freq2.get(char) || 0) + 1); freq2.set(char, (freq2.get(char) || 0) + 1);
} }
const allChars = new Set([...freq1.keys(), ...freq2.keys()]); const allChars = new Set([...freq1.keys(), ...freq2.keys()]);
let similarity = 0; let similarity = 0;
let totalChars = 0; let totalChars = 0;
for (const char of allChars) { for (const char of allChars) {
const count1 = freq1.get(char) || 0; const count1 = freq1.get(char) || 0;
const count2 = freq2.get(char) || 0; const count2 = freq2.get(char) || 0;
similarity += Math.min(count1, count2); similarity += Math.min(count1, count2);
totalChars += Math.max(count1, count2); totalChars += Math.max(count1, count2);
} }
return totalChars === 0 ? 0 : similarity / totalChars; return totalChars === 0 ? 0 : similarity / totalChars;
} }
@ -539,7 +539,7 @@ export class OpusMagnumOCRService {
const len2 = str2.length; const len2 = str2.length;
const maxLen = Math.max(len1, len2); const maxLen = Math.max(len1, len2);
const minLen = Math.min(len1, len2); const minLen = Math.min(len1, len2);
return maxLen === 0 ? 1 : minLen / maxLen; return maxLen === 0 ? 1 : minLen / maxLen;
} }
@ -563,11 +563,11 @@ export class OpusMagnumOCRService {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const imageUrl = URL.createObjectURL(imageFile); const imageUrl = URL.createObjectURL(imageFile);
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!; const ctx = canvas.getContext('2d')!;
canvas.width = img.width; canvas.width = img.width;
canvas.height = img.height; canvas.height = img.height;
ctx.drawImage(img, 0, 0); ctx.drawImage(img, 0, 0);
@ -575,7 +575,7 @@ export class OpusMagnumOCRService {
// Draw debug rectangles // Draw debug rectangles
ctx.strokeStyle = '#00ff00'; ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2; ctx.lineWidth = 2;
const service = new OpusMagnumOCRService(); const service = new OpusMagnumOCRService();
Object.values(service.regions).forEach(region => { Object.values(service.regions).forEach(region => {
ctx.strokeRect(region.x, region.y, region.width, region.height); ctx.strokeRect(region.x, region.y, region.width, region.height);

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)
valid_count += 1
if r.cycles == ocr_data[2]: if value == ocr_data[index + 1]:
r.validated_cycles = r.cycles setattr(r, f"validated_{field}", value)
valid_count += 1 valid_count += 1
if r.area == ocr_data[3]: else:
r.validated_area = r.area setattr(r, field, ocr_data[index + 1])
valid_count += 1
if valid_count == 3: r.needs_manual_validation = valid_count != 3
r.needs_manual_validation = False r.save()
if valid_count:
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" },