QuestionLocalService.php 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736
  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. $questions = $query->get();
  291. $selected = $this->applyRatioSelection($questions, $totalQuestions, $filters);
  292. return $this->mapQuestions(collect($selected));
  293. }
  294. public function getKnowledgePointOptions(): array
  295. {
  296. return KnowledgePoint::query()
  297. ->orderBy('kp_code')
  298. ->pluck('name', 'kp_code')
  299. ->toArray();
  300. }
  301. public function getSkillNameMapping(?string $kpCode = null): array
  302. {
  303. return [];
  304. }
  305. private function applyFilters($query, array $filters)
  306. {
  307. if (!empty($filters['kp_code'])) {
  308. $query->where('kp_code', $filters['kp_code']);
  309. }
  310. if (!empty($filters['difficulty'])) {
  311. $query->where('difficulty', $filters['difficulty']);
  312. }
  313. if (!empty($filters['type'])) {
  314. $query->where('question_type', $filters['type']);
  315. }
  316. if (!empty($filters['search'])) {
  317. $query->search($filters['search']);
  318. }
  319. return $query;
  320. }
  321. private function normalizePayload(array $payload): array
  322. {
  323. $normalized = [
  324. 'question_code' => $payload['question_code'] ?? null,
  325. 'kp_code' => $payload['kp_code'] ?? null,
  326. 'stem' => $payload['stem'] ?? ($payload['content'] ?? ''),
  327. 'options' => $payload['options'] ?? null,
  328. 'answer' => $payload['answer'] ?? null,
  329. 'solution' => $payload['solution'] ?? null,
  330. 'difficulty' => $payload['difficulty'] ?? null,
  331. 'source' => $payload['source'] ?? null,
  332. 'tags' => $payload['tags'] ?? null,
  333. 'question_type' => $payload['question_type'] ?? ($payload['type'] ?? null),
  334. 'meta' => $payload['meta'] ?? null,
  335. ];
  336. if (isset($payload['skills'])) {
  337. $meta = $normalized['meta'] ?? [];
  338. $meta['skills'] = is_array($payload['skills'])
  339. ? $payload['skills']
  340. : array_filter(array_map('trim', explode(',', (string) $payload['skills'])));
  341. $normalized['meta'] = $meta;
  342. }
  343. return array_filter($normalized, static fn ($value) => $value !== null);
  344. }
  345. private function mapQuestions(Collection $questions): array
  346. {
  347. $kpCodes = $questions->pluck('kp_code')->filter()->unique()->values();
  348. $kpMap = $this->resolveKnowledgePointNames($kpCodes->all());
  349. return $questions->map(function (Question $question) use ($kpMap) {
  350. $data = $this->mapQuestion($question);
  351. $data['kp_name'] = $kpMap[$question->kp_code] ?? null;
  352. return $data;
  353. })->values()->all();
  354. }
  355. private function mapQuestion(Question $question): array
  356. {
  357. $meta = $question->meta ?? [];
  358. $data = [
  359. 'id' => $question->id,
  360. 'question_code' => $question->question_code,
  361. 'kp_code' => $question->kp_code,
  362. 'stem' => $question->stem,
  363. 'options' => $question->options,
  364. 'answer' => $question->answer,
  365. 'solution' => $question->solution,
  366. 'difficulty' => $question->difficulty,
  367. 'source' => $question->source,
  368. 'tags' => $question->tags,
  369. 'type' => $question->question_type,
  370. 'question_type' => $question->question_type,
  371. 'skills' => $meta['skills'] ?? [],
  372. 'meta' => $meta,
  373. 'created_at' => $question->created_at?->toDateTimeString(),
  374. 'updated_at' => $question->updated_at?->toDateTimeString(),
  375. ];
  376. return MathFormulaProcessor::processQuestionData($data);
  377. }
  378. private function generateQuestionCode(): string
  379. {
  380. return 'Q' . Str::upper(Str::random(10));
  381. }
  382. private function mapQuestionTypeLabel(string $type): string
  383. {
  384. return match (strtoupper($type)) {
  385. 'CHOICE' => '选择题',
  386. 'MULTIPLE_CHOICE' => '多选题',
  387. 'FILL_IN_THE_BLANK', 'FILL' => '填空题',
  388. 'CALCULATION', 'WORD_PROBLEM', 'ANSWER' => '解答题',
  389. 'PROOF' => '证明题',
  390. default => '其他',
  391. };
  392. }
  393. private function applyRatioSelection(Collection $questions, int $totalQuestions, array $filters): array
  394. {
  395. $questionsByType = $questions->groupBy(fn (Question $q) => $q->question_type ?? 'CALCULATION');
  396. $questionsByDifficulty = $questions->groupBy(function (Question $q) {
  397. $difficulty = (float) ($q->difficulty ?? 0);
  398. if ($difficulty <= 0.4) {
  399. return 'easy';
  400. }
  401. if ($difficulty <= 0.7) {
  402. return 'medium';
  403. }
  404. return 'hard';
  405. });
  406. $typeRatio = $filters['question_type_ratio'] ?? [];
  407. $difficultyRatio = $filters['difficulty_ratio'] ?? [];
  408. $selected = collect();
  409. if (!empty($typeRatio)) {
  410. foreach ($typeRatio as $type => $ratio) {
  411. $bucket = $questionsByType->get($type, collect());
  412. $count = (int) round($totalQuestions * (float) $ratio);
  413. $selected = $selected->merge($bucket->shuffle()->take($count));
  414. }
  415. }
  416. if (!empty($difficultyRatio)) {
  417. foreach ($difficultyRatio as $key => $ratio) {
  418. $bucketKey = $this->normalizeDifficultyKey($key);
  419. $bucket = $questionsByDifficulty->get($bucketKey, collect());
  420. $count = (int) round($totalQuestions * (float) $ratio);
  421. $selected = $selected->merge($bucket->shuffle()->take($count));
  422. }
  423. }
  424. if ($selected->isEmpty()) {
  425. return $questions->shuffle()->take($totalQuestions)->values()->all();
  426. }
  427. if ($selected->count() < $totalQuestions) {
  428. $missing = $totalQuestions - $selected->count();
  429. $fill = $questions->diff($selected)->shuffle()->take($missing);
  430. $selected = $selected->merge($fill);
  431. }
  432. return $selected->values()->all();
  433. }
  434. private function normalizeDifficultyKey(string $key): string
  435. {
  436. if (in_array($key, ['easy', 'medium', 'hard'], true)) {
  437. return $key;
  438. }
  439. $value = (float) $key;
  440. if ($value <= 0.4) {
  441. return 'easy';
  442. }
  443. if ($value <= 0.7) {
  444. return 'medium';
  445. }
  446. return 'hard';
  447. }
  448. private function resolveKnowledgePointNames(array $kpCodes): array
  449. {
  450. $kpCodes = array_values(array_filter(array_unique($kpCodes)));
  451. if (empty($kpCodes)) {
  452. return [];
  453. }
  454. $cacheKey = 'kp-name-map-' . md5(implode('|', $kpCodes));
  455. return Cache::remember($cacheKey, now()->addMinutes(30), function () use ($kpCodes) {
  456. $kpMap = KnowledgePoint::query()
  457. ->whereIn('kp_code', $kpCodes)
  458. ->pluck('name', 'kp_code')
  459. ->toArray();
  460. $missing = array_values(array_filter($kpCodes, fn ($code) => empty($kpMap[$code])));
  461. if (empty($missing)) {
  462. return $kpMap;
  463. }
  464. try {
  465. $api = app(KnowledgeServiceApi::class);
  466. $all = $api->listKnowledgePoints();
  467. foreach ($all as $kp) {
  468. $code = $kp['kp_code'] ?? null;
  469. $name = $kp['cn_name'] ?? $kp['name'] ?? null;
  470. if ($code && $name && in_array($code, $missing, true)) {
  471. $kpMap[$code] = $name;
  472. }
  473. }
  474. } catch (\Throwable $e) {
  475. // Fallback: keep existing mapping
  476. }
  477. return $kpMap;
  478. });
  479. }
  480. /**
  481. * 根据难度系数分布选择题目
  482. *
  483. * @param array $questions 候选题目数组
  484. * @param int $totalQuestions 总题目数
  485. * @param int $difficultyCategory 难度类别 (0-4)
  486. * - 0: 0-0.1占90%,0.1-0.25占10%
  487. * - 1: 0-0.25占90%,0.25-1占10%
  488. * - 2: 0.25-0.5范围占50%,<0.25占25%,>0.5占25%
  489. * - 3: 0.5-0.75范围占50%,<0.5占25%,>0.75占25%
  490. * - 4: 0.75-1范围占50%,其他占50%
  491. * @param array $filters 其他筛选条件
  492. * @return array 分布后的题目
  493. */
  494. public function selectQuestionsByDifficultyDistribution(array $questions, int $totalQuestions, int $difficultyCategory = 1, array $filters = []): array
  495. {
  496. Log::info('QuestionLocalService: 根据难度系数分布选择题目', [
  497. 'total_questions' => $totalQuestions,
  498. 'difficulty_category' => $difficultyCategory,
  499. ]);
  500. if (empty($questions)) {
  501. Log::warning('QuestionLocalService: 输入题目为空');
  502. return [];
  503. }
  504. // 【恢复】简化逻辑,避免复杂处理
  505. $distribution = $this->difficultyDistributionService->calculateDistribution($difficultyCategory, $totalQuestions);
  506. // 按难度范围分桶
  507. $buckets = $this->difficultyDistributionService->groupQuestionsByDifficultyRange($questions, $difficultyCategory);
  508. Log::info('QuestionLocalService: 题目分桶', [
  509. 'buckets' => array_map(fn($bucket) => count($bucket), $buckets),
  510. 'total_input' => count($questions),
  511. 'distribution' => $distribution
  512. ]);
  513. // 根据分布选择题目
  514. $selected = [];
  515. $usedIds = [];
  516. foreach ($distribution as $level => $config) {
  517. $targetCount = $config['count'];
  518. if ($targetCount <= 0) {
  519. Log::debug('QuestionLocalService: 跳过难度层级', [
  520. 'level' => $level,
  521. 'target_count' => $targetCount
  522. ]);
  523. continue;
  524. }
  525. $rangeKey = $this->difficultyDistributionService->mapDifficultyLevelToRangeKey($level, $difficultyCategory);
  526. $bucket = $buckets[$rangeKey] ?? [];
  527. // 随机打乱
  528. shuffle($bucket);
  529. // 选择题目
  530. $taken = 0;
  531. foreach ($bucket as $question) {
  532. if ($taken >= $targetCount) break;
  533. $questionId = $question['id'] ?? null;
  534. if ($questionId && !in_array($questionId, $usedIds)) {
  535. $selected[] = $question;
  536. $usedIds[] = $questionId;
  537. $taken++;
  538. }
  539. }
  540. // 【修复】如果某个难度范围题目不足,记录日志但不截断
  541. if ($taken < $targetCount) {
  542. Log::warning('QuestionLocalService: 难度范围题目不足,允许后续补充', [
  543. 'level' => $level,
  544. 'range_key' => $rangeKey,
  545. 'target' => $targetCount,
  546. 'actual' => $taken,
  547. 'bucket_size' => count($bucket)
  548. ]);
  549. }
  550. }
  551. // 如果数量不足,从剩余题目中补充
  552. if (count($selected) < $totalQuestions) {
  553. Log::warning('QuestionLocalService: 开始补充题目(难度分布无法满足要求)', [
  554. 'need_more' => $totalQuestions - count($selected),
  555. 'selected_count' => count($selected),
  556. 'difficulty_category' => $difficultyCategory,
  557. 'note' => '优先从次级桶补充,不足再放宽'
  558. ]);
  559. $needMore = $totalQuestions - count($selected);
  560. $supplemented = 0;
  561. $supplementOrder = $this->difficultyDistributionService->getSupplementOrder($difficultyCategory);
  562. foreach ($supplementOrder as $bucketKey) {
  563. if ($supplemented >= $needMore) {
  564. break;
  565. }
  566. $bucket = $buckets[$bucketKey] ?? [];
  567. if (empty($bucket)) {
  568. continue;
  569. }
  570. shuffle($bucket);
  571. foreach ($bucket as $q) {
  572. if ($supplemented >= $needMore) {
  573. break;
  574. }
  575. $id = $q['id'] ?? null;
  576. if ($id && !in_array($id, $usedIds)) {
  577. $selected[] = $q;
  578. $usedIds[] = $id;
  579. $supplemented++;
  580. }
  581. }
  582. }
  583. if ($supplemented < $needMore) {
  584. $remaining = [];
  585. foreach ($questions as $q) {
  586. $id = $q['id'] ?? null;
  587. if ($id && !in_array($id, $usedIds)) {
  588. $remaining[] = $q;
  589. }
  590. }
  591. shuffle($remaining);
  592. $supplementCount = min($needMore - $supplemented, count($remaining));
  593. $selected = array_merge($selected, array_slice($remaining, 0, $supplementCount));
  594. $supplemented += $supplementCount;
  595. }
  596. Log::warning('QuestionLocalService: 补充完成', [
  597. 'supplement_added' => $supplemented,
  598. 'final_count_before_truncate' => count($selected),
  599. 'remaining_unused' => max(0, count($questions) - count($selected))
  600. ]);
  601. }
  602. // 截断至目标数量
  603. $selected = array_slice($selected, 0, $totalQuestions);
  604. $finalBuckets = $this->difficultyDistributionService->groupQuestionsByDifficultyRange($selected, $difficultyCategory);
  605. $finalTotal = max(1, count($selected));
  606. $distributionStats = array_map(static function ($bucket) use ($finalTotal) {
  607. $count = count($bucket);
  608. return [
  609. 'count' => $count,
  610. 'ratio' => round(($count / $finalTotal) * 100, 2),
  611. ];
  612. }, $finalBuckets);
  613. Log::info('QuestionLocalService: 难度分布选择完成', [
  614. 'final_count' => count($selected),
  615. 'target_count' => $totalQuestions,
  616. 'success' => count($selected) === $totalQuestions,
  617. 'input_count' => count($questions),
  618. 'distribution_applied' => true,
  619. 'final_distribution' => $distributionStats
  620. ]);
  621. return $selected;
  622. }
  623. }