QuestionTemReviewService.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. <?php
  2. namespace App\Services;
  3. use App\Models\Question;
  4. use App\Support\AnswerSolutionStepMarkerInjector;
  5. use Illuminate\Support\Facades\DB;
  6. use Illuminate\Support\Facades\Schema;
  7. use Illuminate\Support\Str;
  8. /**
  9. * questions_tem 质检入库页:知识点排序、PDF 口径预览、写入 questions
  10. */
  11. class QuestionTemReviewService
  12. {
  13. /** Session 键:供「入库题目调难度」页列举的本轮已入库 question.id */
  14. public const SESSION_TUNING_QUESTION_IDS = 'import_difficulty_tune_question_ids';
  15. /**
  16. * 将成功入库的正式题 ID 合并进会话,供调难度页使用。
  17. *
  18. * @param list<int> $questionIds
  19. */
  20. public static function mergeQuestionIdsIntoTuningSession(array $questionIds): void
  21. {
  22. $questionIds = array_values(array_unique(array_filter(array_map('intval', $questionIds))));
  23. if ($questionIds === []) {
  24. return;
  25. }
  26. $existing = session(self::SESSION_TUNING_QUESTION_IDS, []);
  27. if (! is_array($existing)) {
  28. $existing = [];
  29. }
  30. session([
  31. self::SESSION_TUNING_QUESTION_IDS => array_values(array_unique(array_merge($existing, $questionIds))),
  32. ]);
  33. }
  34. /**
  35. * 左侧:按 questions 表中该知识点正式题数量升序(题少的在前),仅包含 questions_tem 中出现过的 kp_code
  36. *
  37. * @param ?int $limit 为 null 时不截断(质检页需完整列表 + 搜索,否则题量大的 KP 如 B01 会落在 500 条之后而无法检索)
  38. * @return list<array{kp_code: string, kp_name: string, questions_count: int, tem_count: int, tem_importable_count: int}>
  39. */
  40. public function listKnowledgePointsByQuestionsAsc(?int $limit = null): array
  41. {
  42. if (! Schema::hasTable('questions_tem')) {
  43. return [];
  44. }
  45. $temKps = DB::table('questions_tem')
  46. ->whereNotNull('kp_code')
  47. ->where('kp_code', '!=', '')
  48. ->distinct()
  49. ->pluck('kp_code')
  50. ->all();
  51. if ($temKps === []) {
  52. return [];
  53. }
  54. $kpNames = [];
  55. if (Schema::hasTable('knowledge_points')) {
  56. $kpNames = DB::table('knowledge_points')
  57. ->whereIn('kp_code', $temKps)
  58. ->pluck('name', 'kp_code')
  59. ->toArray();
  60. }
  61. $counts = [];
  62. if (Schema::hasTable('questions')) {
  63. $counts = DB::table('questions')
  64. ->whereIn('kp_code', $temKps)
  65. ->selectRaw('kp_code, COUNT(*) as c')
  66. ->groupBy('kp_code')
  67. ->pluck('c', 'kp_code')
  68. ->toArray();
  69. }
  70. $temCounts = DB::table('questions_tem')
  71. ->whereIn('kp_code', $temKps)
  72. ->selectRaw('kp_code, COUNT(*) as c')
  73. ->groupBy('kp_code')
  74. ->pluck('c', 'kp_code')
  75. ->toArray();
  76. $importableMap = $this->importableTemCountsForKpCodes($temKps);
  77. $rows = [];
  78. foreach ($temKps as $kp) {
  79. $name = isset($kpNames[$kp]) ? trim((string) $kpNames[$kp]) : '';
  80. $rows[] = [
  81. 'kp_code' => $kp,
  82. 'kp_name' => $name,
  83. 'questions_count' => (int) ($counts[$kp] ?? 0),
  84. 'tem_count' => (int) ($temCounts[$kp] ?? 0),
  85. 'tem_importable_count' => (int) ($importableMap[$kp] ?? 0),
  86. ];
  87. }
  88. usort($rows, function ($a, $b) {
  89. if ($a['questions_count'] === $b['questions_count']) {
  90. return strcmp($a['kp_code'], $b['kp_code']);
  91. }
  92. return $a['questions_count'] <=> $b['questions_count'];
  93. });
  94. if ($limit !== null && $limit > 0) {
  95. return array_slice($rows, 0, $limit);
  96. }
  97. return $rows;
  98. }
  99. /**
  100. * 与中间列表 {@see listTemQuestionsForKp}(excludeFormalDuplicates=true)同口径:本 KP 下、题干未与正式库重复、且题干非空的 questions_tem 行数。
  101. *
  102. * @param list<string> $kpCodes
  103. * @return array<string, int> kp_code => count
  104. */
  105. private function importableTemCountsForKpCodes(array $kpCodes): array
  106. {
  107. $kpCodes = array_values(array_unique(array_filter($kpCodes)));
  108. if ($kpCodes === [] || ! Schema::hasTable('questions_tem')) {
  109. return [];
  110. }
  111. if (! Schema::hasTable('questions')) {
  112. return collect($kpCodes)
  113. ->mapWithKeys(fn (string $k): array => [$k => (int) DB::table('questions_tem')->where('kp_code', $k)->count()])
  114. ->all();
  115. }
  116. $placeholders = implode(',', array_fill(0, count($kpCodes), '?'));
  117. $sql = "
  118. SELECT t.kp_code AS kp_code, COUNT(*) AS c
  119. FROM questions_tem t
  120. WHERE t.kp_code IN ($placeholders)
  121. AND t.stem IS NOT NULL AND t.stem != ''
  122. AND NOT EXISTS (
  123. SELECT 1 FROM questions q
  124. WHERE q.kp_code = t.kp_code
  125. AND q.stem != ''
  126. AND q.stem = t.stem
  127. )
  128. GROUP BY t.kp_code
  129. ";
  130. $out = array_fill_keys($kpCodes, 0);
  131. foreach (DB::select($sql, $kpCodes) as $row) {
  132. $k = (string) ($row->kp_code ?? '');
  133. if ($k !== '') {
  134. $out[$k] = (int) ($row->c ?? 0);
  135. }
  136. }
  137. return $out;
  138. }
  139. /**
  140. * 与入库、判重一致:questions_tem.stem(当前库表仅此 longtext 题干列,无 content)。
  141. */
  142. public function normalizedStemFromTemRow(object|array $row): string
  143. {
  144. $arr = is_array($row) ? $row : (array) $row;
  145. return (string) ($arr['stem'] ?? '');
  146. }
  147. /**
  148. * 中间:某知识点下 questions_tem 题目(限制条数)
  149. *
  150. * @param bool $excludeFormalDuplicates 为 true 时排除「正式库 questions 已存在同 kp_code + 同 stem」的待审行,与 {@see existsDuplicateInQuestions} 一致,减少无效质检
  151. * @return list<object>
  152. */
  153. public function listTemQuestionsForKp(string $kpCode, int $limit = 300, bool $excludeFormalDuplicates = true): array
  154. {
  155. if (! Schema::hasTable('questions_tem') || $kpCode === '') {
  156. return [];
  157. }
  158. $formalStemSet = [];
  159. if ($excludeFormalDuplicates && Schema::hasTable('questions')) {
  160. foreach (DB::table('questions')->where('kp_code', $kpCode)->pluck('stem') as $stem) {
  161. if ($stem === null || $stem === '') {
  162. continue;
  163. }
  164. $formalStemSet[(string) $stem] = true;
  165. }
  166. }
  167. $out = [];
  168. $q = DB::table('questions_tem')->where('kp_code', $kpCode)->orderBy('id');
  169. foreach ($q->lazyById(200) as $row) {
  170. if ($excludeFormalDuplicates && $formalStemSet !== []) {
  171. $stem = $this->normalizedStemFromTemRow($row);
  172. if ($stem !== '' && isset($formalStemSet[$stem])) {
  173. continue;
  174. }
  175. }
  176. $out[] = $row;
  177. if (count($out) >= $limit) {
  178. break;
  179. }
  180. }
  181. return $out;
  182. }
  183. /**
  184. * 与 ExamPdfExportService::renderPreviewHtml 一致:公式预处理 + 解析换行,供页面 KaTeX 渲染
  185. *
  186. * @param array<string, mixed> $row questions_tem 一行转数组
  187. * @return array{stem: string, options: ?array, answer: string, solution: string, question_type: string}
  188. */
  189. public function buildPdfStylePreviewFields(array $row): array
  190. {
  191. $stem = (string) ($row['stem'] ?? '');
  192. $answer = (string) ($row['answer'] ?? $row['correct_answer'] ?? '');
  193. $solution = AnswerSolutionStepMarkerInjector::enrichIfNeeded(
  194. (string) ($row['solution'] ?? ''),
  195. $row['question_type'] ?? $row['tags'] ?? 'answer'
  196. );
  197. $questionType = strtolower((string) ($row['question_type'] ?? $row['tags'] ?? 'answer'));
  198. $options = $row['options'] ?? null;
  199. if (is_string($options) && trim($options) !== '') {
  200. $decoded = json_decode($options, true);
  201. $options = is_array($decoded) ? $decoded : null;
  202. }
  203. $processedStem = MathFormulaProcessor::processFormulas($stem);
  204. $processedAnswer = MathFormulaProcessor::processFormulas($answer);
  205. $processedSolution = MathFormulaProcessor::processFormulas($this->formatNewlinesForPdf($solution));
  206. $processedOptions = null;
  207. if (is_array($options)) {
  208. $processedOptions = [];
  209. foreach ($options as $key => $value) {
  210. if (is_array($value)) {
  211. $text = (string) ($value['text'] ?? $value['value'] ?? reset($value) ?? '');
  212. $processedOptions[$key] = MathFormulaProcessor::processFormulas($text);
  213. } else {
  214. $processedOptions[$key] = MathFormulaProcessor::processFormulas((string) $value);
  215. }
  216. }
  217. }
  218. return [
  219. 'stem' => $processedStem,
  220. 'options' => $processedOptions,
  221. 'answer' => $processedAnswer,
  222. 'solution' => $processedSolution,
  223. 'question_type' => $questionType,
  224. ];
  225. }
  226. private function formatNewlinesForPdf(?string $text): string
  227. {
  228. if ($text === null || $text === '') {
  229. return '';
  230. }
  231. $text = preg_replace('/\\\\n(?![a-zA-Z])/', '<br>', $text);
  232. return (string) preg_replace('/(<br>\s*){3,}/', '<br><br>', $text);
  233. }
  234. /**
  235. * 是否已在 questions 中存在(同 kp + 题干完全一致则视为重复)
  236. */
  237. public function existsDuplicateInQuestions(string $kpCode, string $stem): bool
  238. {
  239. if ($stem === '' || ! Schema::hasTable('questions')) {
  240. return false;
  241. }
  242. return Question::query()
  243. ->where('kp_code', $kpCode)
  244. ->where('stem', $stem)
  245. ->exists();
  246. }
  247. /**
  248. * 待审行默认难度:与入库写入规则一致,限制在 [0.00, 0.90] 并保留两位小数
  249. *
  250. * @param object|array<string, mixed> $row
  251. */
  252. public function defaultDifficultyForTemRow(object|array $row): float
  253. {
  254. $arr = is_array($row) ? $row : (array) $row;
  255. $d = 0.5;
  256. if (array_key_exists('difficulty', $arr) && $arr['difficulty'] !== null && $arr['difficulty'] !== '') {
  257. $d = (float) $arr['difficulty'];
  258. }
  259. return max(0.0, min(0.9, round($d, 2)));
  260. }
  261. /**
  262. * 将 questions_tem 一行写入 questions(入库)
  263. *
  264. * @param ?float $difficultyOverride 若传入则作为 questions.difficulty(仍限制 0.00–0.90、两位小数);null 时按表内字段或默认 0.5
  265. * @return array{ok: bool, message: string, question_id: ?int}
  266. */
  267. public function importTemRowToQuestions(int $temId, ?float $difficultyOverride = null): array
  268. {
  269. if (! Schema::hasTable('questions_tem')) {
  270. return ['ok' => false, 'message' => 'questions_tem 表不存在', 'question_id' => null];
  271. }
  272. $row = DB::table('questions_tem')->where('id', $temId)->first();
  273. if (! $row) {
  274. return ['ok' => false, 'message' => '待入库题目不存在', 'question_id' => null];
  275. }
  276. $arr = (array) $row;
  277. $stem = $this->normalizedStemFromTemRow($arr);
  278. $kp = (string) ($arr['kp_code'] ?? '');
  279. if ($stem === '' || $kp === '') {
  280. return ['ok' => false, 'message' => '题干或知识点为空', 'question_id' => null];
  281. }
  282. if ($this->existsDuplicateInQuestions($kp, $stem)) {
  283. return ['ok' => false, 'message' => '正式库已存在相同知识点且题干一致的题目', 'question_id' => null];
  284. }
  285. $options = $arr['options'] ?? null;
  286. if (is_string($options) && trim($options) !== '') {
  287. $decoded = json_decode($options, true);
  288. $options = is_array($decoded) ? $decoded : null;
  289. }
  290. $questionType = $this->normalizeQuestionTypeForDb($arr['question_type'] ?? $arr['tags'] ?? 'answer');
  291. $difficulty = $difficultyOverride !== null
  292. ? max(0.0, min(0.9, round($difficultyOverride, 2)))
  293. : $this->defaultDifficultyForTemRow($arr);
  294. $rawSolution = (string) ($arr['solution'] ?? '');
  295. $solutionStored = AnswerSolutionStepMarkerInjector::enrichIfNeeded($rawSolution, $arr['question_type'] ?? $arr['tags'] ?? 'answer');
  296. $payload = [
  297. 'question_code' => 'QT'.strtoupper(Str::random(12)),
  298. 'question_type' => $questionType,
  299. 'kp_code' => $kp,
  300. 'stem' => $stem,
  301. 'options' => $options,
  302. 'answer' => (string) ($arr['answer'] ?? $arr['correct_answer'] ?? ''),
  303. 'solution' => $solutionStored,
  304. 'difficulty' => $difficulty,
  305. 'source' => 'questions_tem_review',
  306. 'tags' => is_string($arr['tags'] ?? null) ? $arr['tags'] : null,
  307. 'meta' => [
  308. 'imported_from' => 'questions_tem',
  309. 'questions_tem_id' => $temId,
  310. ],
  311. ];
  312. if (isset($arr['textbook_id'])) {
  313. $payload['textbook_id'] = (int) $arr['textbook_id'];
  314. }
  315. try {
  316. $question = Question::query()->create($payload);
  317. $updates = [];
  318. if (Schema::hasColumn('questions', 'audit_status')) {
  319. $updates['audit_status'] = 0;
  320. }
  321. if (Schema::hasColumn('questions', 'grade') && isset($arr['grade'])) {
  322. $updates['grade'] = (int) $arr['grade'];
  323. }
  324. if ($updates !== []) {
  325. DB::table('questions')->where('id', $question->id)->update($updates);
  326. }
  327. return [
  328. 'ok' => true,
  329. 'message' => '已入库',
  330. 'question_id' => (int) $question->id,
  331. ];
  332. } catch (\Throwable $e) {
  333. return [
  334. 'ok' => false,
  335. 'message' => '入库失败:'.$e->getMessage(),
  336. 'question_id' => null,
  337. ];
  338. }
  339. }
  340. /**
  341. * 批量将 questions_tem 行写入 questions(每行逻辑与 importTemRowToQuestions 相同)
  342. *
  343. * @param list<int> $temIds
  344. * @return array{imported: int, skipped: int, failed: int, lines: list<string>}
  345. */
  346. /**
  347. * @return array{imported: int, skipped: int, failed: int, lines: list<string>, imported_question_ids: list<int>}
  348. */
  349. public function importTemIdsToQuestions(array $temIds): array
  350. {
  351. $imported = 0;
  352. $skipped = 0;
  353. $failed = 0;
  354. $lines = [];
  355. $importedQuestionIds = [];
  356. foreach (array_unique(array_filter(array_map('intval', $temIds))) as $id) {
  357. if ($id <= 0) {
  358. continue;
  359. }
  360. $r = $this->importTemRowToQuestions($id);
  361. if ($r['ok']) {
  362. $imported++;
  363. if (! empty($r['question_id'])) {
  364. $importedQuestionIds[] = (int) $r['question_id'];
  365. }
  366. continue;
  367. }
  368. $msg = $r['message'];
  369. if (
  370. str_contains($msg, '正式库已存在')
  371. || str_contains($msg, '题干或知识点为空')
  372. ) {
  373. $skipped++;
  374. } else {
  375. $failed++;
  376. }
  377. if (count($lines) < 30) {
  378. $lines[] = "#{$id}: {$msg}";
  379. }
  380. }
  381. return [
  382. 'imported' => $imported,
  383. 'skipped' => $skipped,
  384. 'failed' => $failed,
  385. 'lines' => $lines,
  386. 'imported_question_ids' => $importedQuestionIds,
  387. ];
  388. }
  389. private function normalizeQuestionTypeForDb(mixed $raw): string
  390. {
  391. $t = strtolower(trim((string) $raw));
  392. if (str_contains($t, 'choice') || str_contains($t, '选择')) {
  393. return 'choice';
  394. }
  395. if (str_contains($t, 'fill') || str_contains($t, 'blank') || str_contains($t, '填空')) {
  396. return 'fill';
  397. }
  398. return 'answer';
  399. }
  400. }