| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605 |
- <?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\Support\Collection;
- 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 = 'knowledge_' . $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 格式非法,必须为 knowledge_ + 15位数字');
- }
- $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));
- $record = KnowledgeExplanation::updateOrCreate([
- 'knowledge_id' => $knowledgeId,
- ], [
- '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,
- ]);
- 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 格式非法,必须为 knowledge_ + 15位数字');
- }
- $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('/^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 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];
- }
- }
|