QuestionLocalService.php 28 KB

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