QuestionLocalService.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759
  1. <?php
  2. namespace App\Services;
  3. use App\Models\KnowledgePoint;
  4. use App\Models\Question;
  5. use App\Services\KnowledgeServiceApi;
  6. use Illuminate\Support\Collection;
  7. use Illuminate\Support\Facades\Cache;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Log;
  10. use Illuminate\Support\Str;
  11. class QuestionLocalService
  12. {
  13. private DifficultyDistributionService $difficultyDistributionService;
  14. public function __construct(?DifficultyDistributionService $difficultyDistributionService = null)
  15. {
  16. $this->difficultyDistributionService = $difficultyDistributionService
  17. ?? app(DifficultyDistributionService::class);
  18. }
  19. public function listQuestions(int $page = 1, int $perPage = 50, array $filters = []): array
  20. {
  21. $query = $this->applyFilters(Question::query(), $filters);
  22. $paginator = $query->orderByDesc('id')->paginate($perPage, ['*'], 'page', $page);
  23. $data = $this->mapQuestions(collect($paginator->items()));
  24. return [
  25. 'data' => $data,
  26. 'meta' => [
  27. 'page' => $paginator->currentPage(),
  28. 'per_page' => $paginator->perPage(),
  29. 'total' => $paginator->total(),
  30. 'total_pages' => $paginator->lastPage(),
  31. ],
  32. ];
  33. }
  34. public function getQuestionById(int $id): ?array
  35. {
  36. $question = Question::find($id);
  37. if (!$question) {
  38. return null;
  39. }
  40. return $this->mapQuestion($question);
  41. }
  42. public function getQuestionByCode(string $questionCode): ?array
  43. {
  44. $question = Question::where('question_code', $questionCode)->first();
  45. if (!$question) {
  46. return null;
  47. }
  48. return $this->mapQuestion($question);
  49. }
  50. public function updateQuestionByCode(string $questionCode, array $payload): bool
  51. {
  52. $question = Question::where('question_code', $questionCode)->first();
  53. if (!$question) {
  54. return false;
  55. }
  56. $question->fill($this->normalizePayload($payload));
  57. $question->save();
  58. return true;
  59. }
  60. public function deleteQuestionByCode(string $questionCode): bool
  61. {
  62. $question = Question::where('question_code', $questionCode)->first();
  63. if (!$question) {
  64. return false;
  65. }
  66. $question->delete();
  67. return true;
  68. }
  69. public function deleteQuestionById(int $id): bool
  70. {
  71. $question = Question::find($id);
  72. if (!$question) {
  73. return false;
  74. }
  75. $question->delete();
  76. return true;
  77. }
  78. public function searchQuestions(string $query, int $limit = 20): array
  79. {
  80. $questions = Question::query()
  81. ->search($query)
  82. ->orderByDesc('id')
  83. ->limit($limit)
  84. ->get();
  85. return [
  86. 'data' => $this->mapQuestions($questions),
  87. ];
  88. }
  89. public function getQuestionsByIds(array $ids): array
  90. {
  91. if (empty($ids)) {
  92. return ['data' => []];
  93. }
  94. $questions = Question::query()
  95. ->whereIn('id', $ids)
  96. ->orderByDesc('id')
  97. ->get();
  98. return [
  99. 'data' => $this->mapQuestions($questions),
  100. ];
  101. }
  102. public function getQuestionsByKpCode(string $kpCode, int $limit = 100): array
  103. {
  104. $questions = Question::query()
  105. ->where('kp_code', $kpCode)
  106. ->orderByDesc('id')
  107. ->limit($limit)
  108. ->get();
  109. return [
  110. 'data' => $this->mapQuestions($questions),
  111. ];
  112. }
  113. public function getStatistics(array $filters = []): array
  114. {
  115. $baseQuery = $this->applyFilters(Question::query(), $filters);
  116. $total = (clone $baseQuery)->count();
  117. $byDifficulty = (clone $baseQuery)
  118. ->selectRaw('difficulty, COUNT(*) as total')
  119. ->groupBy('difficulty')
  120. ->pluck('total', 'difficulty')
  121. ->toArray();
  122. $byTypeRaw = (clone $baseQuery)
  123. ->selectRaw('question_type, COUNT(*) as total')
  124. ->groupBy('question_type')
  125. ->pluck('total', 'question_type')
  126. ->toArray();
  127. $byType = [];
  128. foreach ($byTypeRaw as $type => $count) {
  129. $label = $this->mapQuestionTypeLabel((string) $type);
  130. $byType[$label] = ($byType[$label] ?? 0) + $count;
  131. }
  132. $byKp = (clone $baseQuery)
  133. ->selectRaw('kp_code, COUNT(*) as total')
  134. ->groupBy('kp_code')
  135. ->pluck('total', 'kp_code')
  136. ->toArray();
  137. $bySource = (clone $baseQuery)
  138. ->selectRaw('source, COUNT(*) as total')
  139. ->groupBy('source')
  140. ->pluck('total', 'source')
  141. ->toArray();
  142. return [
  143. 'total' => $total,
  144. 'by_difficulty' => $byDifficulty,
  145. 'by_type' => $byType,
  146. 'by_kp' => $byKp,
  147. 'by_source' => $bySource,
  148. ];
  149. }
  150. public function generateQuestions(array $params): array
  151. {
  152. $kpCode = $params['kp_code'] ?? null;
  153. // 允许 kp_code 为空,此时从所有可用题目中选择
  154. if (!$kpCode) {
  155. // 从 params 中获取 kp_codes 数组
  156. $kpCodes = $params['kp_codes'] ?? [];
  157. if (is_string($kpCodes)) {
  158. $kpCodes = array_map('trim', explode(',', $kpCodes));
  159. }
  160. if (is_array($kpCodes) && !empty($kpCodes)) {
  161. $kpCode = $kpCodes[0]; // 使用第一个知识点
  162. } else {
  163. // 如果没有指定知识点,从数据库中随机选择一个可用的知识点
  164. $availableKp = Question::query()
  165. ->whereNotNull('kp_code')
  166. ->where('kp_code', '!=', '')
  167. ->distinct()
  168. ->pluck('kp_code')
  169. ->first();
  170. if ($availableKp) {
  171. $kpCode = $availableKp;
  172. } else {
  173. return [
  174. 'success' => false,
  175. 'message' => '系统中没有可用的题目,请先添加题目数据',
  176. ];
  177. }
  178. }
  179. }
  180. $count = max(1, (int) ($params['count'] ?? 1));
  181. $keyword = (string) ($params['keyword'] ?? '');
  182. $type = $params['type'] ?? null;
  183. $difficulty = $params['difficulty'] ?? null;
  184. $skills = $params['skills'] ?? [];
  185. $solutionService = app(AiSolutionService::class);
  186. $created = [];
  187. DB::transaction(function () use (
  188. $count,
  189. $kpCode,
  190. $keyword,
  191. $type,
  192. $difficulty,
  193. $skills,
  194. $solutionService,
  195. &$created,
  196. $params
  197. ) {
  198. for ($i = 1; $i <= $count; $i++) {
  199. $questionCode = $this->generateQuestionCode();
  200. $stemSuffix = $keyword ? "({$keyword})" : '';
  201. $stem = "【AI生成】{$kpCode} 题目 {$i}{$stemSuffix}";
  202. $options = null;
  203. $answer = null;
  204. $questionType = $type ?? 'CALCULATION';
  205. if (in_array($questionType, ['CHOICE', 'MULTIPLE_CHOICE'], true)) {
  206. $options = [
  207. ['label' => 'A', 'text' => '选项 A'],
  208. ['label' => 'B', 'text' => '选项 B'],
  209. ['label' => 'C', 'text' => '选项 C'],
  210. ['label' => 'D', 'text' => '选项 D'],
  211. ];
  212. $answer = 'A';
  213. }
  214. $solution = $solutionService->generateSolution($stem, [
  215. 'kp_code' => $kpCode,
  216. 'difficulty' => $difficulty,
  217. 'question_type' => $questionType,
  218. ]);
  219. $question = Question::create([
  220. 'question_code' => $questionCode,
  221. 'kp_code' => $kpCode,
  222. 'stem' => $stem,
  223. 'options' => $options,
  224. 'answer' => $answer,
  225. 'solution' => $solution['solution'] ?? null,
  226. 'difficulty' => $difficulty,
  227. 'source' => 'ai::local',
  228. 'question_type' => $questionType,
  229. 'meta' => [
  230. 'skills' => $skills,
  231. 'prompt_template' => $params['prompt_template'] ?? null,
  232. 'strategy' => $params['strategy'] ?? null,
  233. 'generated_at' => now()->toDateTimeString(),
  234. 'solution_steps' => $solution['steps'] ?? [],
  235. ],
  236. ]);
  237. $created[] = $this->mapQuestion($question);
  238. }
  239. });
  240. return [
  241. 'success' => true,
  242. 'message' => '生成完成',
  243. 'count' => count($created),
  244. 'data' => $created,
  245. ];
  246. }
  247. public function importQuestions(array $questions): array
  248. {
  249. if (empty($questions)) {
  250. return [
  251. 'success' => false,
  252. 'message' => '题目为空',
  253. 'count' => 0,
  254. ];
  255. }
  256. $created = 0;
  257. DB::transaction(function () use ($questions, &$created) {
  258. foreach ($questions as $payload) {
  259. $questionCode = $payload['question_code'] ?? $this->generateQuestionCode();
  260. $question = Question::firstOrNew(['question_code' => $questionCode]);
  261. $question->fill($this->normalizePayload($payload));
  262. $question->save();
  263. $created++;
  264. }
  265. });
  266. return [
  267. 'success' => true,
  268. 'message' => '导入完成',
  269. 'count' => $created,
  270. ];
  271. }
  272. public function selectQuestionsForExam(int $totalQuestions, array $filters): array
  273. {
  274. $query = Question::query();
  275. // 【新增】只获取审核通过的题目(audit_status = 0 表示合格)
  276. $query->where('audit_status', 0);
  277. if (!empty($filters['kp_codes'])) {
  278. $query->whereIn('kp_code', $filters['kp_codes']);
  279. }
  280. if (!empty($filters['skills'])) {
  281. $skills = array_values(array_filter($filters['skills']));
  282. if (!empty($skills)) {
  283. $query->where(function ($q) use ($skills) {
  284. foreach ($skills as $skill) {
  285. $q->orWhereJsonContains('meta->skills', $skill);
  286. }
  287. });
  288. }
  289. }
  290. if (!empty($filters['grade'])) {
  291. $stageGrade = $this->normalizeStageGrade((int) $filters['grade']);
  292. if ($stageGrade !== null) {
  293. $query->where('grade', $stageGrade);
  294. }
  295. }
  296. $questions = $query->get();
  297. $selected = $this->applyRatioSelection($questions, $totalQuestions, $filters);
  298. return $this->mapQuestions(collect($selected));
  299. }
  300. public function getKnowledgePointOptions(): array
  301. {
  302. return KnowledgePoint::query()
  303. ->orderBy('kp_code')
  304. ->pluck('name', 'kp_code')
  305. ->toArray();
  306. }
  307. public function getSkillNameMapping(?string $kpCode = null): array
  308. {
  309. return [];
  310. }
  311. private function applyFilters($query, array $filters)
  312. {
  313. if (!empty($filters['kp_code'])) {
  314. $query->where('kp_code', $filters['kp_code']);
  315. }
  316. if (!empty($filters['difficulty'])) {
  317. $query->where('difficulty', $filters['difficulty']);
  318. }
  319. if (!empty($filters['type'])) {
  320. $query->where('question_type', $filters['type']);
  321. }
  322. if (!empty($filters['search'])) {
  323. $query->search($filters['search']);
  324. }
  325. if (!empty($filters['grade'])) {
  326. $stageGrade = $this->normalizeStageGrade((int) $filters['grade']);
  327. if ($stageGrade !== null) {
  328. $query->where('grade', $stageGrade);
  329. }
  330. }
  331. return $query;
  332. }
  333. private function normalizeStageGrade(int $grade): ?int
  334. {
  335. if ($grade <= 0) {
  336. return null;
  337. }
  338. return $grade <= 9 ? 2 : 3;
  339. }
  340. private function normalizePayload(array $payload): array
  341. {
  342. $normalized = [
  343. 'question_code' => $payload['question_code'] ?? null,
  344. 'kp_code' => $payload['kp_code'] ?? null,
  345. 'stem' => $payload['stem'] ?? ($payload['content'] ?? ''),
  346. 'options' => $payload['options'] ?? null,
  347. 'answer' => $payload['answer'] ?? null,
  348. 'solution' => $payload['solution'] ?? null,
  349. 'difficulty' => $payload['difficulty'] ?? null,
  350. 'source' => $payload['source'] ?? null,
  351. 'tags' => $payload['tags'] ?? null,
  352. 'question_type' => $payload['question_type'] ?? ($payload['type'] ?? null),
  353. 'meta' => $payload['meta'] ?? null,
  354. ];
  355. if (isset($payload['skills'])) {
  356. $meta = $normalized['meta'] ?? [];
  357. $meta['skills'] = is_array($payload['skills'])
  358. ? $payload['skills']
  359. : array_filter(array_map('trim', explode(',', (string) $payload['skills'])));
  360. $normalized['meta'] = $meta;
  361. }
  362. return array_filter($normalized, static fn ($value) => $value !== null);
  363. }
  364. private function mapQuestions(Collection $questions): array
  365. {
  366. $kpCodes = $questions->pluck('kp_code')->filter()->unique()->values();
  367. $kpMap = $this->resolveKnowledgePointNames($kpCodes->all());
  368. return $questions->map(function (Question $question) use ($kpMap) {
  369. $data = $this->mapQuestion($question);
  370. $data['kp_name'] = $kpMap[$question->kp_code] ?? null;
  371. return $data;
  372. })->values()->all();
  373. }
  374. private function mapQuestion(Question $question): array
  375. {
  376. $meta = $question->meta ?? [];
  377. $data = [
  378. 'id' => $question->id,
  379. 'question_code' => $question->question_code,
  380. 'kp_code' => $question->kp_code,
  381. 'stem' => $question->stem,
  382. 'options' => $question->options,
  383. 'answer' => $question->answer,
  384. 'solution' => $question->solution,
  385. 'difficulty' => $question->difficulty,
  386. 'source' => $question->source,
  387. 'tags' => $question->tags,
  388. 'type' => $question->question_type,
  389. 'question_type' => $question->question_type,
  390. 'skills' => $meta['skills'] ?? [],
  391. 'meta' => $meta,
  392. 'created_at' => $question->created_at?->toDateTimeString(),
  393. 'updated_at' => $question->updated_at?->toDateTimeString(),
  394. ];
  395. return MathFormulaProcessor::processQuestionData($data);
  396. }
  397. private function generateQuestionCode(): string
  398. {
  399. return 'Q' . Str::upper(Str::random(10));
  400. }
  401. private function mapQuestionTypeLabel(string $type): string
  402. {
  403. return match (strtoupper($type)) {
  404. 'CHOICE' => '选择题',
  405. 'MULTIPLE_CHOICE' => '多选题',
  406. 'FILL_IN_THE_BLANK', 'FILL' => '填空题',
  407. 'CALCULATION', 'WORD_PROBLEM', 'ANSWER' => '解答题',
  408. 'PROOF' => '证明题',
  409. default => '其他',
  410. };
  411. }
  412. private function applyRatioSelection(Collection $questions, int $totalQuestions, array $filters): array
  413. {
  414. $questionsByType = $questions->groupBy(fn (Question $q) => $q->question_type ?? 'CALCULATION');
  415. $questionsByDifficulty = $questions->groupBy(function (Question $q) {
  416. $difficulty = (float) ($q->difficulty ?? 0);
  417. if ($difficulty <= 0.4) {
  418. return 'easy';
  419. }
  420. if ($difficulty <= 0.7) {
  421. return 'medium';
  422. }
  423. return 'hard';
  424. });
  425. $typeRatio = $filters['question_type_ratio'] ?? [];
  426. $difficultyRatio = $filters['difficulty_ratio'] ?? [];
  427. $selected = collect();
  428. if (!empty($typeRatio)) {
  429. foreach ($typeRatio as $type => $ratio) {
  430. $bucket = $questionsByType->get($type, collect());
  431. $count = (int) round($totalQuestions * (float) $ratio);
  432. $selected = $selected->merge($bucket->shuffle()->take($count));
  433. }
  434. }
  435. if (!empty($difficultyRatio)) {
  436. foreach ($difficultyRatio as $key => $ratio) {
  437. $bucketKey = $this->normalizeDifficultyKey($key);
  438. $bucket = $questionsByDifficulty->get($bucketKey, collect());
  439. $count = (int) round($totalQuestions * (float) $ratio);
  440. $selected = $selected->merge($bucket->shuffle()->take($count));
  441. }
  442. }
  443. if ($selected->isEmpty()) {
  444. return $questions->shuffle()->take($totalQuestions)->values()->all();
  445. }
  446. if ($selected->count() < $totalQuestions) {
  447. $missing = $totalQuestions - $selected->count();
  448. $fill = $questions->diff($selected)->shuffle()->take($missing);
  449. $selected = $selected->merge($fill);
  450. }
  451. return $selected->values()->all();
  452. }
  453. private function normalizeDifficultyKey(string $key): string
  454. {
  455. if (in_array($key, ['easy', 'medium', 'hard'], true)) {
  456. return $key;
  457. }
  458. $value = (float) $key;
  459. if ($value <= 0.4) {
  460. return 'easy';
  461. }
  462. if ($value <= 0.7) {
  463. return 'medium';
  464. }
  465. return 'hard';
  466. }
  467. private function resolveKnowledgePointNames(array $kpCodes): array
  468. {
  469. $kpCodes = array_values(array_filter(array_unique($kpCodes)));
  470. if (empty($kpCodes)) {
  471. return [];
  472. }
  473. $cacheKey = 'kp-name-map-' . md5(implode('|', $kpCodes));
  474. return Cache::remember($cacheKey, now()->addMinutes(30), function () use ($kpCodes) {
  475. $kpMap = KnowledgePoint::query()
  476. ->whereIn('kp_code', $kpCodes)
  477. ->pluck('name', 'kp_code')
  478. ->toArray();
  479. $missing = array_values(array_filter($kpCodes, fn ($code) => empty($kpMap[$code])));
  480. if (empty($missing)) {
  481. return $kpMap;
  482. }
  483. try {
  484. $api = app(KnowledgeServiceApi::class);
  485. $all = $api->listKnowledgePoints();
  486. foreach ($all as $kp) {
  487. $code = $kp['kp_code'] ?? null;
  488. $name = $kp['cn_name'] ?? $kp['name'] ?? null;
  489. if ($code && $name && in_array($code, $missing, true)) {
  490. $kpMap[$code] = $name;
  491. }
  492. }
  493. } catch (\Throwable $e) {
  494. // Fallback: keep existing mapping
  495. }
  496. return $kpMap;
  497. });
  498. }
  499. /**
  500. * 根据难度系数分布选择题目
  501. *
  502. * @param array $questions 候选题目数组
  503. * @param int $totalQuestions 总题目数
  504. * @param int $difficultyCategory 难度类别 (0-4)
  505. * - 0: 0-0.1占90%,0.1-0.25占10%
  506. * - 1: 0-0.25占90%,0.25-1占10%
  507. * - 2: 0.25-0.5范围占50%,<0.25占25%,>0.5占25%
  508. * - 3: 0.5-0.75范围占50%,<0.5占25%,>0.75占25%
  509. * - 4: 0.75-1范围占50%,其他占50%
  510. * @param array $filters 其他筛选条件
  511. * @return array 分布后的题目
  512. */
  513. public function selectQuestionsByDifficultyDistribution(array $questions, int $totalQuestions, int $difficultyCategory = 1, array $filters = []): array
  514. {
  515. Log::info('QuestionLocalService: 根据难度系数分布选择题目', [
  516. 'total_questions' => $totalQuestions,
  517. 'difficulty_category' => $difficultyCategory,
  518. ]);
  519. if (empty($questions)) {
  520. Log::warning('QuestionLocalService: 输入题目为空');
  521. return [];
  522. }
  523. // 【恢复】简化逻辑,避免复杂处理
  524. $distribution = $this->difficultyDistributionService->calculateDistribution($difficultyCategory, $totalQuestions);
  525. // 按难度范围分桶
  526. $buckets = $this->difficultyDistributionService->groupQuestionsByDifficultyRange($questions, $difficultyCategory);
  527. Log::info('QuestionLocalService: 题目分桶', [
  528. 'buckets' => array_map(fn($bucket) => count($bucket), $buckets),
  529. 'total_input' => count($questions),
  530. 'distribution' => $distribution
  531. ]);
  532. // 根据分布选择题目
  533. $selected = [];
  534. $usedIds = [];
  535. foreach ($distribution as $level => $config) {
  536. $targetCount = $config['count'];
  537. if ($targetCount <= 0) {
  538. Log::debug('QuestionLocalService: 跳过难度层级', [
  539. 'level' => $level,
  540. 'target_count' => $targetCount
  541. ]);
  542. continue;
  543. }
  544. $rangeKey = $this->difficultyDistributionService->mapDifficultyLevelToRangeKey($level, $difficultyCategory);
  545. $bucket = $buckets[$rangeKey] ?? [];
  546. // 随机打乱
  547. shuffle($bucket);
  548. // 选择题目
  549. $taken = 0;
  550. foreach ($bucket as $question) {
  551. if ($taken >= $targetCount) break;
  552. $questionId = $question['id'] ?? null;
  553. if ($questionId && !in_array($questionId, $usedIds)) {
  554. $selected[] = $question;
  555. $usedIds[] = $questionId;
  556. $taken++;
  557. }
  558. }
  559. // 【修复】如果某个难度范围题目不足,记录日志但不截断
  560. if ($taken < $targetCount) {
  561. Log::warning('QuestionLocalService: 难度范围题目不足,允许后续补充', [
  562. 'level' => $level,
  563. 'range_key' => $rangeKey,
  564. 'target' => $targetCount,
  565. 'actual' => $taken,
  566. 'bucket_size' => count($bucket)
  567. ]);
  568. }
  569. }
  570. // 如果数量不足,从剩余题目中补充
  571. if (count($selected) < $totalQuestions) {
  572. Log::warning('QuestionLocalService: 开始补充题目(难度分布无法满足要求)', [
  573. 'need_more' => $totalQuestions - count($selected),
  574. 'selected_count' => count($selected),
  575. 'difficulty_category' => $difficultyCategory,
  576. 'note' => '优先从次级桶补充,不足再放宽'
  577. ]);
  578. $needMore = $totalQuestions - count($selected);
  579. $supplemented = 0;
  580. $supplementOrder = $this->difficultyDistributionService->getSupplementOrder($difficultyCategory);
  581. foreach ($supplementOrder as $bucketKey) {
  582. if ($supplemented >= $needMore) {
  583. break;
  584. }
  585. $bucket = $buckets[$bucketKey] ?? [];
  586. if (empty($bucket)) {
  587. continue;
  588. }
  589. shuffle($bucket);
  590. foreach ($bucket as $q) {
  591. if ($supplemented >= $needMore) {
  592. break;
  593. }
  594. $id = $q['id'] ?? null;
  595. if ($id && !in_array($id, $usedIds)) {
  596. $selected[] = $q;
  597. $usedIds[] = $id;
  598. $supplemented++;
  599. }
  600. }
  601. }
  602. if ($supplemented < $needMore) {
  603. $remaining = [];
  604. foreach ($questions as $q) {
  605. $id = $q['id'] ?? null;
  606. if ($id && !in_array($id, $usedIds)) {
  607. $remaining[] = $q;
  608. }
  609. }
  610. shuffle($remaining);
  611. $supplementCount = min($needMore - $supplemented, count($remaining));
  612. $selected = array_merge($selected, array_slice($remaining, 0, $supplementCount));
  613. $supplemented += $supplementCount;
  614. }
  615. Log::warning('QuestionLocalService: 补充完成', [
  616. 'supplement_added' => $supplemented,
  617. 'final_count_before_truncate' => count($selected),
  618. 'remaining_unused' => max(0, count($questions) - count($selected))
  619. ]);
  620. }
  621. // 截断至目标数量
  622. $selected = array_slice($selected, 0, $totalQuestions);
  623. $finalBuckets = $this->difficultyDistributionService->groupQuestionsByDifficultyRange($selected, $difficultyCategory);
  624. $finalTotal = max(1, count($selected));
  625. $distributionStats = array_map(static function ($bucket) use ($finalTotal) {
  626. $count = count($bucket);
  627. return [
  628. 'count' => $count,
  629. 'ratio' => round(($count / $finalTotal) * 100, 2),
  630. ];
  631. }, $finalBuckets);
  632. Log::info('QuestionLocalService: 难度分布选择完成', [
  633. 'final_count' => count($selected),
  634. 'target_count' => $totalQuestions,
  635. 'success' => count($selected) === $totalQuestions,
  636. 'input_count' => count($questions),
  637. 'distribution_applied' => true,
  638. 'final_distribution' => $distributionStats
  639. ]);
  640. return $selected;
  641. }
  642. }