ExamTypeStrategy.php 58 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Facades\Log;
  4. use App\Models\StudentKnowledgeMastery;
  5. use App\Models\MistakeRecord;
  6. use App\Models\KnowledgePoint;
  7. use App\Models\Paper;
  8. use App\Models\PaperQuestion;
  9. use App\Models\Question;
  10. use Illuminate\Support\Facades\DB;
  11. class ExamTypeStrategy
  12. {
  13. protected QuestionExpansionService $questionExpansionService;
  14. protected QuestionLocalService $questionLocalService;
  15. protected DifficultyDistributionService $difficultyDistributionService;
  16. public function __construct(
  17. QuestionExpansionService $questionExpansionService,
  18. QuestionLocalService $questionLocalService = null,
  19. DifficultyDistributionService $difficultyDistributionService = null
  20. )
  21. {
  22. $this->questionExpansionService = $questionExpansionService;
  23. $this->questionLocalService = $questionLocalService ?? app(QuestionLocalService::class);
  24. $this->difficultyDistributionService = $difficultyDistributionService ?? app(DifficultyDistributionService::class);
  25. }
  26. /**
  27. * 根据组卷类型构建参数
  28. * assembleType: 0-摸底, 1-智能组卷, 2-知识点组卷, 3-教材组卷, 4-通用, 5-错题本, 6-按知识点组卷
  29. */
  30. public function buildParams(array $baseParams, int $assembleType): array
  31. {
  32. Log::info('ExamTypeStrategy: 构建组卷参数', [
  33. 'assemble_type' => $assembleType,
  34. 'base_params_keys' => array_keys($baseParams)
  35. ]);
  36. return match($assembleType) {
  37. 0 => $this->applyDifficultyDistribution($this->buildDiagnosticParams($baseParams)), // 摸底
  38. 1 => $this->applyDifficultyDistribution($this->buildIntelligentAssembleParams($baseParams)), // 智能组卷
  39. 2 => $this->applyDifficultyDistribution($this->buildKnowledgePointAssembleParams($baseParams)), // 知识点组卷
  40. 3 => $this->applyDifficultyDistribution($this->buildTextbookAssembleParams($baseParams)), // 教材组卷
  41. 4 => $this->applyDifficultyDistribution($this->buildGeneralParams($baseParams)), // 通用
  42. 5 => $this->buildMistakeParams($baseParams), // 错题本不应用难度分布
  43. 6 => $this->applyDifficultyDistribution($this->buildKnowledgePointsParams($baseParams)), // 按知识点组卷
  44. default => $this->applyDifficultyDistribution($this->buildGeneralParams($baseParams))
  45. };
  46. }
  47. /**
  48. * 根据组卷类型构建参数(兼容旧版 exam_type 参数)
  49. * @deprecated 使用 buildParams(array, int) 替代
  50. */
  51. public function buildParamsLegacy(array $baseParams, string $examType): array
  52. {
  53. // 兼容旧版 exam_type 参数
  54. $assembleType = match($examType) {
  55. 'diagnostic' => 0,
  56. 'general' => 4,
  57. 'practice' => 5,
  58. 'mistake' => 5,
  59. 'textbook' => 3,
  60. 'knowledge' => 2,
  61. 'knowledge_points' => 6,
  62. default => 4
  63. };
  64. return $this->buildParams($baseParams, $assembleType);
  65. }
  66. /**
  67. * 通用智能出卷(原有行为)
  68. */
  69. private function buildGeneralParams(array $params): array
  70. {
  71. Log::info('ExamTypeStrategy: 通用智能出卷参数', $params);
  72. // 返回原始参数,难度分布逻辑在 buildParams 中统一处理
  73. return $params;
  74. }
  75. /**
  76. * 应用难度系数分布逻辑
  77. * 根据 difficulty_category 参数实现分层选题策略
  78. *
  79. * @param array $params 基础参数
  80. * @param bool $forceApply 是否强制应用(默认false,不对错题本类型应用)
  81. * @return array 增强后的参数
  82. */
  83. private function applyDifficultyDistribution(array $params, bool $forceApply = false): array
  84. {
  85. // 检查是否为排除类型(错题本 assembleType=5)
  86. $assembleType = (int) ($params['assemble_type'] ?? 4);
  87. $isExcludedType = ($assembleType === 5); // 只有错题本类型不应用难度分布
  88. // 如果不是强制应用且为排除类型,则不应用难度分布
  89. if (!$forceApply && $isExcludedType) {
  90. Log::info('ExamTypeStrategy: 跳过难度分布(错题本类型)', [
  91. 'assemble_type' => $assembleType
  92. ]);
  93. return $params;
  94. }
  95. $difficultyCategory = (int) ($params['difficulty_category'] ?? 1);
  96. $totalQuestions = (int) ($params['total_questions'] ?? 20);
  97. Log::info('ExamTypeStrategy: 应用难度系数分布', [
  98. 'difficulty_category' => $difficultyCategory,
  99. 'total_questions' => $totalQuestions,
  100. 'assemble_type' => $assembleType
  101. ]);
  102. // 根据难度类别计算题目分布
  103. $distribution = $this->difficultyDistributionService->calculateDistribution($difficultyCategory, $totalQuestions);
  104. // 构建难度分布配置
  105. $difficultyDistributionConfig = [
  106. 'strategy' => 'difficulty分层选题',
  107. 'category' => $difficultyCategory,
  108. 'total_questions' => $totalQuestions,
  109. 'distribution' => $distribution,
  110. 'ranges' => $this->difficultyDistributionService->getRanges($difficultyCategory),
  111. 'use_question_local_service' => true, // 标记使用新的独立方法
  112. ];
  113. $enhanced = array_merge($params, [
  114. 'difficulty_distribution_config' => $difficultyDistributionConfig,
  115. // 保留原有的 difficulty_ratio 以兼容性
  116. 'difficulty_ratio' => [
  117. '基础' => $distribution['low']['percentage'],
  118. '中等' => $distribution['medium']['percentage'],
  119. '拔高' => $distribution['high']['percentage'],
  120. ],
  121. // 启用难度分布选题标志
  122. 'enable_difficulty_distribution' => true,
  123. // 【重要】保持原始的 difficulty_category,不修改
  124. 'difficulty_category' => $difficultyCategory,
  125. ]);
  126. Log::info('ExamTypeStrategy: 难度分布应用完成', [
  127. 'category' => $difficultyCategory,
  128. 'distribution' => $distribution
  129. ]);
  130. return $enhanced;
  131. }
  132. /**
  133. * 应用难度分布到题目集合
  134. * 这是一个独立的公共方法,供外部调用
  135. *
  136. * @param array $questions 候选题目数组
  137. * @param int $totalQuestions 总题目数
  138. * @param int $difficultyCategory 难度类别 (1-4)
  139. * @param array $filters 其他筛选条件
  140. * @return array 分布后的题目
  141. */
  142. public function applyDifficultyDistributionToQuestions(array $questions, int $totalQuestions, int $difficultyCategory = 1, array $filters = []): array
  143. {
  144. Log::info('ExamTypeStrategy: 应用难度分布到题目集合', [
  145. 'total_questions' => $totalQuestions,
  146. 'difficulty_category' => $difficultyCategory,
  147. 'input_questions' => count($questions)
  148. ]);
  149. // 使用 QuestionLocalService 的独立方法
  150. return $this->questionLocalService->selectQuestionsByDifficultyDistribution(
  151. $questions,
  152. $totalQuestions,
  153. $difficultyCategory,
  154. $filters
  155. );
  156. }
  157. /**
  158. * 根据难度类别计算题目分布
  159. *
  160. * @param int $category 难度类别 (0-4)
  161. * @param int $totalQuestions 总题目数
  162. * @return array 分布配置
  163. */
  164. private function calculateDifficultyDistribution(int $category, int $totalQuestions): array
  165. {
  166. return $this->difficultyDistributionService->calculateDistribution($category, $totalQuestions);
  167. }
  168. /**
  169. * 获取难度范围配置
  170. *
  171. * @param int $category 难度类别
  172. * @return array 难度范围配置
  173. */
  174. private function getDifficultyRanges(int $category): array
  175. {
  176. return $this->difficultyDistributionService->getRanges($category);
  177. }
  178. /**
  179. * 摸底测试:评估当前水平
  180. */
  181. private function buildDiagnosticParams(array $params): array
  182. {
  183. Log::info('ExamTypeStrategy: 构建摸底测试参数', $params);
  184. $textbookId = $params['textbook_id'] ?? null;
  185. $grade = $params['grade'] ?? null;
  186. $totalQuestions = $params['total_questions'] ?? 20;
  187. $endCatalogId = $params['end_catalog_id'] ?? null; // 截止章节ID
  188. if (!$textbookId) {
  189. Log::warning('ExamTypeStrategy: 摸底测试需要 textbook_id 参数');
  190. return $this->buildGeneralParams($params);
  191. }
  192. // 第一步:根据 textbook_id 查询章节(支持截止章节过滤)
  193. $catalogChapterIds = $this->getTextbookChapterIds($textbookId, $endCatalogId);
  194. if (empty($catalogChapterIds)) {
  195. Log::warning('ExamTypeStrategy: 未找到课本章节', ['textbook_id' => $textbookId]);
  196. return $this->buildGeneralParams($params);
  197. }
  198. Log::info('ExamTypeStrategy: 获取到课本章节(摸底测试)', [
  199. 'textbook_id' => $textbookId,
  200. 'end_catalog_id' => $endCatalogId,
  201. 'chapter_count' => count($catalogChapterIds)
  202. ]);
  203. // 第二步:根据章节ID查询知识点关联
  204. $kpCodes = $this->getKnowledgePointsFromChapters($catalogChapterIds, 25);
  205. if (empty($kpCodes)) {
  206. Log::warning('ExamTypeStrategy: 未找到知识点关联', ['textbook_id' => $textbookId]);
  207. return $this->buildGeneralParams($params);
  208. }
  209. Log::info('ExamTypeStrategy: 获取到知识点(摸底测试)', [
  210. 'kp_count' => count($kpCodes),
  211. 'kp_codes' => $kpCodes,
  212. 'textbook_id' => $textbookId,
  213. 'grade' => $grade,
  214. 'note' => '知识点数量将直接影响题目多样性'
  215. ]);
  216. // 摸底测试:平衡所有难度,覆盖多个知识点
  217. // 【修复】移除硬编码难度配比,使用difficulty_category参数动态计算
  218. $enhanced = array_merge($params, [
  219. 'kp_codes' => $kpCodes,
  220. 'textbook_id' => $textbookId,
  221. 'grade' => $grade,
  222. 'catalog_chapter_ids' => $catalogChapterIds,
  223. // 题型配比:选择题多一些,便于快速评估
  224. 'question_type_ratio' => [
  225. '选择题' => 50,
  226. '填空题' => 25,
  227. '解答题' => 25,
  228. ],
  229. 'paper_name' => $params['paper_name'] ?? ('摸底测试_' . now()->format('Ymd_His')),
  230. ]);
  231. Log::info('ExamTypeStrategy: 摸底测试参数构建完成(未应用难度分布)', [
  232. 'textbook_id' => $textbookId,
  233. 'kp_count' => count($kpCodes),
  234. 'total_questions' => $totalQuestions,
  235. 'difficulty_category' => $params['difficulty_category'] ?? 1,
  236. 'note' => '将在buildParams中应用difficulty_category难度分布'
  237. ]);
  238. return $enhanced;
  239. }
  240. /**
  241. * 错题本 (assembleType=5)
  242. * 根据 paper_ids 数组查询卷子中的错题,组合成新卷子
  243. * 不需要 total_questions 参数
  244. */
  245. private function buildMistakeParams(array $params): array
  246. {
  247. Log::info('ExamTypeStrategy: 构建错题本参数', $params);
  248. $paperIds = $params['paper_ids'] ?? [];
  249. $studentId = $params['student_id'] ?? null;
  250. // 检查是否有 paper_ids 参数
  251. if (empty($paperIds)) {
  252. Log::warning('ExamTypeStrategy: 错题本需要 paper_ids 参数');
  253. return $this->buildGeneralParams($params);
  254. }
  255. Log::info('ExamTypeStrategy: 错题本组卷', [
  256. 'paper_ids' => $paperIds,
  257. 'student_id' => $studentId,
  258. 'paper_count' => count($paperIds)
  259. ]);
  260. // 通过 paper_ids 查询卷子中的错题
  261. $mistakeQuestionIds = $this->getMistakeQuestionsFromPapers($paperIds, $studentId);
  262. if (empty($mistakeQuestionIds)) {
  263. Log::warning('ExamTypeStrategy: 未找到错题', [
  264. 'paper_ids' => $paperIds
  265. ]);
  266. return $this->buildGeneralParams($params);
  267. }
  268. Log::info('ExamTypeStrategy: 获取到错题', [
  269. 'paper_count' => count($paperIds),
  270. 'mistake_count' => count($mistakeQuestionIds),
  271. 'mistake_question_ids' => array_slice($mistakeQuestionIds, 0, 10) // 只记录前10个
  272. ]);
  273. // 获取错题知识点
  274. $mistakeKnowledgePoints = $this->getKnowledgePointsFromQuestions($mistakeQuestionIds);
  275. // 组装增强参数
  276. $mistakeCount = count($mistakeQuestionIds);
  277. $maxQuestions = 50; // 错题本最大题目数限制
  278. // 如果错题超过最大值,截取到最大值
  279. if ($mistakeCount > $maxQuestions) {
  280. Log::warning('ExamTypeStrategy: 错题数量超过最大值限制,已截取', [
  281. 'mistake_count' => $mistakeCount,
  282. 'max_limit' => $maxQuestions,
  283. 'truncated_count' => $maxQuestions
  284. ]);
  285. $mistakeQuestionIds = array_slice($mistakeQuestionIds, 0, $maxQuestions);
  286. $mistakeCount = $maxQuestions;
  287. }
  288. $enhanced = array_merge($params, [
  289. 'mistake_question_ids' => $mistakeQuestionIds,
  290. 'paper_ids' => $paperIds,
  291. 'priority_knowledge_points' => array_values($mistakeKnowledgePoints),
  292. 'paper_name' => $params['paper_name'] ?? ('错题本_' . now()->format('Ymd_His')),
  293. 'total_questions' => $mistakeCount, // 错题本题目数量由实际错题数量决定
  294. // 错题本:保持原有题型配比
  295. 'question_type_ratio' => [
  296. '选择题' => 35,
  297. '填空题' => 30,
  298. '解答题' => 35,
  299. ],
  300. // 错题本不应用难度分布
  301. 'is_mistake_exam' => true,
  302. 'is_paper_based_mistake' => true, // 标记是基于卷子的错题本
  303. 'mistake_count' => $mistakeCount,
  304. 'knowledge_points_count' => count($mistakeKnowledgePoints),
  305. 'max_questions_limit' => $maxQuestions,
  306. ]);
  307. Log::info('ExamTypeStrategy: 错题本参数构建完成', [
  308. 'paper_count' => count($paperIds),
  309. 'mistake_count' => count($mistakeQuestionIds),
  310. 'knowledge_points_count' => count($mistakeKnowledgePoints)
  311. ]);
  312. return $enhanced;
  313. }
  314. /**
  315. * 专项练习:针对特定技能或知识点练习
  316. */
  317. private function buildPracticeParams(array $params): array
  318. {
  319. Log::info('ExamTypeStrategy: 构建专项练习参数', $params);
  320. $studentId = $params['student_id'] ?? null;
  321. $practiceOptions = $params['practice_options'] ?? [];
  322. $weaknessThreshold = $practiceOptions['weakness_threshold'] ?? 0.7;
  323. $intensity = $practiceOptions['intensity'] ?? 'medium';
  324. $focusWeaknesses = $practiceOptions['focus_weaknesses'] ?? true;
  325. // 【修复】移除硬编码难度配比,专项练习使用difficulty_category参数
  326. // 注意:强度调节逻辑已移除,统一使用difficulty_category控制难度分布
  327. // 获取学生薄弱点
  328. $weaknessFilter = [];
  329. if ($studentId && $focusWeaknesses) {
  330. $weaknessFilter = $this->getStudentWeaknesses($studentId, $weaknessThreshold);
  331. Log::info('ExamTypeStrategy: 获取到学生薄弱点', [
  332. 'student_id' => $studentId,
  333. 'weakness_threshold' => $weaknessThreshold,
  334. 'weakness_count' => count($weaknessFilter)
  335. ]);
  336. }
  337. // 优先使用薄弱点知识点,如果没有则使用用户选择的知识点
  338. $kpCodes = $params['kp_codes'] ?? [];
  339. if ($studentId && empty($kpCodes) && !empty($weaknessFilter)) {
  340. $kpCodes = array_column($weaknessFilter, 'kp_code');
  341. Log::info('ExamTypeStrategy: 使用薄弱点作为知识点', [
  342. 'kp_codes' => $kpCodes
  343. ]);
  344. }
  345. $enhanced = array_merge($params, [
  346. 'kp_codes' => $kpCodes,
  347. // 专项练习更注重题型覆盖
  348. 'question_type_ratio' => [
  349. '选择题' => 40,
  350. '填空题' => 30,
  351. '解答题' => 30,
  352. ],
  353. 'paper_name' => $params['paper_name'] ?? ('专项练习_' . now()->format('Ymd_His')),
  354. // 标记这是专项练习,用于后续处理
  355. 'is_practice_exam' => true,
  356. 'weakness_filter' => $weaknessFilter,
  357. ]);
  358. Log::info('ExamTypeStrategy: 专项练习参数构建完成(未应用难度分布)', [
  359. 'intensity' => $intensity,
  360. 'difficulty_category' => $params['difficulty_category'] ?? 1,
  361. 'kp_codes_count' => count($enhanced['kp_codes']),
  362. 'weakness_count' => count($weaknessFilter),
  363. 'note' => '将在buildParams中应用difficulty_category难度分布'
  364. ]);
  365. return $enhanced;
  366. }
  367. /**
  368. * 教材同步:按教材章节出题
  369. */
  370. private function buildTextbookParams(array $params): array
  371. {
  372. Log::info('ExamTypeStrategy: 构建教材同步参数', $params);
  373. // 教材同步:按章节顺序,难度递增
  374. // 【修复】移除硬编码难度配比,使用difficulty_category参数
  375. $textbookOptions = $params['textbook_options'] ?? [];
  376. $enhanced = array_merge($params, [
  377. 'question_type_ratio' => [
  378. '选择题' => 40,
  379. '填空题' => 30,
  380. '解答题' => 30,
  381. ],
  382. 'paper_name' => $params['paper_name'] ?? ('教材同步_' . now()->format('Ymd_His')),
  383. 'textbook_options' => $textbookOptions,
  384. ]);
  385. Log::info('ExamTypeStrategy: 教材同步参数构建完成(未应用难度分布)', [
  386. 'difficulty_category' => $params['difficulty_category'] ?? 1,
  387. 'note' => '将在buildParams中应用difficulty_category难度分布'
  388. ]);
  389. return $enhanced;
  390. }
  391. /**
  392. * 知识点专练:单个或少量知识点深练
  393. */
  394. private function buildKnowledgeParams(array $params): array
  395. {
  396. Log::info('ExamTypeStrategy: 构建知识点专练参数', $params);
  397. // 知识点专练:深度挖掘,多角度考查
  398. // 【修复】移除硬编码难度配比,使用difficulty_category参数
  399. $knowledgeOptions = $params['knowledge_options'] ?? [];
  400. $enhanced = array_merge($params, [
  401. 'question_type_ratio' => [
  402. '选择题' => 30,
  403. '填空题' => 35,
  404. '解答题' => 35,
  405. ],
  406. 'paper_name' => $params['paper_name'] ?? ('知识点专练_' . now()->format('Ymd_His')),
  407. 'knowledge_options' => $knowledgeOptions,
  408. ]);
  409. Log::info('ExamTypeStrategy: 知识点专练参数构建完成(未应用难度分布)', [
  410. 'difficulty_category' => $params['difficulty_category'] ?? 1,
  411. 'note' => '将在buildParams中应用difficulty_category难度分布'
  412. ]);
  413. return $enhanced;
  414. }
  415. /**
  416. * 按知识点组卷:根据指定知识点数组智能选题
  417. * 优先级策略:
  418. * 1. 直接关联知识点题目(来自输入数组)
  419. * 2. 相同知识点其他题目
  420. * 3. 子知识点题目(下探1层)
  421. * 4. 薄弱点题目比例调整
  422. * 5. 子知识点题目(下探2层)
  423. */
  424. private function buildKnowledgePointsParams(array $params): array
  425. {
  426. Log::info('ExamTypeStrategy: 构建按知识点组卷参数', $params);
  427. $studentId = $params['student_id'] ?? null;
  428. $totalQuestions = $params['total_questions'] ?? 20;
  429. $knowledgePointsOptions = $params['knowledge_points_options'] ?? [];
  430. $weaknessThreshold = $knowledgePointsOptions['weakness_threshold'] ?? 0.7;
  431. $focusWeaknesses = $knowledgePointsOptions['focus_weaknesses'] ?? true;
  432. $intensity = $knowledgePointsOptions['intensity'] ?? 'medium';
  433. // 获取用户指定的知识点数组
  434. $targetKnowledgePoints = $params['kp_codes'] ?? [];
  435. if (empty($targetKnowledgePoints)) {
  436. Log::warning('ExamTypeStrategy: 未指定知识点数组,使用默认策略');
  437. return $this->buildGeneralParams($params);
  438. }
  439. Log::info('ExamTypeStrategy: 目标知识点数组', [
  440. 'target_knowledge_points' => $targetKnowledgePoints,
  441. 'count' => count($targetKnowledgePoints)
  442. ]);
  443. // 根据强度调整难度配比
  444. $difficultyRatio = match($intensity) {
  445. 'low' => [
  446. '基础' => 60,
  447. '中等' => 35,
  448. '拔高' => 5,
  449. ],
  450. 'medium' => [
  451. '基础' => 45,
  452. '中等' => 40,
  453. '拔高' => 15,
  454. ],
  455. 'high' => [
  456. '基础' => 30,
  457. '中等' => 45,
  458. '拔高' => 25,
  459. ],
  460. default => [
  461. '基础' => 45,
  462. '中等' => 40,
  463. '拔高' => 15,
  464. ]
  465. };
  466. // 获取学生薄弱点(用于判断目标知识点是否为薄弱点)
  467. $weaknessFilter = [];
  468. if ($studentId && $focusWeaknesses) {
  469. $weaknessFilter = $this->getStudentWeaknesses($studentId, $weaknessThreshold);
  470. Log::info('ExamTypeStrategy: 获取到学生薄弱点', [
  471. 'student_id' => $studentId,
  472. 'weakness_threshold' => $weaknessThreshold,
  473. 'weakness_count' => count($weaknessFilter)
  474. ]);
  475. }
  476. // 检查目标知识点中哪些是薄弱点
  477. $weaknessKpCodes = array_column($weaknessFilter, 'kp_code');
  478. $targetWeaknessKps = array_intersect($targetKnowledgePoints, $weaknessKpCodes);
  479. Log::info('ExamTypeStrategy: 目标知识点中的薄弱点', [
  480. 'target_weakness_kps' => $targetWeaknessKps,
  481. 'weakness_count' => count($targetWeaknessKps)
  482. ]);
  483. // 使用 QuestionExpansionService 按知识点优先级扩展题目
  484. // 修改 expandQuestions 支持直接传入知识点数组
  485. $questionStrategy = $this->questionExpansionService->expandQuestionsByKnowledgePoints(
  486. $params,
  487. $studentId,
  488. $targetKnowledgePoints,
  489. $weaknessFilter,
  490. $totalQuestions
  491. );
  492. // 获取扩展统计
  493. $expansionStats = $this->questionExpansionService->getExpansionStats($questionStrategy);
  494. $enhanced = array_merge($params, [
  495. 'difficulty_ratio' => $difficultyRatio,
  496. 'kp_codes' => $targetKnowledgePoints, // 确保使用目标知识点
  497. 'mistake_question_ids' => $questionStrategy['mistake_question_ids'] ?? [],
  498. // 优先级知识点:目标知识点 + 薄弱点
  499. 'priority_knowledge_points' => array_merge(
  500. array_values($targetKnowledgePoints),
  501. array_column($weaknessFilter, 'kp_code')
  502. ),
  503. 'question_type_ratio' => [
  504. '选择题' => 35,
  505. '填空题' => 30,
  506. '解答题' => 35,
  507. ],
  508. 'paper_name' => $params['paper_name'] ?? ('知识点组卷_' . now()->format('Ymd_His')),
  509. // 标记这是按知识点组卷,用于后续处理
  510. 'is_knowledge_points_exam' => true,
  511. 'weakness_filter' => $weaknessFilter,
  512. // 目标知识点中的薄弱点(用于调整题目数量)
  513. 'target_weakness_kps' => array_values($targetWeaknessKps),
  514. // 题目扩展统计
  515. 'question_expansion_stats' => $expansionStats
  516. ]);
  517. Log::info('ExamTypeStrategy: 按知识点组卷参数构建完成', [
  518. 'intensity' => $intensity,
  519. 'target_knowledge_points_count' => count($targetKnowledgePoints),
  520. 'target_weakness_kps_count' => count($targetWeaknessKps),
  521. 'mistake_question_ids_count' => count($enhanced['mistake_question_ids']),
  522. 'priority_knowledge_points_count' => count($enhanced['priority_knowledge_points']),
  523. 'question_expansion_stats' => $enhanced['question_expansion_stats'],
  524. 'weakness_count' => count($weaknessFilter)
  525. ]);
  526. return $enhanced;
  527. }
  528. /**
  529. * 为摸底测试扩展知识点(确保覆盖全面)
  530. */
  531. private function expandKpCodesForDiagnostic(array $kpCodes): array
  532. {
  533. if (!empty($kpCodes)) {
  534. return $kpCodes;
  535. }
  536. // 如果没有指定知识点,返回一些通用的数学知识点
  537. return [
  538. '一元二次方程',
  539. '二次函数',
  540. '旋转',
  541. '圆',
  542. '概率初步',
  543. ];
  544. }
  545. /**
  546. * 获取学生薄弱点
  547. */
  548. private function getStudentWeaknesses(string $studentId, float $threshold): array
  549. {
  550. try {
  551. // 使用 StudentKnowledgeMastery 模型获取掌握度低于阈值的知识点
  552. $weaknessRecords = StudentKnowledgeMastery::forStudent($studentId)
  553. ->weaknesses($threshold)
  554. ->orderByMastery('asc')
  555. ->limit(20)
  556. ->with('knowledgePoint') // 预加载知识点信息
  557. ->get();
  558. // 转换为统一格式
  559. return $weaknessRecords->map(function ($record) {
  560. return [
  561. 'kp_code' => $record->kp_code,
  562. 'kp_name' => $record->knowledgePoint->name ?? $record->kp_code,
  563. 'mastery' => (float) ($record->mastery_level ?? 0),
  564. 'attempts' => (int) ($record->total_attempts ?? 0),
  565. 'correct' => (int) ($record->correct_attempts ?? 0),
  566. 'incorrect' => (int) ($record->incorrect_attempts ?? 0),
  567. 'confidence' => (float) ($record->confidence_level ?? 0),
  568. 'trend' => $record->mastery_trend ?? 'stable',
  569. ];
  570. })->toArray();
  571. } catch (\Exception $e) {
  572. Log::error('ExamTypeStrategy: 获取学生薄弱点失败', [
  573. 'student_id' => $studentId,
  574. 'threshold' => $threshold,
  575. 'error' => $e->getMessage()
  576. ]);
  577. return [];
  578. }
  579. }
  580. /**
  581. * 智能组卷 (assembleType=1)
  582. * 根据 textbook_id 查询章节,获取知识点,然后组卷
  583. * 增加年级概念选题逻辑
  584. */
  585. private function buildIntelligentAssembleParams(array $params): array
  586. {
  587. Log::info('ExamTypeStrategy: 构建智能组卷参数', $params);
  588. $textbookId = $params['textbook_id'] ?? null;
  589. $grade = $params['grade'] ?? null; // 年级信息
  590. $totalQuestions = $params['total_questions'] ?? 20;
  591. if (!$textbookId) {
  592. Log::warning('ExamTypeStrategy: 智能组卷需要 textbook_id 参数');
  593. return $this->buildGeneralParams($params);
  594. }
  595. // 第一步:根据 textbook_id 查询章节
  596. $catalogChapterIds = $this->getTextbookChapterIds($textbookId);
  597. if (empty($catalogChapterIds)) {
  598. Log::warning('ExamTypeStrategy: 未找到课本章节', ['textbook_id' => $textbookId]);
  599. return $this->buildGeneralParams($params);
  600. }
  601. Log::info('ExamTypeStrategy: 获取到课本章节', [
  602. 'textbook_id' => $textbookId,
  603. 'chapter_count' => count($catalogChapterIds)
  604. ]);
  605. // 第二步:根据章节ID查询知识点关联
  606. $kpCodes = $this->getKnowledgePointsFromChapters($catalogChapterIds, 25);
  607. if (empty($kpCodes)) {
  608. Log::warning('ExamTypeStrategy: 未找到知识点关联', [
  609. 'textbook_id' => $textbookId,
  610. 'chapter_ids' => $catalogChapterIds
  611. ]);
  612. return $this->buildGeneralParams($params);
  613. }
  614. Log::info('ExamTypeStrategy: 获取到知识点', [
  615. 'kp_count' => count($kpCodes),
  616. 'kp_codes' => array_slice($kpCodes, 0, 5) // 只记录前5个
  617. ]);
  618. // 组装增强参数
  619. $enhanced = array_merge($params, [
  620. 'kp_codes' => $kpCodes,
  621. 'textbook_id' => $textbookId,
  622. 'grade' => $grade,
  623. 'catalog_chapter_ids' => $catalogChapterIds,
  624. 'paper_name' => $params['paper_name'] ?? ('智能组卷_' . now()->format('Ymd_His')),
  625. // 智能组卷:平衡的题型和难度配比
  626. 'question_type_ratio' => [
  627. '选择题' => 40,
  628. '填空题' => 30,
  629. '解答题' => 30,
  630. ],
  631. 'difficulty_ratio' => [
  632. '基础' => 25,
  633. '中等' => 50,
  634. '拔高' => 25,
  635. ],
  636. 'question_category' => 0, // question_category=0 代表普通题目(智能组卷)
  637. 'is_intelligent_assemble' => true,
  638. ]);
  639. Log::info('ExamTypeStrategy: 智能组卷参数构建完成', [
  640. 'textbook_id' => $textbookId,
  641. 'grade' => $grade,
  642. 'kp_count' => count($kpCodes),
  643. 'total_questions' => $totalQuestions
  644. ]);
  645. return $enhanced;
  646. }
  647. /**
  648. * 知识点组卷 (assembleType=2)
  649. * 直接根据 kp_code_list 查询题目,排除已做过的题目
  650. */
  651. private function buildKnowledgePointAssembleParams(array $params): array
  652. {
  653. Log::info('ExamTypeStrategy: 构建知识点组卷参数', $params);
  654. $kpCodeList = $params['kp_code_list'] ?? [];
  655. $studentId = $params['student_id'] ?? null;
  656. $totalQuestions = $params['total_questions'] ?? 20;
  657. if (empty($kpCodeList)) {
  658. Log::warning('ExamTypeStrategy: 知识点组卷需要 kp_code_list 参数');
  659. return $this->buildGeneralParams($params);
  660. }
  661. Log::info('ExamTypeStrategy: 知识点组卷', [
  662. 'kp_code_list' => $kpCodeList,
  663. 'student_id' => $studentId,
  664. 'total_questions' => $totalQuestions
  665. ]);
  666. // 如果有学生ID,获取已做过的题目ID列表(用于排除)
  667. $answeredQuestionIds = [];
  668. if ($studentId) {
  669. $answeredQuestionIds = $this->getStudentAnsweredQuestionIds($studentId, $kpCodeList);
  670. Log::info('ExamTypeStrategy: 获取学生已答题目', [
  671. 'student_id' => $studentId,
  672. 'answered_count' => count($answeredQuestionIds)
  673. ]);
  674. }
  675. // 组装增强参数
  676. $enhanced = array_merge($params, [
  677. 'kp_codes' => $kpCodeList,
  678. 'exclude_question_ids' => $answeredQuestionIds,
  679. 'paper_name' => $params['paper_name'] ?? ('知识点组卷_' . now()->format('Ymd_His')),
  680. // 知识点组卷:注重题型平衡(恢复原有配比)
  681. 'question_type_ratio' => [
  682. '选择题' => 35,
  683. '填空题' => 30,
  684. '解答题' => 35,
  685. ],
  686. 'difficulty_ratio' => [
  687. '基础' => 25,
  688. '中等' => 50,
  689. '拔高' => 25,
  690. ],
  691. 'question_category' => 0, // question_category=0 代表普通题目
  692. 'is_knowledge_point_assemble' => true,
  693. ]);
  694. Log::info('ExamTypeStrategy: 知识点组卷参数构建完成', [
  695. 'kp_count' => count($kpCodeList),
  696. 'exclude_count' => count($answeredQuestionIds),
  697. 'total_questions' => $totalQuestions
  698. ]);
  699. return $enhanced;
  700. }
  701. /**
  702. * 教材组卷 (assembleType=3)
  703. * 根据 chapter_id_list 查询课本章节,获取知识点,然后组卷
  704. * 优化:按textbook_catalog_node_id筛选题目,添加章节知识点数量统计
  705. */
  706. private function buildTextbookAssembleParams(array $params): array
  707. {
  708. Log::info('ExamTypeStrategy: 构建教材组卷参数', $params);
  709. $chapterIdList = $params['chapter_id_list'] ?? [];
  710. $studentId = $params['student_id'] ?? null;
  711. $totalQuestions = $params['total_questions'] ?? 20;
  712. // 【优化】如果用户没有指定章节,自动从教材所有有题目的章节中选择
  713. if (empty($chapterIdList) && !empty($params['textbook_id'])) {
  714. Log::info('ExamTypeStrategy: 用户未指定章节,自动从教材选择', [
  715. 'textbook_id' => $params['textbook_id']
  716. ]);
  717. // 【修复】先查询教材下的所有章节,再筛选有题目的章节
  718. // 步骤1:根据textbook_id获取该教材下的所有章节ID
  719. $allChapterIds = DB::table('textbook_catalog_nodes')
  720. ->where('textbook_id', $params['textbook_id'])
  721. ->where('node_type', 'section')
  722. ->pluck('id')
  723. ->toArray();
  724. if (empty($allChapterIds)) {
  725. Log::warning('ExamTypeStrategy: 教材下未找到章节', [
  726. 'textbook_id' => $params['textbook_id']
  727. ]);
  728. } else {
  729. // 步骤2:从这些章节中筛选出有题目的章节
  730. $chapterIdList = DB::table('questions')
  731. ->whereIn('textbook_catalog_nodes_id', $allChapterIds)
  732. ->whereNotNull('textbook_catalog_nodes_id')
  733. ->where('textbook_catalog_nodes_id', '!=', '')
  734. ->distinct()
  735. ->pluck('textbook_catalog_nodes_id')
  736. ->toArray();
  737. Log::info('ExamTypeStrategy: 自动选择的章节列表', [
  738. 'textbook_id' => $params['textbook_id'],
  739. 'total_chapters' => count($allChapterIds),
  740. 'chapters_with_questions' => count($chapterIdList),
  741. 'chapter_ids' => $chapterIdList
  742. ]);
  743. }
  744. }
  745. // 【新增】如果用户指定了章节,解析章节节点类型并获取所有section/subsection节点
  746. if (!empty($chapterIdList)) {
  747. Log::info('ExamTypeStrategy: 用户指定了章节,开始解析节点类型', [
  748. 'input_chapter_ids' => $chapterIdList
  749. ]);
  750. // 解析用户传入的chapter_id_list,获取所有section/subsection节点
  751. $resolvedSectionIds = $this->resolveSectionNodesFromChapters($chapterIdList);
  752. if (!empty($resolvedSectionIds)) {
  753. // 使用解析后的section/subsection节点ID替换原有的chapterIdList
  754. $originalChapterCount = count($chapterIdList);
  755. $chapterIdList = $resolvedSectionIds;
  756. Log::info('ExamTypeStrategy: 章节节点解析完成,使用解析后的节点', [
  757. 'original_count' => $originalChapterCount,
  758. 'resolved_count' => count($chapterIdList),
  759. 'resolved_ids' => $chapterIdList
  760. ]);
  761. } else {
  762. Log::warning('ExamTypeStrategy: 章节节点解析失败,使用原始节点列表', [
  763. 'chapter_id_list' => $chapterIdList
  764. ]);
  765. }
  766. }
  767. if (empty($chapterIdList)) {
  768. Log::warning('ExamTypeStrategy: 教材组卷需要 chapter_id_list 参数或有效的textbook_id');
  769. return $this->buildGeneralParams($params);
  770. }
  771. Log::info('ExamTypeStrategy: 教材组卷', [
  772. 'chapter_id_list' => $chapterIdList,
  773. 'student_id' => $studentId,
  774. 'total_questions' => $totalQuestions
  775. ]);
  776. // 【修复】第一步:根据章节ID查询知识点关联,并统计每个章节的知识点数量
  777. $kpCodes = $this->getKnowledgePointsFromChapters($chapterIdList);
  778. // 【新增】获取每个章节对应的知识点数量统计
  779. $chapterKnowledgePointStats = $this->getChapterKnowledgePointStats($chapterIdList);
  780. Log::info('ExamTypeStrategy: 获取章节知识点统计', [
  781. 'chapter_stats' => $chapterKnowledgePointStats,
  782. 'total_chapters' => count($chapterIdList)
  783. ]);
  784. // 【重要】教材组卷严格限制知识点数量,不进行任何扩展
  785. if (empty($kpCodes)) {
  786. Log::warning('ExamTypeStrategy: 未找到章节知识点关联,但保留章节筛选参数', [
  787. 'chapter_id_list' => $chapterIdList,
  788. 'note' => '将在LearningAnalyticsService中按textbook_catalog_nodes_id字段筛选题目'
  789. ]);
  790. } else {
  791. Log::info('ExamTypeStrategy: 获取章节知识点(教材组卷严格限制)', [
  792. 'kp_count' => count($kpCodes),
  793. 'kp_codes' => $kpCodes,
  794. 'note' => '教材组卷只返回章节关联的知识点,不进行扩展'
  795. ]);
  796. }
  797. // 第二步:如果有学生ID,获取已做过的题目ID列表(用于排除)
  798. $answeredQuestionIds = [];
  799. if ($studentId) {
  800. $answeredQuestionIds = $this->getStudentAnsweredQuestionIds($studentId, $kpCodes);
  801. Log::info('ExamTypeStrategy: 获取学生已答题目', [
  802. 'student_id' => $studentId,
  803. 'answered_count' => count($answeredQuestionIds)
  804. ]);
  805. }
  806. // 【优化】第三步:按textbook_catalog_node_id筛选题目,添加筛选条件
  807. // 在LearningAnalyticsService中会使用这些参数进行题目筛选
  808. $textbookCatalogNodeIds = $chapterIdList; // 直接使用章节ID作为textbook_catalog_node_id筛选条件
  809. // 组装增强参数
  810. $enhanced = array_merge($params, [
  811. 'kp_codes' => $kpCodes, // 可能为空,但仍保留
  812. 'chapter_id_list' => $chapterIdList,
  813. 'textbook_catalog_node_ids' => $textbookCatalogNodeIds, // 【重要】即使kpCodes为空也设置此参数
  814. 'exclude_question_ids' => $answeredQuestionIds,
  815. 'paper_name' => $params['paper_name'] ?? ('教材组卷_' . now()->format('Ymd_His')),
  816. // 教材组卷:按教材特点分配题型
  817. 'question_type_ratio' => [
  818. '选择题' => 40,
  819. '填空题' => 30,
  820. '解答题' => 30,
  821. ],
  822. 'difficulty_ratio' => [
  823. '基础' => 25,
  824. '中等' => 50,
  825. '拔高' => 25,
  826. ],
  827. 'question_category' => 0, // question_category=0 代表普通题目
  828. 'is_textbook_assemble' => true,
  829. // 【新增】章节知识点数量统计
  830. 'chapter_knowledge_point_stats' => $chapterKnowledgePointStats,
  831. ]);
  832. Log::info('ExamTypeStrategy: 教材组卷参数构建完成', [
  833. 'chapter_count' => count($chapterIdList),
  834. 'kp_count' => count($kpCodes),
  835. 'exclude_count' => count($answeredQuestionIds),
  836. 'total_questions' => $totalQuestions,
  837. 'chapter_stats' => $chapterKnowledgePointStats
  838. ]);
  839. return $enhanced;
  840. }
  841. /**
  842. * 根据课本ID获取章节ID列表
  843. * @param int $textbookId 教材ID
  844. * @param int|null $endCatalogId 截止章节ID(仅摸底使用),只返回该章节及之前的章节
  845. */
  846. private function getTextbookChapterIds(int $textbookId, ?int $endCatalogId = null): array
  847. {
  848. try {
  849. $query = DB::table('textbook_catalog_nodes')
  850. ->where('textbook_id', $textbookId)
  851. ->where('node_type', 'section') // nodeType='section'
  852. ->orderBy('sort_order');
  853. // 如果指定了截止章节,只获取该章节及之前的章节
  854. if ($endCatalogId) {
  855. $endSortOrder = DB::table('textbook_catalog_nodes')
  856. ->where('id', $endCatalogId)
  857. ->value('sort_order');
  858. if ($endSortOrder !== null) {
  859. $query->where('sort_order', '<=', $endSortOrder);
  860. Log::debug('ExamTypeStrategy: 应用截止章节过滤', [
  861. 'end_catalog_id' => $endCatalogId,
  862. 'end_sort_order' => $endSortOrder
  863. ]);
  864. } else {
  865. Log::warning('ExamTypeStrategy: 截止章节ID无效', [
  866. 'end_catalog_id' => $endCatalogId
  867. ]);
  868. }
  869. }
  870. $chapterIds = $query->pluck('id')->toArray();
  871. Log::debug('ExamTypeStrategy: 查询课本章节ID', [
  872. 'textbook_id' => $textbookId,
  873. 'end_catalog_id' => $endCatalogId,
  874. 'found_count' => count($chapterIds)
  875. ]);
  876. return $chapterIds;
  877. } catch (\Exception $e) {
  878. Log::error('ExamTypeStrategy: 查询课本章节ID失败', [
  879. 'textbook_id' => $textbookId,
  880. 'error' => $e->getMessage()
  881. ]);
  882. return [];
  883. }
  884. }
  885. /**
  886. * 根据章节ID列表获取知识点代码列表(均等抽样版本)
  887. * 使用"知识点均等抽样"算法,确保覆盖全书章节
  888. *
  889. * @param array $chapterIds 章节ID列表
  890. * @param int $limit 目标题目数量(默认25)
  891. * @return array 抽样后的知识点代码列表
  892. */
  893. private function getKnowledgePointsFromChapters(array $chapterIds, int $limit = 25): array
  894. {
  895. try {
  896. // 如果章节数较少,使用原有逻辑
  897. if (count($chapterIds) <= 15) {
  898. Log::info('ExamTypeStrategy: 章节数较少,使用原有逻辑', [
  899. 'chapter_count' => count($chapterIds)
  900. ]);
  901. return $this->getKnowledgePointsFromChaptersLegacy($chapterIds, $limit);
  902. }
  903. // ========== 步骤1: 构建全量有序池 ==========
  904. // 查询所有知识点,按章节顺序排列,去重后得到有序数组
  905. $allKpData = DB::table('textbook_chapter_knowledge_relation as tckr')
  906. ->join('textbook_catalog_nodes as tcn', 'tckr.catalog_chapter_id', '=', 'tcn.id')
  907. ->whereIn('tckr.catalog_chapter_id', $chapterIds)
  908. ->select('tckr.kp_code', 'tcn.sort_order', 'tcn.id as chapter_id', 'tcn.title as chapter_title')
  909. ->orderBy('tcn.sort_order', 'asc')
  910. ->orderBy('tckr.kp_code', 'asc')
  911. ->get();
  912. // 去重并构建有序数组
  913. $allKpCodes = [];
  914. $chapterInfo = [];
  915. foreach ($allKpData as $row) {
  916. if (!in_array($row->kp_code, $allKpCodes)) {
  917. $allKpCodes[] = $row->kp_code;
  918. $chapterInfo[$row->kp_code] = [
  919. 'chapter_id' => $row->chapter_id,
  920. 'chapter_title' => $row->chapter_title,
  921. 'sort_order' => $row->sort_order
  922. ];
  923. }
  924. }
  925. $totalKps = count($allKpCodes);
  926. Log::info('ExamTypeStrategy: 构建全量有序池完成', [
  927. 'chapter_count' => count($chapterIds),
  928. 'total_knowledge_points' => $totalKps,
  929. 'target_questions' => $limit
  930. ]);
  931. // ========== 步骤2: 计算抽样步长 ==========
  932. $step = $totalKps > 0 ? $totalKps / $limit : 1;
  933. Log::info('ExamTypeStrategy: 计算抽样步长', [
  934. 'step' => $step
  935. ]);
  936. // ========== 步骤3: 等距取样 ==========
  937. $selectedKps = [];
  938. $selectedCount = 0;
  939. $currentIndex = 0;
  940. while ($selectedCount < $limit && $selectedCount < $totalKps) {
  941. $index = (int) floor($currentIndex);
  942. if ($index < $totalKps && isset($allKpCodes[$index])) {
  943. $kpCode = $allKpCodes[$index];
  944. if (!in_array($kpCode, $selectedKps)) {
  945. $selectedKps[] = $kpCode;
  946. $selectedCount++;
  947. }
  948. }
  949. $currentIndex += $step;
  950. }
  951. // 如果数量不足,循环取样
  952. if (count($selectedKps) < $limit && $totalKps > 0) {
  953. Log::info('ExamTypeStrategy: 第一次抽样不足,进行循环取样', [
  954. 'selected_count' => count($selectedKps),
  955. 'target_count' => $limit
  956. ]);
  957. $needed = $limit - count($selectedKps);
  958. $startIndex = 0;
  959. for ($i = 0; $i < $needed && count($selectedKps) < $limit; $i++) {
  960. $index = ($startIndex + $i) % $totalKps;
  961. $kpCode = $allKpCodes[$index];
  962. if (!in_array($kpCode, $selectedKps)) {
  963. $selectedKps[] = $kpCode;
  964. }
  965. }
  966. }
  967. // ========== 步骤4: 记录抽样统计 ==========
  968. $chapterDistribution = [];
  969. foreach ($selectedKps as $kpCode) {
  970. if (isset($chapterInfo[$kpCode])) {
  971. $chapterId = $chapterInfo[$kpCode]['chapter_id'];
  972. if (!isset($chapterDistribution[$chapterId])) {
  973. $chapterDistribution[$chapterId] = [
  974. 'count' => 0,
  975. 'title' => $chapterInfo[$kpCode]['chapter_title']
  976. ];
  977. }
  978. $chapterDistribution[$chapterId]['count']++;
  979. }
  980. }
  981. Log::info('ExamTypeStrategy: 等距抽样完成', [
  982. 'total_kps' => $totalKps,
  983. 'selected_kps' => count($selectedKps),
  984. 'step' => $step,
  985. 'chapter_distribution' => $chapterDistribution,
  986. 'sample_kp_codes' => array_slice($selectedKps, 0, 10)
  987. ]);
  988. return array_filter($selectedKps);
  989. } catch (\Exception $e) {
  990. Log::error('ExamTypeStrategy: 均等抽样获取章节知识点失败,使用原有逻辑', [
  991. 'chapter_ids' => $chapterIds,
  992. 'error' => $e->getMessage()
  993. ]);
  994. // 失败时回退到原有逻辑
  995. return $this->getKnowledgePointsFromChaptersLegacy($chapterIds, $limit);
  996. }
  997. }
  998. /**
  999. * 原有逻辑(保留作为回退方案)
  1000. * 修复:解决DISTINCT和ORDER BY冲突问题
  1001. */
  1002. private function getKnowledgePointsFromChaptersLegacy(array $chapterIds, int $limit = 25): array
  1003. {
  1004. try {
  1005. // 修复方案1:使用GROUP BY替代DISTINCT,并选择需要的字段
  1006. $kpCodes = DB::table('textbook_chapter_knowledge_relation')
  1007. ->whereIn('catalog_chapter_id', $chapterIds)
  1008. ->select('kp_code')
  1009. ->groupBy('kp_code')
  1010. ->pluck('kp_code')
  1011. ->toArray();
  1012. Log::debug('ExamTypeStrategy: 查询章节知识点(原有逻辑)', [
  1013. 'chapter_count' => count($chapterIds),
  1014. 'found_kp_count' => count($kpCodes)
  1015. ]);
  1016. return array_filter($kpCodes); // 移除空值
  1017. } catch (\Exception $e) {
  1018. Log::error('ExamTypeStrategy: 查询章节知识点失败', [
  1019. 'chapter_ids' => $chapterIds,
  1020. 'error' => $e->getMessage()
  1021. ]);
  1022. return [];
  1023. }
  1024. }
  1025. /**
  1026. * 获取学生已答题目ID列表(用于排除)
  1027. */
  1028. private function getStudentAnsweredQuestionIds(string $studentId, array $kpCodes): array
  1029. {
  1030. try {
  1031. // 【修复】查询 student_answer_questions 表,不使用 kp_code 过滤(该字段可能不存在)
  1032. $query = DB::table('student_answer_questions')
  1033. ->where('student_id', $studentId)
  1034. ->distinct();
  1035. // 如果有 question_id 字段,直接查询
  1036. $questionIds = $query->pluck('question_id')->toArray();
  1037. Log::debug('ExamTypeStrategy: 查询学生已答题目', [
  1038. 'student_id' => $studentId,
  1039. 'kp_count' => count($kpCodes),
  1040. 'answered_count' => count($questionIds),
  1041. 'note' => '只按学生ID过滤,不按kp_code过滤'
  1042. ]);
  1043. return array_filter($questionIds); // 移除空值
  1044. } catch (\Exception $e) {
  1045. Log::error('ExamTypeStrategy: 查询学生已答题目失败', [
  1046. 'student_id' => $studentId,
  1047. 'error' => $e->getMessage()
  1048. ]);
  1049. return [];
  1050. }
  1051. }
  1052. /**
  1053. * 通过卷子ID列表查询错题
  1054. * 注意:paper_ids 使用的是 papers 表中的 paper_id 字段,不是 id 字段
  1055. * 错题数据从 mistake_records 表中获取,该表已包含 paper_id 字段
  1056. * 注意:不按学生ID过滤,因为卷子可能包含其他同学的错题,需要收集所有错题
  1057. */
  1058. private function getMistakeQuestionsFromPapers(array $paperIds, ?string $studentId = null): array
  1059. {
  1060. try {
  1061. // 使用 Eloquent 模型查询,从 mistake_records 表中获取错题记录
  1062. // 不按 student_id 过滤,因为要收集所有学生的错题
  1063. $mistakeRecords = MistakeRecord::query()
  1064. ->whereIn('paper_id', $paperIds)
  1065. ->get();
  1066. if ($mistakeRecords->isEmpty()) {
  1067. Log::warning('ExamTypeStrategy: 卷子中未找到错题记录', [
  1068. 'paper_ids' => $paperIds
  1069. ]);
  1070. return [];
  1071. }
  1072. // 收集所有错题的 question_id
  1073. $questionIds = $mistakeRecords->pluck('question_id')
  1074. ->filter()
  1075. ->unique()
  1076. ->values()
  1077. ->toArray();
  1078. Log::info('ExamTypeStrategy: 查询卷子错题完成', [
  1079. 'paper_count' => count($paperIds),
  1080. 'paper_ids_input' => $paperIds,
  1081. 'mistake_record_count' => $mistakeRecords->count(),
  1082. 'unique_question_count' => count($questionIds),
  1083. 'question_ids' => array_slice($questionIds, 0, 20), // 最多记录20个ID
  1084. 'per_paper_stats' => $mistakeRecords->groupBy('paper_id')->map->count()->toArray()
  1085. ]);
  1086. return array_filter($questionIds);
  1087. } catch (\Exception $e) {
  1088. Log::error('ExamTypeStrategy: 查询卷子错题失败', [
  1089. 'paper_ids' => $paperIds,
  1090. 'error' => $e->getMessage()
  1091. ]);
  1092. return [];
  1093. }
  1094. }
  1095. /**
  1096. * 通过题目ID列表获取知识点代码列表
  1097. */
  1098. private function getKnowledgePointsFromQuestions(array $questionIds): array
  1099. {
  1100. try {
  1101. // 使用 Eloquent 模型查询,获取题目的知识点
  1102. $questions = Question::whereIn('id', $questionIds)
  1103. ->whereNotNull('kp_code')
  1104. ->where('kp_code', '!=', '')
  1105. ->distinct()
  1106. ->pluck('kp_code')
  1107. ->toArray();
  1108. Log::debug('ExamTypeStrategy: 查询题目知识点', [
  1109. 'question_count' => count($questionIds),
  1110. 'kp_count' => count($questions)
  1111. ]);
  1112. return array_filter($questions);
  1113. } catch (\Exception $e) {
  1114. Log::error('ExamTypeStrategy: 查询题目知识点失败', [
  1115. 'question_ids' => array_slice($questionIds, 0, 10), // 只记录前10个
  1116. 'error' => $e->getMessage()
  1117. ]);
  1118. return [];
  1119. }
  1120. }
  1121. /**
  1122. * 【新增】获取每个章节对应的知识点数量统计
  1123. * 通过textbook_chapter_knowledge_relation表关联查询
  1124. *
  1125. * @param array $chapterIdList 章节ID列表
  1126. * @return array 章节知识点数量统计
  1127. */
  1128. private function getChapterKnowledgePointStats(array $chapterIdList): array
  1129. {
  1130. try {
  1131. // 查询每个章节对应的知识点数量
  1132. $stats = DB::table('textbook_chapter_knowledge_relation as tckr')
  1133. ->join('textbook_catalog_nodes as tcn', 'tckr.catalog_chapter_id', '=', 'tcn.id')
  1134. ->whereIn('tckr.catalog_chapter_id', $chapterIdList)
  1135. ->select(
  1136. 'tckr.catalog_chapter_id as chapter_id',
  1137. 'tcn.title as chapter_title',
  1138. DB::raw('COUNT(DISTINCT tckr.kp_code) as knowledge_point_count')
  1139. )
  1140. ->groupBy('tckr.catalog_chapter_id', 'tcn.title')
  1141. ->orderBy('tckr.catalog_chapter_id')
  1142. ->get();
  1143. // 转换为数组格式
  1144. $result = [];
  1145. foreach ($stats as $stat) {
  1146. $result[] = [
  1147. 'chapter_id' => $stat->chapter_id,
  1148. 'chapter_title' => $stat->chapter_title,
  1149. 'knowledge_point_count' => (int) $stat->knowledge_point_count
  1150. ];
  1151. }
  1152. Log::debug('ExamTypeStrategy: 获取章节知识点统计', [
  1153. 'chapter_count' => count($chapterIdList),
  1154. 'stats_count' => count($result),
  1155. 'stats' => $result
  1156. ]);
  1157. return $result;
  1158. } catch (\Exception $e) {
  1159. Log::error('ExamTypeStrategy: 获取章节知识点统计失败', [
  1160. 'chapter_id_list' => $chapterIdList,
  1161. 'error' => $e->getMessage()
  1162. ]);
  1163. // 返回空数组,不影响组卷流程
  1164. return [];
  1165. }
  1166. }
  1167. /**
  1168. * 【新增】递归获取section节点ID列表
  1169. * 根据用户传入的chapter_id_list,自动识别节点类型:
  1170. * - 如果是chapter节点,查找其下的所有section子节点(不包含subsection)
  1171. * - 如果是section节点,直接使用
  1172. *
  1173. * @param array $chapterIdList 用户传入的章节ID列表
  1174. * @return array 最终用于查询知识点的section节点ID列表
  1175. */
  1176. private function resolveSectionNodesFromChapters(array $chapterIdList): array
  1177. {
  1178. try {
  1179. if (empty($chapterIdList)) {
  1180. return [];
  1181. }
  1182. Log::info('ExamTypeStrategy: 开始解析章节节点', [
  1183. 'input_chapter_ids' => $chapterIdList
  1184. ]);
  1185. $finalSectionIds = [];
  1186. foreach ($chapterIdList as $nodeId) {
  1187. // 查询节点信息
  1188. $node = DB::table('textbook_catalog_nodes')
  1189. ->where('id', $nodeId)
  1190. ->first();
  1191. if (!$node) {
  1192. Log::warning('ExamTypeStrategy: 未找到节点', ['node_id' => $nodeId]);
  1193. continue;
  1194. }
  1195. Log::debug('ExamTypeStrategy: 处理节点', [
  1196. 'node_id' => $nodeId,
  1197. 'node_type' => $node->node_type,
  1198. 'title' => $node->title
  1199. ]);
  1200. // 根据节点类型处理
  1201. switch ($node->node_type) {
  1202. case 'chapter':
  1203. // 如果是chapter节点,查找其下的所有section子节点
  1204. $childNodes = DB::table('textbook_catalog_nodes')
  1205. ->where('parent_id', $nodeId)
  1206. ->where('node_type', 'section')
  1207. ->orderBy('sort_order')
  1208. ->get();
  1209. Log::info('ExamTypeStrategy: 解析chapter节点', [
  1210. 'chapter_id' => $nodeId,
  1211. 'chapter_title' => $node->title,
  1212. 'section_count' => count($childNodes)
  1213. ]);
  1214. foreach ($childNodes as $child) {
  1215. if (!in_array($child->id, $finalSectionIds)) {
  1216. $finalSectionIds[] = $child->id;
  1217. Log::debug('ExamTypeStrategy: 添加section子节点', [
  1218. 'parent_chapter' => $nodeId,
  1219. 'child_node_id' => $child->id,
  1220. 'child_title' => $child->title,
  1221. 'child_type' => $child->node_type
  1222. ]);
  1223. }
  1224. }
  1225. break;
  1226. case 'section':
  1227. // 如果是section节点,直接使用
  1228. if (!in_array($nodeId, $finalSectionIds)) {
  1229. $finalSectionIds[] = $nodeId;
  1230. Log::debug('ExamTypeStrategy: 直接使用section节点', [
  1231. 'node_id' => $nodeId,
  1232. 'title' => $node->title,
  1233. 'node_type' => $node->node_type
  1234. ]);
  1235. }
  1236. break;
  1237. case 'subsection':
  1238. // 如果是subsection节点,需要找到其父级section节点
  1239. $parentSection = DB::table('textbook_catalog_nodes')
  1240. ->where('id', $node->parent_id)
  1241. ->where('node_type', 'section')
  1242. ->first();
  1243. if ($parentSection && !in_array($parentSection->id, $finalSectionIds)) {
  1244. $finalSectionIds[] = $parentSection->id;
  1245. Log::debug('ExamTypeStrategy: 通过subsection找到父级section', [
  1246. 'subsection_id' => $nodeId,
  1247. 'subsection_title' => $node->title,
  1248. 'parent_section_id' => $parentSection->id,
  1249. 'parent_section_title' => $parentSection->title
  1250. ]);
  1251. }
  1252. break;
  1253. default:
  1254. Log::warning('ExamTypeStrategy: 未知节点类型', [
  1255. 'node_id' => $nodeId,
  1256. 'node_type' => $node->node_type
  1257. ]);
  1258. break;
  1259. }
  1260. }
  1261. Log::info('ExamTypeStrategy: 章节节点解析完成', [
  1262. 'input_count' => count($chapterIdList),
  1263. 'output_section_count' => count($finalSectionIds),
  1264. 'section_ids' => $finalSectionIds
  1265. ]);
  1266. return $finalSectionIds;
  1267. } catch (\Exception $e) {
  1268. Log::error('ExamTypeStrategy: 解析章节节点失败', [
  1269. 'chapter_id_list' => $chapterIdList,
  1270. 'error' => $e->getMessage()
  1271. ]);
  1272. // 出错时返回空数组
  1273. return [];
  1274. }
  1275. }
  1276. }