|
@@ -0,0 +1,659 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+namespace App\Services;
|
|
|
|
|
+
|
|
|
|
|
+use App\Models\KnowledgeExplanation;
|
|
|
|
|
+use App\Models\KnowledgePoint;
|
|
|
|
|
+use App\Models\MistakeRecord;
|
|
|
|
|
+use App\Models\PaperQuestion;
|
|
|
|
|
+use App\Models\Question;
|
|
|
|
|
+use Illuminate\Database\QueryException;
|
|
|
|
|
+use Illuminate\Support\Collection;
|
|
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
|
|
+
|
|
|
|
|
+class KnowledgeExplanationService
|
|
|
|
|
+{
|
|
|
|
|
+ public function __construct(
|
|
|
|
|
+ private readonly ExamPdfExportService $examPdfExportService
|
|
|
|
|
+ ) {
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function generateKnowledgeId(): string
|
|
|
|
|
+ {
|
|
|
|
|
+ // 对齐 paper_id 的数字段生成规则(PaperIdGenerator),并增加唯一性兜底
|
|
|
|
|
+ for ($i = 0; $i < 5; $i++) {
|
|
|
|
|
+ $numericId = PaperIdGenerator::generate();
|
|
|
|
|
+ $knowledgeId = 'paper_' . $numericId;
|
|
|
|
|
+ if (! $this->validateKnowledgeId($knowledgeId)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $exists = KnowledgeExplanation::query()
|
|
|
|
|
+ ->where('knowledge_id', $knowledgeId)
|
|
|
|
|
+ ->exists();
|
|
|
|
|
+ if (! $exists) {
|
|
|
|
|
+ return $knowledgeId;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ throw new \RuntimeException('无法生成唯一的 knowledge_id');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function prepareKnowledgeExplanation(array $payload): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $knowledgeId = (string) ($payload['knowledge_id'] ?? $this->generateKnowledgeId());
|
|
|
|
|
+ if (! $this->validateKnowledgeId($knowledgeId)) {
|
|
|
|
|
+ throw new \InvalidArgumentException('knowledge_id 格式非法,必须为 paper_ + 15位数字(兼容 knowledge_ 前缀)');
|
|
|
|
|
+ }
|
|
|
|
|
+ $studentId = (string) ($payload['student_id'] ?? '');
|
|
|
|
|
+ $teacherId = (string) ($payload['teacher_id'] ?? '');
|
|
|
|
|
+ $difficultyCategory = isset($payload['difficulty_category']) ? (int) $payload['difficulty_category'] : null;
|
|
|
|
|
+ $kpCodes = $this->resolveKpCodes($payload);
|
|
|
|
|
+ $knowledgePoints = $this->examPdfExportService->buildExplanations($kpCodes);
|
|
|
|
|
+
|
|
|
|
|
+ $history = $this->loadStudentQuestionHistory($studentId);
|
|
|
|
|
+ $casePayload = [];
|
|
|
|
|
+ foreach ($knowledgePoints as &$point) {
|
|
|
|
|
+ $kpCode = (string) ($point['kp_code'] ?? '');
|
|
|
|
|
+ if ($kpCode === '') {
|
|
|
|
|
+ $point['cases'] = [];
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $cases = $this->pickCasesForKnowledgePoint($kpCode, $history['done'], $history['wrong'], 5, $difficultyCategory);
|
|
|
|
|
+ $point['cases'] = $cases;
|
|
|
|
|
+ $casePayload[$kpCode] = array_map(static function (array $item): array {
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'question_id' => $item['question_id'],
|
|
|
|
|
+ 'source_type' => $item['source_type'],
|
|
|
|
|
+ 'is_wrong_case' => $item['is_wrong_case'],
|
|
|
|
|
+ 'child_kp_code' => $item['child_kp_code'] ?? null,
|
|
|
|
|
+ 'child_kp_name' => $item['child_kp_name'] ?? null,
|
|
|
|
|
+ 'source_label' => $item['source_label'] ?? null,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }, $cases);
|
|
|
|
|
+ }
|
|
|
|
|
+ unset($point);
|
|
|
|
|
+
|
|
|
|
|
+ $contentHash = hash('sha256', json_encode([
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ 'kp_codes' => $kpCodes,
|
|
|
|
|
+ 'case_payload' => $casePayload,
|
|
|
|
|
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
|
|
|
+
|
|
|
|
|
+ $recordPayload = [
|
|
|
|
|
+ 'teacher_id' => $teacherId,
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ 'assemble_type' => 22,
|
|
|
|
|
+ 'status' => 'processing',
|
|
|
|
|
+ 'kp_codes' => $kpCodes,
|
|
|
|
|
+ 'case_payload' => $casePayload,
|
|
|
|
|
+ 'content_hash' => $contentHash,
|
|
|
|
|
+ 'pdf_url' => null,
|
|
|
|
|
+ 'generated_at' => null,
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ $record = KnowledgeExplanation::updateOrCreate([
|
|
|
|
|
+ 'knowledge_id' => $knowledgeId,
|
|
|
|
|
+ ], $recordPayload);
|
|
|
|
|
+ } catch (QueryException $e) {
|
|
|
|
|
+ if (! $this->isDuplicatePrimaryKeyError($e)) {
|
|
|
|
|
+ throw $e;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 兼容线上历史表主键异常(id 非正常自增):
|
|
|
|
|
+ // 1) 若 knowledge_id 已存在则直接更新;
|
|
|
|
|
+ // 2) 否则手动分配一个递增 id 再插入,避免任务失败。
|
|
|
|
|
+ $record = $this->persistWithManualIdFallback($knowledgeId, $recordPayload);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'knowledge_id' => $knowledgeId,
|
|
|
|
|
+ 'record' => $record,
|
|
|
|
|
+ 'knowledge_points' => $knowledgePoints,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 仅用于本地模板调试预览:不落库,直接返回渲染数据。
|
|
|
|
|
+ */
|
|
|
|
|
+ public function previewKnowledgeExplanation(array $payload): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $knowledgeId = (string) ($payload['knowledge_id'] ?? $this->generateKnowledgeId());
|
|
|
|
|
+ if (! $this->validateKnowledgeId($knowledgeId)) {
|
|
|
|
|
+ throw new \InvalidArgumentException('knowledge_id 格式非法,必须为 paper_ + 15位数字(兼容 knowledge_ 前缀)');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $studentId = (string) ($payload['student_id'] ?? '');
|
|
|
|
|
+ $teacherId = (string) ($payload['teacher_id'] ?? '');
|
|
|
|
|
+ $difficultyCategory = isset($payload['difficulty_category']) ? (int) $payload['difficulty_category'] : null;
|
|
|
|
|
+ $kpCodes = $this->resolveKpCodes($payload);
|
|
|
|
|
+ $knowledgePoints = $this->examPdfExportService->buildExplanations($kpCodes);
|
|
|
|
|
+ $history = $this->loadStudentQuestionHistory($studentId);
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($knowledgePoints as &$point) {
|
|
|
|
|
+ $kpCode = (string) ($point['kp_code'] ?? '');
|
|
|
|
|
+ if ($kpCode === '') {
|
|
|
|
|
+ $point['cases'] = [];
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $point['cases'] = $this->pickCasesForKnowledgePoint($kpCode, $history['done'], $history['wrong'], 5, $difficultyCategory);
|
|
|
|
|
+ }
|
|
|
|
|
+ unset($point);
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'knowledge_id' => $knowledgeId,
|
|
|
|
|
+ 'student_id' => $studentId,
|
|
|
|
|
+ 'teacher_id' => $teacherId,
|
|
|
|
|
+ 'knowledge_points' => $knowledgePoints,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 使用已保存的 knowledge_id/kp_codes/case_payload 重建 PDF 渲染数据。
|
|
|
|
|
+ * 知识点正文会读取当前库中最新内容,案例题目按 case_payload 中的 question_id 复原。
|
|
|
|
|
+ */
|
|
|
|
|
+ public function rebuildKnowledgePointsForRecord(KnowledgeExplanation $record): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $kpCodes = is_array($record->kp_codes) ? $record->kp_codes : [];
|
|
|
|
|
+ $knowledgePoints = $this->examPdfExportService->buildExplanations($kpCodes);
|
|
|
|
|
+ $casePayload = is_array($record->case_payload) ? $record->case_payload : [];
|
|
|
|
|
+
|
|
|
|
|
+ if (! empty($casePayload)) {
|
|
|
|
|
+ $questionIds = [];
|
|
|
|
|
+ foreach ($casePayload as $items) {
|
|
|
|
|
+ if (! is_array($items)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ foreach ($items as $item) {
|
|
|
|
|
+ $questionId = (int) ($item['question_id'] ?? 0);
|
|
|
|
|
+ if ($questionId > 0) {
|
|
|
|
|
+ $questionIds[$questionId] = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $questionsById = empty($questionIds)
|
|
|
|
|
+ ? collect()
|
|
|
|
|
+ : Question::query()
|
|
|
|
|
+ ->whereIn('id', array_keys($questionIds))
|
|
|
|
|
+ ->get(['id', 'kp_code', 'stem', 'options', 'meta', 'answer', 'solution', 'question_type', 'difficulty'])
|
|
|
|
|
+ ->keyBy('id');
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($knowledgePoints as &$point) {
|
|
|
|
|
+ $kpCode = (string) ($point['kp_code'] ?? '');
|
|
|
|
|
+ $point['cases'] = [];
|
|
|
|
|
+ $items = $casePayload[$kpCode] ?? [];
|
|
|
|
|
+ if (! is_array($items)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ foreach ($items as $item) {
|
|
|
|
|
+ if (! is_array($item)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $questionId = (int) ($item['question_id'] ?? 0);
|
|
|
|
|
+ $question = $questionsById->get($questionId);
|
|
|
|
|
+ if (! $question instanceof Question) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $sourceType = (string) ($item['source_type'] ?? 'fallback');
|
|
|
|
|
+ $case = $this->formatCaseQuestion($question, $sourceType, (bool) ($item['is_wrong_case'] ?? false));
|
|
|
|
|
+ $case['child_kp_code'] = $item['child_kp_code'] ?? null;
|
|
|
|
|
+ $case['child_kp_name'] = $item['child_kp_name'] ?? null;
|
|
|
|
|
+ $case['source_label'] = $item['source_label'] ?? ($case['source_label'] ?? null);
|
|
|
|
|
+ $point['cases'][] = $case;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ unset($point);
|
|
|
|
|
+
|
|
|
|
|
+ return $knowledgePoints;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $payload = [
|
|
|
|
|
+ 'knowledge_id' => $record->knowledge_id,
|
|
|
|
|
+ 'student_id' => $record->student_id,
|
|
|
|
|
+ 'teacher_id' => $record->teacher_id,
|
|
|
|
|
+ 'kp_codes' => $kpCodes,
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ return (array) ($this->previewKnowledgeExplanation($payload)['knowledge_points'] ?? []);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function validateKnowledgeId(string $knowledgeId): bool
|
|
|
|
|
+ {
|
|
|
|
|
+ if (! preg_match('/^(?:paper_|knowledge_)([1-9]\d{14})$/', $knowledgeId, $matches)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return PaperIdGenerator::validate($matches[1]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function resolveKpCodes(array $payload): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $raw = $payload['kp_codes'] ?? $payload['kp_code_list'] ?? [];
|
|
|
|
|
+ if (! is_array($raw)) {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ $codes = [];
|
|
|
|
|
+ foreach ($raw as $code) {
|
|
|
|
|
+ $value = trim((string) $code);
|
|
|
|
|
+ if ($value === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $codes[$value] = true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return array_keys($codes);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function isDuplicatePrimaryKeyError(QueryException $e): bool
|
|
|
|
|
+ {
|
|
|
|
|
+ $message = (string) $e->getMessage();
|
|
|
|
|
+
|
|
|
|
|
+ return str_contains($message, 'Integrity constraint violation: 1062')
|
|
|
|
|
+ && str_contains($message, 'knowledge_explanations.PRIMARY');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function persistWithManualIdFallback(string $knowledgeId, array $recordPayload): KnowledgeExplanation
|
|
|
|
|
+ {
|
|
|
|
|
+ $existing = KnowledgeExplanation::query()
|
|
|
|
|
+ ->where('knowledge_id', $knowledgeId)
|
|
|
|
|
+ ->first();
|
|
|
|
|
+ if ($existing) {
|
|
|
|
|
+ $existing->fill($recordPayload);
|
|
|
|
|
+ $existing->save();
|
|
|
|
|
+
|
|
|
|
|
+ return $existing;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return DB::transaction(function () use ($knowledgeId, $recordPayload): KnowledgeExplanation {
|
|
|
|
|
+ $table = (new KnowledgeExplanation())->getTable();
|
|
|
|
|
+ $maxId = (int) DB::table($table)->lockForUpdate()->max('id');
|
|
|
|
|
+ $nextId = $maxId + 1;
|
|
|
|
|
+ $now = now();
|
|
|
|
|
+
|
|
|
|
|
+ DB::table($table)->insert(array_merge($recordPayload, [
|
|
|
|
|
+ 'id' => $nextId,
|
|
|
|
|
+ 'knowledge_id' => $knowledgeId,
|
|
|
|
|
+ 'created_at' => $now,
|
|
|
|
|
+ 'updated_at' => $now,
|
|
|
|
|
+ ]));
|
|
|
|
|
+
|
|
|
|
|
+ return KnowledgeExplanation::query()
|
|
|
|
|
+ ->where('knowledge_id', $knowledgeId)
|
|
|
|
|
+ ->firstOrFail();
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function loadStudentQuestionHistory(string $studentId): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $done = PaperQuestion::query()
|
|
|
|
|
+ ->select('paper_questions.question_bank_id')
|
|
|
|
|
+ ->join('papers', 'papers.paper_id', '=', 'paper_questions.paper_id')
|
|
|
|
|
+ ->where('papers.student_id', $studentId)
|
|
|
|
|
+ ->whereNotNull('paper_questions.question_bank_id')
|
|
|
|
|
+ ->pluck('paper_questions.question_bank_id')
|
|
|
|
|
+ ->map(static fn ($id) => (int) $id)
|
|
|
|
|
+ ->filter(static fn ($id) => $id > 0)
|
|
|
|
|
+ ->unique()
|
|
|
|
|
+ ->values()
|
|
|
|
|
+ ->all();
|
|
|
|
|
+
|
|
|
|
|
+ $wrong = MistakeRecord::query()
|
|
|
|
|
+ ->where('student_id', $studentId)
|
|
|
|
|
+ ->whereNotNull('question_id')
|
|
|
|
|
+ ->pluck('question_id')
|
|
|
|
|
+ ->map(static fn ($id) => (int) $id)
|
|
|
|
|
+ ->filter(static fn ($id) => $id > 0)
|
|
|
|
|
+ ->unique()
|
|
|
|
|
+ ->values()
|
|
|
|
|
+ ->all();
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'done' => $done,
|
|
|
|
|
+ 'wrong' => $wrong,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function pickCasesForKnowledgePoint(string $kpCode, array $doneIds, array $wrongIds, int $limit, ?int $difficultyCategory = null): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $children = KnowledgePoint::query()
|
|
|
|
|
+ ->where('parent_kp_code', $kpCode)
|
|
|
|
|
+ ->whereNotNull('kp_code')
|
|
|
|
|
+ ->where('kp_code', '!=', '')
|
|
|
|
|
+ ->orderBy('id')
|
|
|
|
|
+ ->limit($limit)
|
|
|
|
|
+ ->get(['kp_code', 'name']);
|
|
|
|
|
+
|
|
|
|
|
+ if ($children->isNotEmpty()) {
|
|
|
|
|
+ $selected = collect();
|
|
|
|
|
+ $usedQuestionIds = [];
|
|
|
|
|
+ foreach ($children as $child) {
|
|
|
|
|
+ if ($selected->count() >= $limit) {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ $case = $this->pickSingleCaseForKnowledgePoint((string) $child->kp_code, $doneIds, $wrongIds, $usedQuestionIds, $difficultyCategory);
|
|
|
|
|
+ if ($case === null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $case['child_kp_code'] = (string) $child->kp_code;
|
|
|
|
|
+ $case['child_kp_name'] = (string) ($child->name ?: $child->kp_code);
|
|
|
|
|
+ $case['source_label'] = (string) ($child->name ?: $child->kp_code);
|
|
|
|
|
+ $selected->push($case);
|
|
|
|
|
+ $usedQuestionIds[] = (int) $case['question_id'];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $selected->values()->all();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $selected = collect();
|
|
|
|
|
+
|
|
|
|
|
+ $pick = function (Collection $bucket, string $sourceType, bool $isWrong) use ($selected, $limit): void {
|
|
|
|
|
+ foreach ($bucket as $question) {
|
|
|
|
|
+ if ($selected->count() >= $limit) {
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ if ($selected->contains('question_id', (int) $question->id)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $selected->push($this->formatCaseQuestion($question, $sourceType, $isWrong));
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ $pick($this->queryBucket($kpCode, static function ($query) use ($doneIds) {
|
|
|
|
|
+ if (! empty($doneIds)) {
|
|
|
|
|
+ $query->whereNotIn('id', $doneIds);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, $difficultyCategory), 'new', false);
|
|
|
|
|
+
|
|
|
|
|
+ if ($selected->count() < $limit) {
|
|
|
|
|
+ $pick($this->queryBucket($kpCode, static function ($query) use ($wrongIds) {
|
|
|
|
|
+ if (empty($wrongIds)) {
|
|
|
|
|
+ $query->whereRaw('1=0');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ $query->whereIn('id', $wrongIds);
|
|
|
|
|
+ }, $difficultyCategory), 'wrong', true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if ($selected->count() < $limit) {
|
|
|
|
|
+ $pick($this->queryBucket($kpCode, static function ($query) use ($doneIds) {
|
|
|
|
|
+ if (empty($doneIds)) {
|
|
|
|
|
+ $query->whereRaw('1=0');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ $query->whereIn('id', $doneIds);
|
|
|
|
|
+ }, $difficultyCategory), 'reviewed', false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if ($selected->count() < $limit) {
|
|
|
|
|
+ $excluded = $selected->pluck('question_id')->all();
|
|
|
|
|
+ $pick($this->queryBucket($kpCode, static function ($query) use ($excluded) {
|
|
|
|
|
+ if (! empty($excluded)) {
|
|
|
|
|
+ $query->whereNotIn('id', $excluded);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, $difficultyCategory), 'fallback', false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $selected->values()->all();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function pickSingleCaseForKnowledgePoint(string $kpCode, array $doneIds, array $wrongIds, array $excludedIds = [], ?int $difficultyCategory = null): ?array
|
|
|
|
|
+ {
|
|
|
|
|
+ $pickOne = function (callable $mutator, string $sourceType, bool $isWrong) use ($kpCode, $difficultyCategory): ?array {
|
|
|
|
|
+ $bucket = $this->queryBucket($kpCode, $mutator, $difficultyCategory);
|
|
|
|
|
+ $question = $bucket->first();
|
|
|
|
|
+ if (! $question) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $this->formatCaseQuestion($question, $sourceType, $isWrong);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ $baseExclude = $excludedIds;
|
|
|
|
|
+
|
|
|
|
|
+ $case = $pickOne(static function ($query) use ($doneIds, $baseExclude) {
|
|
|
|
|
+ if (! empty($doneIds)) {
|
|
|
|
|
+ $query->whereNotIn('id', $doneIds);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (! empty($baseExclude)) {
|
|
|
|
|
+ $query->whereNotIn('id', $baseExclude);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 'new', false);
|
|
|
|
|
+ if ($case) {
|
|
|
|
|
+ return $case;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $case = $pickOne(static function ($query) use ($wrongIds, $baseExclude) {
|
|
|
|
|
+ if (empty($wrongIds)) {
|
|
|
|
|
+ $query->whereRaw('1=0');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ $query->whereIn('id', $wrongIds);
|
|
|
|
|
+ if (! empty($baseExclude)) {
|
|
|
|
|
+ $query->whereNotIn('id', $baseExclude);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 'wrong', true);
|
|
|
|
|
+ if ($case) {
|
|
|
|
|
+ return $case;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $case = $pickOne(static function ($query) use ($doneIds, $baseExclude) {
|
|
|
|
|
+ if (empty($doneIds)) {
|
|
|
|
|
+ $query->whereRaw('1=0');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ $query->whereIn('id', $doneIds);
|
|
|
|
|
+ if (! empty($baseExclude)) {
|
|
|
|
|
+ $query->whereNotIn('id', $baseExclude);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 'reviewed', false);
|
|
|
|
|
+ if ($case) {
|
|
|
|
|
+ return $case;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $pickOne(static function ($query) use ($baseExclude) {
|
|
|
|
|
+ if (! empty($baseExclude)) {
|
|
|
|
|
+ $query->whereNotIn('id', $baseExclude);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 'fallback', false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function queryBucket(string $kpCode, callable $mutator, ?int $difficultyCategory = null): Collection
|
|
|
|
|
+ {
|
|
|
|
|
+ $query = Question::query()
|
|
|
|
|
+ ->where('kp_code', $kpCode)
|
|
|
|
|
+ ->whereNotNull('stem')
|
|
|
|
|
+ ->where('stem', '!=', '')
|
|
|
|
|
+ ->whereNotNull('answer')
|
|
|
|
|
+ ->whereNotNull('solution')
|
|
|
|
|
+ ->inRandomOrder()
|
|
|
|
|
+ ->limit(80);
|
|
|
|
|
+
|
|
|
|
|
+ $mutator($query);
|
|
|
|
|
+
|
|
|
|
|
+ $candidates = $query->get(['id', 'kp_code', 'stem', 'options', 'meta', 'answer', 'solution', 'question_type', 'difficulty']);
|
|
|
|
|
+
|
|
|
|
|
+ return $this->rankCandidates($candidates, $difficultyCategory, 30);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function rankCandidates(Collection $candidates, ?int $difficultyCategory, int $limit): Collection
|
|
|
|
|
+ {
|
|
|
|
|
+ if ($candidates->isEmpty()) {
|
|
|
|
|
+ return collect();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 无难度偏好时:随机抽样
|
|
|
|
|
+ if ($difficultyCategory === null || $difficultyCategory < 0 || $difficultyCategory > 4) {
|
|
|
|
|
+ return $candidates->shuffle()->take($limit)->values();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $target = $this->targetDifficultyByCategory($difficultyCategory);
|
|
|
|
|
+
|
|
|
|
|
+ // 先按难度贴合度排序,再加随机扰动,避免每次都返回同题
|
|
|
|
|
+ $ranked = $candidates
|
|
|
|
|
+ ->shuffle()
|
|
|
|
|
+ ->map(function (Question $q) use ($target): array {
|
|
|
|
|
+ $difficulty = $this->normalizeDifficultyValue($q->difficulty);
|
|
|
|
|
+ $distance = abs($difficulty - $target);
|
|
|
|
|
+ $jitter = mt_rand(0, 1000) / 10000;
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'question' => $q,
|
|
|
|
|
+ 'rank_score' => $distance + $jitter,
|
|
|
|
|
+ ];
|
|
|
|
|
+ })
|
|
|
|
|
+ ->sortBy('rank_score')
|
|
|
|
|
+ ->pluck('question')
|
|
|
|
|
+ ->take($limit)
|
|
|
|
|
+ ->values();
|
|
|
|
|
+
|
|
|
|
|
+ return $ranked;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function targetDifficultyByCategory(int $difficultyCategory): float
|
|
|
|
|
+ {
|
|
|
|
|
+ return match ($difficultyCategory) {
|
|
|
|
|
+ 0 => 0.25,
|
|
|
|
|
+ 1 => 0.40,
|
|
|
|
|
+ 2 => 0.55,
|
|
|
|
|
+ 3 => 0.70,
|
|
|
|
|
+ 4 => 0.85,
|
|
|
|
|
+ default => 0.55,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function normalizeDifficultyValue(mixed $difficulty): float
|
|
|
|
|
+ {
|
|
|
|
|
+ if (! is_numeric($difficulty)) {
|
|
|
|
|
+ return 0.55;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $value = (float) $difficulty;
|
|
|
|
|
+ if ($value > 1) {
|
|
|
|
|
+ $value = $value / 5;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return max(0.0, min(1.0, $value));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function formatCaseQuestion(Question $question, string $sourceType, bool $isWrongCase): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $sourceLabel = match ($sourceType) {
|
|
|
|
|
+ 'wrong' => '错题讲解',
|
|
|
|
|
+ 'reviewed' => '已做题',
|
|
|
|
|
+ 'fallback' => '补充题',
|
|
|
|
|
+ default => '新题',
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ $stemRaw = (string) ($question->stem ?? '');
|
|
|
|
|
+ $options = $this->normalizeQuestionOptions($question->options);
|
|
|
|
|
+ if (empty($options) && is_array($question->meta ?? null)) {
|
|
|
|
|
+ $meta = (array) $question->meta;
|
|
|
|
|
+ $options = $this->normalizeQuestionOptions($meta['options'] ?? $meta['question_options'] ?? null);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (empty($options)) {
|
|
|
|
|
+ [$stemWithoutExtractedOptions, $extractedOptions] = $this->extractChoiceOptionsFromStem((string) ($question->stem ?? ''));
|
|
|
|
|
+ if (! empty($extractedOptions)) {
|
|
|
|
|
+ $stemRaw = $stemWithoutExtractedOptions;
|
|
|
|
|
+ $options = $extractedOptions;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'question_id' => (int) $question->id,
|
|
|
|
|
+ 'source_type' => $sourceType,
|
|
|
|
|
+ 'is_wrong_case' => $isWrongCase,
|
|
|
|
|
+ 'source_label' => $sourceLabel,
|
|
|
|
|
+ 'stem' => $stemRaw,
|
|
|
|
|
+ 'options' => $options,
|
|
|
|
|
+ 'answer' => (string) ($question->answer ?? ''),
|
|
|
|
|
+ 'solution' => (string) ($question->solution ?? ''),
|
|
|
|
|
+ 'question_type' => (string) ($question->question_type ?? ''),
|
|
|
|
|
+ 'difficulty' => is_numeric($question->difficulty) ? (float) $question->difficulty : null,
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 标准化选择题选项,输出为 ['A' => '...', 'B' => '...']。
|
|
|
|
|
+ */
|
|
|
|
|
+ private function normalizeQuestionOptions(mixed $rawOptions): array
|
|
|
|
|
+ {
|
|
|
|
|
+ if (is_string($rawOptions)) {
|
|
|
|
|
+ $decoded = json_decode($rawOptions, true);
|
|
|
|
|
+ if (is_array($decoded)) {
|
|
|
|
|
+ $rawOptions = $decoded;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (! is_array($rawOptions) || empty($rawOptions)) {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $normalized = [];
|
|
|
|
|
+ foreach ($rawOptions as $key => $value) {
|
|
|
|
|
+ $label = strtoupper(trim((string) $key));
|
|
|
|
|
+ $content = '';
|
|
|
|
|
+
|
|
|
|
|
+ if (is_array($value)) {
|
|
|
|
|
+ // 兼容多种选项结构:['A' => '...'] / [['label'=>'A','content'=>'...']]
|
|
|
|
|
+ $candidateLabel = (string) ($value['label'] ?? $value['key'] ?? '');
|
|
|
|
|
+ if ($candidateLabel !== '') {
|
|
|
|
|
+ $label = strtoupper(trim($candidateLabel));
|
|
|
|
|
+ }
|
|
|
|
|
+ $content = (string) ($value['content'] ?? $value['value'] ?? $value['text'] ?? '');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ $content = (string) $value;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (trim($content) === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (! preg_match('/^[A-Z]$/', $label)) {
|
|
|
|
|
+ $idx = count($normalized);
|
|
|
|
|
+ $label = chr(ord('A') + $idx);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 选项文本保持原样,公式与 HTML 转义由卷子共用 partial(exam-choice-options)处理
|
|
|
|
|
+ $normalized[$label] = $content;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return $normalized;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 兜底:从题干中提取 A/B/C/D 选项文本(兼容旧库数据)。
|
|
|
|
|
+ */
|
|
|
|
|
+ private function extractChoiceOptionsFromStem(string $stem): array
|
|
|
|
|
+ {
|
|
|
|
|
+ if (trim($stem) === '') {
|
|
|
|
|
+ return [$stem, []];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $pattern = '/(?:^|<br\s*\/?>|\r?\n)\s*([A-H])\s*[\..、::]\s*(.+?)(?=(?:<br\s*\/?>|\r?\n)\s*[A-H]\s*[\..、::]\s*|$)/isu';
|
|
|
|
|
+ preg_match_all($pattern, $stem, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
|
|
|
|
+ if (empty($matches)) {
|
|
|
|
|
+ return [$stem, []];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $options = [];
|
|
|
|
|
+ foreach ($matches as $m) {
|
|
|
|
|
+ $label = strtoupper(trim((string) ($m[1][0] ?? '')));
|
|
|
|
|
+ $content = trim((string) ($m[2][0] ?? ''));
|
|
|
|
|
+ if ($label === '' || $content === '') {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ $options[$label] = $content;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (empty($options)) {
|
|
|
|
|
+ return [$stem, []];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ $firstOptionOffset = (int) ($matches[0][0][1] ?? 0);
|
|
|
|
|
+ $stemWithoutOptions = trim(substr($stem, 0, $firstOptionOffset));
|
|
|
|
|
+
|
|
|
|
|
+ return [$stemWithoutOptions !== '' ? $stemWithoutOptions : $stem, $options];
|
|
|
|
|
+ }
|
|
|
|
|
+}
|