QuestionTemReviewService.php 14 KB

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