'7', '八年级' => '8', '九年级' => '9', '高一' => '10', '高二' => '11', '高三' => '12'] as $label => $value) { if (Str::contains($context, $label)) { return $value; } } return null; } /** * 推断章节 */ public function inferChapter(string $context): ?string { if (preg_match('/第[一二三四五六七八九十]+章[^\\n]*/u', $context, $match)) { return $match[0]; } return null; } /** * 匹配目录节点 ID */ public function matchCatalogNodeId(string $context, ?int $textbookId): ?int { if (!$textbookId) { return null; } $needle = trim($context); if ($needle === '') { return null; } $nodes = TextbookCatalog::query() ->where('textbook_id', $textbookId) ->orderBy('depth') ->orderBy('sort_order') ->get(['id', 'title']); $chapterNeedle = $this->extractChapterLabel($needle); $normalizedNeedle = $this->normalizeText($chapterNeedle ?: $needle); $bestId = null; $bestScore = 0; foreach ($nodes as $node) { $title = (string) $node->title; if ($title === '') { continue; } $normalizedTitle = $this->normalizeText($title); if ($normalizedTitle === '') { continue; } if (Str::contains($normalizedNeedle, $normalizedTitle) || Str::contains($normalizedTitle, $normalizedNeedle)) { return (int) $node->id; } $score = 0; $chapterInTitle = $this->extractChapterLabel($title); if ($chapterNeedle && $chapterInTitle && $chapterNeedle === $chapterInTitle) { $score += 30; } $similarity = $this->similarityScore($normalizedNeedle, $normalizedTitle); $score += $similarity; if ($score > $bestScore) { $bestScore = $score; $bestId = (int) $node->id; } } return $bestScore >= 60 ? $bestId : null; } /** * 从文件名推断教材 (兼容旧接口) */ public function resolveTextbookFromFilename(array $parsed): ?Textbook { return $this->findBestTextbook([ 'grade' => $parsed['grade'] ?? null, 'term' => $parsed['term'] ?? null, 'series' => $parsed['series'] ?? null, 'subject' => $parsed['subject'] ?? null, ]); } /** * 根据一组属性推断最匹配的教材 * 根据属性推断最匹配的教材 */ public function findBestTextbook(array $attributes): ?Textbook { // 核心参数获取 $seriesId = $attributes['series_id'] ?? null; if (!$seriesId && !empty($attributes['series'])) { $formal = $this->resolveSeries($attributes['series']); $seriesId = $formal?->id; } if (!$seriesId) { return null; } $grade = (string)($attributes['grade'] ?? ''); $term = (string)($attributes['term'] ?? ''); $semester = $this->termToSemester($term); // 仅在当前系列下筛选 $textbooks = Textbook::query()->where('series_id', $seriesId)->get(); if ($textbooks->isEmpty()) { return null; } $best = null; $maxScore = -1; foreach ($textbooks as $tb) { $score = 20; // 基础系列分 // 年级匹配 if ($grade && (int)$tb->grade === (int)$grade) { $score += 5; } // 学期匹配 if ($semester && (int)$tb->semester === $semester) { $score += 5; } if ($score > $maxScore) { $maxScore = $score; $best = $tb; } } return ($best && $maxScore >= 20) ? $best : null; } /** * 推断卷子类型 */ public function inferSourceType(string $context): ?string { $map = [ '单元' => '单元测试', '月考' => '月考', '期中' => '期中考试', '期末' => '期末考试', '中考' => '中考套卷', '模拟' => '模拟考试', '考前' => '模拟考试', '真题' => '真题卷', '周测' => '周练/周测', '周练' => '周练/周测', '练' => '课时练习', ]; foreach ($map as $key => $val) { if (Str::contains($context, $key)) { return $val; } } return '综合测试'; } /** * 将解析出的系列俗称 (如 北师大版) 匹配到数据库正式系列模型 (如 北师大版(新)) */ public function resolveSeries(?string $hint): ?\App\Models\TextbookSeries { if (!$hint) { return null; } $hint = trim((string)$hint); $series = \App\Models\TextbookSeries::query()->get(); $best = null; $bestScore = 0; foreach ($series as $s) { $name = (string)$s->name; // 标准化名称:去掉括号里的(新)、(旧)等干扰项 $standardName = preg_replace('/((新|旧|修订版|实验版|.*制))/u', '', $name); // 包含匹配:如 "北师大版" 包含在 "北师大版(新)" 剥离后的 "北师大版" 中 if (Str::contains($name, $hint) || Str::contains($hint, $name) || Str::contains($hint, $standardName) || Str::contains($standardName, $hint)) { return $s; } // 模糊得分 $score = $this->similarityScore($this->normalizeText($hint), $this->normalizeText($name)); if ($score > $bestScore) { $bestScore = $score; $best = $s; } } return $bestScore >= 70 ? $best : null; } /** * 获取教材建议 */ public function getTextbookSuggestions(SourcePaper $paper, array $parsedImportFilename = []): array { $title = (string) ($paper->title ?? $paper->full_title ?? ''); $context = Str::lower($title); $grade = $paper->grade ? (int) $paper->grade : ($parsedImportFilename['grade'] ?? null); $semester = $this->termToSemester($paper->term) ?? $this->termToSemester($parsedImportFilename['term'] ?? null); $seriesHint = $paper->textbook_series ?: ($parsedImportFilename['series'] ?? null); $subjectHint = $parsedImportFilename['subject'] ?? null; $suggestions = []; $textbooks = Textbook::query()->with('series')->get(); foreach ($textbooks as $textbook) { $score = 0; if ($grade && (int) $textbook->grade === $grade) { $score += 3; } if ($semester && (int) $textbook->semester === $semester) { $score += 3; } $official = Str::lower((string) $textbook->official_title); if ($official !== '' && Str::contains($context, $official)) { $score += 4; } $aliases = $this->normalizeAliases($textbook->aliases ?? []); foreach ($aliases as $alias) { $alias = Str::lower((string) $alias); if ($alias !== '' && Str::contains($context, $alias)) { $score += 2; } } $seriesName = $textbook->series?->name ?? null; if ($seriesHint && $seriesName && (Str::contains((string) $seriesName, (string) $seriesHint) || Str::contains((string) $seriesHint, (string) $seriesName))) { $score += 5; } if ($subjectHint) { $subjectHint = Str::lower((string) $subjectHint); $official = Str::lower((string) $textbook->official_title); if ($official !== '' && Str::contains($official, $subjectHint)) { $score += 1; } } if ($score > 0) { $suggestions[] = [ 'id' => $textbook->id, 'title' => $textbook->official_title, 'series' => $textbook->series?->name ?? '未归类系列', 'grade' => $textbook->grade, 'semester' => $textbook->semester, 'score' => $score, ]; } } usort($suggestions, fn ($a, $b) => $b['score'] <=> $a['score']); return array_slice($suggestions, 0, 5); } /** * 将学期字符串归一化为 semester 数字 (1=上, 2=下) */ public function termToSemester(?string $term): ?int { if (!$term) { return null; } if (Str::contains($term, '上')) { return 1; } if (Str::contains($term, '下')) { return 2; } return null; } private function normalizeText(string $text): string { $text = Str::lower($text); $text = preg_replace('/\\s+/u', '', $text); $text = preg_replace('/[,。.、,\\.\\-—_()\\(\\)【】\\[\\]::;;!!??]/u', '', $text); return $text ?? ''; } private function extractChapterLabel(string $text): ?string { if (preg_match('/第\\s*[一二三四五六七八九十0-9]+\\s*[章节]/u', $text, $match)) { return trim($match[0]); } return null; } private function similarityScore(string $a, string $b): int { if ($a === '' || $b === '') { return 0; } similar_text($a, $b, $percent); return (int) round($percent); } private function normalizeAliases(array|string|null $aliases): array { if (is_array($aliases)) { return $aliases; } if (!is_string($aliases) || trim($aliases) === '') { return []; } $decoded = json_decode($aliases, true); if (is_array($decoded)) { return $decoded; } return array_values(array_filter(array_map('trim', preg_split('/[,,;;\\n]+/u', $aliases)))); } }