|
@@ -4,6 +4,7 @@ namespace App\Filament\Pages;
|
|
|
|
|
|
|
|
use App\Jobs\ImportTemToQuestionsJob;
|
|
use App\Jobs\ImportTemToQuestionsJob;
|
|
|
use App\Services\ExamPdfExportService;
|
|
use App\Services\ExamPdfExportService;
|
|
|
|
|
+use App\Services\MathFormulaProcessor;
|
|
|
use App\Services\QuestionQualityCheckService;
|
|
use App\Services\QuestionQualityCheckService;
|
|
|
use App\Services\QuestionsTemAssemblyService;
|
|
use App\Services\QuestionsTemAssemblyService;
|
|
|
use App\Services\QuestionTemReviewService;
|
|
use App\Services\QuestionTemReviewService;
|
|
@@ -17,6 +18,7 @@ use Illuminate\Support\Facades\DB;
|
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
use Livewire\Attributes\Computed;
|
|
use Livewire\Attributes\Computed;
|
|
|
use Livewire\Attributes\Renderless;
|
|
use Livewire\Attributes\Renderless;
|
|
|
|
|
+use Livewire\Attributes\Url;
|
|
|
use UnitEnum;
|
|
use UnitEnum;
|
|
|
|
|
|
|
|
class QuestionTemQualityReview extends Page
|
|
class QuestionTemQualityReview extends Page
|
|
@@ -38,13 +40,17 @@ class QuestionTemQualityReview extends Page
|
|
|
|
|
|
|
|
protected string $view = 'filament.pages.question-tem-quality-review';
|
|
protected string $view = 'filament.pages.question-tem-quality-review';
|
|
|
|
|
|
|
|
|
|
+ #[Url(as: 'kp', except: '')]
|
|
|
public ?string $selectedKpCode = null;
|
|
public ?string $selectedKpCode = null;
|
|
|
|
|
|
|
|
/** 左侧知识点列表搜索(匹配 kp_code、kp_name,不区分大小写) */
|
|
/** 左侧知识点列表搜索(匹配 kp_code、kp_name,不区分大小写) */
|
|
|
|
|
+ #[Url(as: 'kps', except: '')]
|
|
|
public string $kpSearch = '';
|
|
public string $kpSearch = '';
|
|
|
/** 左侧按年级筛选;空字符串=全部 */
|
|
/** 左侧按年级筛选;空字符串=全部 */
|
|
|
|
|
+ #[Url(as: 'grade', except: '')]
|
|
|
public string $gradeFilter = '';
|
|
public string $gradeFilter = '';
|
|
|
/** 左侧按学期筛选;空字符串=全部 */
|
|
/** 左侧按学期筛选;空字符串=全部 */
|
|
|
|
|
+ #[Url(as: 'semester', except: '')]
|
|
|
public string $semesterFilter = '';
|
|
public string $semesterFilter = '';
|
|
|
/** 中间卡片首屏渲染数量(性能优先,按需加载更多) */
|
|
/** 中间卡片首屏渲染数量(性能优先,按需加载更多) */
|
|
|
public int $cardRenderLimit = 20;
|
|
public int $cardRenderLimit = 20;
|
|
@@ -390,7 +396,7 @@ class QuestionTemQualityReview extends Page
|
|
|
return Cache::remember($key, now()->addMinutes(2), function (): array {
|
|
return Cache::remember($key, now()->addMinutes(2), function (): array {
|
|
|
$q = DB::table('questions')
|
|
$q = DB::table('questions')
|
|
|
->where('kp_code', $this->selectedKpCode)
|
|
->where('kp_code', $this->selectedKpCode)
|
|
|
- ->select(['id', 'stem', 'question_type'])
|
|
|
|
|
|
|
+ ->select(['id', 'stem', 'question_type', 'options', 'answer', 'solution'])
|
|
|
->orderByRaw("
|
|
->orderByRaw("
|
|
|
CASE
|
|
CASE
|
|
|
WHEN LOWER(COALESCE(question_type, '')) LIKE '%choice%' OR question_type LIKE '%选择%' THEN 1
|
|
WHEN LOWER(COALESCE(question_type, '')) LIKE '%choice%' OR question_type LIKE '%选择%' THEN 1
|
|
@@ -407,6 +413,89 @@ class QuestionTemQualityReview extends Page
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 中间卡片:给每道 tem 题计算「当前知识点下最相似的正式库题目」综合相似度。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return array<int, array{question_id:int, score:float, score_text:string}>
|
|
|
|
|
+ */
|
|
|
|
|
+ #[Computed]
|
|
|
|
|
+ public function temSimilarityHints(): array
|
|
|
|
|
+ {
|
|
|
|
|
+ if (! $this->selectedKpCode) {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $temRows = $this->visibleTemQuestionCards;
|
|
|
|
|
+ $questionRows = $this->currentKpQuestionStemRows;
|
|
|
|
|
+ if ($temRows === [] || $questionRows === []) {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $normalizedQuestions = [];
|
|
|
|
|
+ foreach ($questionRows as $qr) {
|
|
|
|
|
+ $qid = (int) ($qr->id ?? 0);
|
|
|
|
|
+ if ($qid <= 0) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $normalizedQuestions[$qid] = [
|
|
|
|
|
+ 'type' => $this->normalizeSimilarityQuestionType((string) ($qr->question_type ?? '')),
|
|
|
|
|
+ 'stem' => $this->normalizeSimilarityText((string) ($qr->stem ?? '')),
|
|
|
|
|
+ 'options' => $this->normalizeSimilarityOptions($qr->options ?? null),
|
|
|
|
|
+ 'answer' => $this->normalizeSimilarityText((string) ($qr->answer ?? '')),
|
|
|
|
|
+ 'solution' => $this->normalizeSimilarityText((string) ($qr->solution ?? '')),
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $out = [];
|
|
|
|
|
+ foreach ($temRows as $card) {
|
|
|
|
|
+ $tid = (int) ($card['id'] ?? 0);
|
|
|
|
|
+ if ($tid <= 0) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $grouped = $card['grouped_questions'] ?? ['choice' => [], 'fill' => [], 'answer' => []];
|
|
|
|
|
+ $q = $grouped['choice'][0] ?? $grouped['fill'][0] ?? $grouped['answer'][0] ?? null;
|
|
|
|
|
+ $temPayload = [
|
|
|
|
|
+ 'type' => $this->normalizeSimilarityQuestionType((string) ($q->question_type ?? '')),
|
|
|
|
|
+ 'stem' => $this->normalizeSimilarityText((string) ($q->stem ?? $q->content ?? $card['stem_preview'] ?? '')),
|
|
|
|
|
+ 'options' => $this->normalizeSimilarityOptions($q->options ?? null),
|
|
|
|
|
+ 'answer' => $this->normalizeSimilarityText((string) ($q->answer ?? '')),
|
|
|
|
|
+ 'solution' => $this->normalizeSimilarityText((string) ($q->solution ?? '')),
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ if ($temPayload['stem'] === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $bestQid = 0;
|
|
|
|
|
+ $bestScore = 0.0;
|
|
|
|
|
+ foreach ($normalizedQuestions as $qid => $questionPayload) {
|
|
|
|
|
+ if (($questionPayload['stem'] ?? '') === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($temPayload['type'] !== '' && $questionPayload['type'] !== '' && $temPayload['type'] !== $questionPayload['type']) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $score = $this->calculateCompositeSimilarityScore($temPayload, $questionPayload);
|
|
|
|
|
+ if ($score > $bestScore) {
|
|
|
|
|
+ $bestScore = $score;
|
|
|
|
|
+ $bestQid = (int) $qid;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if ($bestQid > 0) {
|
|
|
|
|
+ $out[$tid] = [
|
|
|
|
|
+ 'question_id' => $bestQid,
|
|
|
|
|
+ 'score' => round($bestScore, 1),
|
|
|
|
|
+ 'score_text' => number_format($bestScore, 1).'%',
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
public function updatedSelectedTemId(mixed $value): void
|
|
public function updatedSelectedTemId(mixed $value): void
|
|
|
{
|
|
{
|
|
|
if ($this->selectedTemId) {
|
|
if ($this->selectedTemId) {
|
|
@@ -491,6 +580,42 @@ class QuestionTemQualityReview extends Page
|
|
|
ImportTemToQuestionsJob::dispatch($id, Auth::id() ? (int) Auth::id() : null, $this->selectedKpCode);
|
|
ImportTemToQuestionsJob::dispatch($id, Auth::id() ? (int) Auth::id() : null, $this->selectedKpCode);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ public function markAsSimilarQuestion(int $temId, int $questionId): void
|
|
|
|
|
+ {
|
|
|
|
|
+ if ($temId <= 0 || $questionId <= 0) {
|
|
|
|
|
+ Notification::make()->title('参数无效')->warning()->send();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (! Schema::hasTable('questions_tem')) {
|
|
|
|
|
+ Notification::make()->title('questions_tem 表不存在')->danger()->send();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $updates = [
|
|
|
|
|
+ 'audit_reason' => "相似题{$questionId}",
|
|
|
|
|
+ 'updated_at' => now(),
|
|
|
|
|
+ ];
|
|
|
|
|
+ if (Schema::hasColumn('questions_tem', 'audit_status')) {
|
|
|
|
|
+ $updates['audit_status'] = -1;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ DB::table('questions_tem')
|
|
|
|
|
+ ->where('id', $temId)
|
|
|
|
|
+ ->update($updates);
|
|
|
|
|
+
|
|
|
|
|
+ $this->pendingImportTemIds = array_values(array_filter(
|
|
|
|
|
+ $this->pendingImportTemIds,
|
|
|
|
|
+ static fn ($x) => (int) $x !== $temId
|
|
|
|
|
+ ));
|
|
|
|
|
+
|
|
|
|
|
+ $this->forgetCurrentKpCaches();
|
|
|
|
|
+ Notification::make()
|
|
|
|
|
+ ->title("已标记为相似题 #{$questionId}")
|
|
|
|
|
+ ->success()
|
|
|
|
|
+ ->send();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
public function removeFromPendingImport(int $id): void
|
|
public function removeFromPendingImport(int $id): void
|
|
|
{
|
|
{
|
|
|
$this->pendingImportTemIds = array_values(array_filter(
|
|
$this->pendingImportTemIds = array_values(array_filter(
|
|
@@ -987,6 +1112,100 @@ class QuestionTemQualityReview extends Page
|
|
|
return trim($text);
|
|
return trim($text);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private function normalizeSimilarityText(string $text): string
|
|
|
|
|
+ {
|
|
|
|
|
+ if ($text === '') {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $text = MathFormulaProcessor::processFormulas($text);
|
|
|
|
|
+ $text = preg_replace('/<img\b[^>]*>/iu', ' ', $text) ?? $text;
|
|
|
|
|
+ $text = strip_tags($text);
|
|
|
|
|
+ $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
|
|
+ $text = mb_strtolower($text, 'UTF-8');
|
|
|
|
|
+ $text = preg_replace('/[[:punct:]\p{P}\p{S}]+/u', ' ', $text) ?? $text;
|
|
|
|
|
+ $text = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
|
|
|
|
+
|
|
|
|
|
+ return trim($text);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function normalizeSimilarityOptions(mixed $raw): string
|
|
|
|
|
+ {
|
|
|
|
|
+ if (is_string($raw)) {
|
|
|
|
|
+ $trimmed = trim($raw);
|
|
|
|
|
+ if ($trimmed === '') {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ $decoded = json_decode($trimmed, true);
|
|
|
|
|
+ if (is_array($decoded)) {
|
|
|
|
|
+ $raw = $decoded;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return $this->normalizeSimilarityText($trimmed);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (! is_array($raw) || $raw === []) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $parts = [];
|
|
|
|
|
+ foreach ($raw as $item) {
|
|
|
|
|
+ if (is_array($item)) {
|
|
|
|
|
+ $parts[] = (string) ($item['content'] ?? $item['text'] ?? $item['value'] ?? reset($item) ?? '');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $parts[] = (string) $item;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $this->normalizeSimilarityText(implode(' | ', $parts));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function normalizeSimilarityQuestionType(string $raw): string
|
|
|
|
|
+ {
|
|
|
|
|
+ $type = mb_strtolower(trim($raw), 'UTF-8');
|
|
|
|
|
+ return match (true) {
|
|
|
|
|
+ str_contains($type, 'choice'), str_contains($type, '选择') => 'choice',
|
|
|
|
|
+ str_contains($type, 'fill'), str_contains($type, 'blank'), str_contains($type, '填空') => 'fill',
|
|
|
|
|
+ str_contains($type, 'answer'), str_contains($type, '解答') => 'answer',
|
|
|
|
|
+ default => '',
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * @param array{type:string,stem:string,options:string,answer:string,solution:string} $tem
|
|
|
|
|
+ * @param array{type:string,stem:string,options:string,answer:string,solution:string} $question
|
|
|
|
|
+ */
|
|
|
|
|
+ private function calculateCompositeSimilarityScore(array $tem, array $question): float
|
|
|
|
|
+ {
|
|
|
|
|
+ $weights = [
|
|
|
|
|
+ 'stem' => 0.45,
|
|
|
|
|
+ 'options' => 0.20,
|
|
|
|
|
+ 'answer' => 0.20,
|
|
|
|
|
+ 'solution' => 0.15,
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ $weightedSum = 0.0;
|
|
|
|
|
+ $effectiveWeight = 0.0;
|
|
|
|
|
+ foreach ($weights as $field => $weight) {
|
|
|
|
|
+ $a = (string) ($tem[$field] ?? '');
|
|
|
|
|
+ $b = (string) ($question[$field] ?? '');
|
|
|
|
|
+ if ($a === '' || $b === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ similar_text($a, $b, $pct);
|
|
|
|
|
+ $score = max(0.0, min(100.0, (float) $pct));
|
|
|
|
|
+ $weightedSum += $score * $weight;
|
|
|
|
|
+ $effectiveWeight += $weight;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if ($effectiveWeight <= 0.0) {
|
|
|
|
|
+ return 0.0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return round($weightedSum / $effectiveWeight, 1);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private function formatMetaDatetime(mixed $value): ?string
|
|
private function formatMetaDatetime(mixed $value): ?string
|
|
|
{
|
|
{
|
|
|
if ($value === null) {
|
|
if ($value === null) {
|