better re-processing OCR for admin
This commit is contained in:
parent
8f88548a59
commit
9ee45463a8
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
103
opus_submitter/src/stores/uploads.ts
Normal file
103
opus_submitter/src/stores/uploads.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
@ -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
14
uv.lock
@ -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" },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user