QuestionLocalService.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  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\Str;
  10. class QuestionLocalService
  11. {
  12. public function listQuestions(int $page = 1, int $perPage = 50, array $filters = []): array
  13. {
  14. $query = $this->applyFilters(Question::query(), $filters);
  15. $paginator = $query->orderByDesc('id')->paginate($perPage, ['*'], 'page', $page);
  16. $data = $this->mapQuestions(collect($paginator->items()));
  17. return [
  18. 'data' => $data,
  19. 'meta' => [
  20. 'page' => $paginator->currentPage(),
  21. 'per_page' => $paginator->perPage(),
  22. 'total' => $paginator->total(),
  23. 'total_pages' => $paginator->lastPage(),
  24. ],
  25. ];
  26. }
  27. public function getQuestionById(int $id): ?array
  28. {
  29. $question = Question::find($id);
  30. if (!$question) {
  31. return null;
  32. }
  33. return $this->mapQuestion($question);
  34. }
  35. public function getQuestionByCode(string $questionCode): ?array
  36. {
  37. $question = Question::where('question_code', $questionCode)->first();
  38. if (!$question) {
  39. return null;
  40. }
  41. return $this->mapQuestion($question);
  42. }
  43. public function updateQuestionByCode(string $questionCode, array $payload): bool
  44. {
  45. $question = Question::where('question_code', $questionCode)->first();
  46. if (!$question) {
  47. return false;
  48. }
  49. $question->fill($this->normalizePayload($payload));
  50. $question->save();
  51. return true;
  52. }
  53. public function deleteQuestionByCode(string $questionCode): bool
  54. {
  55. $question = Question::where('question_code', $questionCode)->first();
  56. if (!$question) {
  57. return false;
  58. }
  59. $question->delete();
  60. return true;
  61. }
  62. public function deleteQuestionById(int $id): bool
  63. {
  64. $question = Question::find($id);
  65. if (!$question) {
  66. return false;
  67. }
  68. $question->delete();
  69. return true;
  70. }
  71. public function searchQuestions(string $query, int $limit = 20): array
  72. {
  73. $questions = Question::query()
  74. ->search($query)
  75. ->orderByDesc('id')
  76. ->limit($limit)
  77. ->get();
  78. return [
  79. 'data' => $this->mapQuestions($questions),
  80. ];
  81. }
  82. public function getQuestionsByIds(array $ids): array
  83. {
  84. if (empty($ids)) {
  85. return ['data' => []];
  86. }
  87. $questions = Question::query()
  88. ->whereIn('id', $ids)
  89. ->orderByDesc('id')
  90. ->get();
  91. return [
  92. 'data' => $this->mapQuestions($questions),
  93. ];
  94. }
  95. public function getQuestionsByKpCode(string $kpCode, int $limit = 100): array
  96. {
  97. $questions = Question::query()
  98. ->where('kp_code', $kpCode)
  99. ->orderByDesc('id')
  100. ->limit($limit)
  101. ->get();
  102. return [
  103. 'data' => $this->mapQuestions($questions),
  104. ];
  105. }
  106. public function getStatistics(array $filters = []): array
  107. {
  108. $baseQuery = $this->applyFilters(Question::query(), $filters);
  109. $total = (clone $baseQuery)->count();
  110. $byDifficulty = (clone $baseQuery)
  111. ->selectRaw('difficulty, COUNT(*) as total')
  112. ->groupBy('difficulty')
  113. ->pluck('total', 'difficulty')
  114. ->toArray();
  115. $byTypeRaw = (clone $baseQuery)
  116. ->selectRaw('question_type, COUNT(*) as total')
  117. ->groupBy('question_type')
  118. ->pluck('total', 'question_type')
  119. ->toArray();
  120. $byType = [];
  121. foreach ($byTypeRaw as $type => $count) {
  122. $label = $this->mapQuestionTypeLabel((string) $type);
  123. $byType[$label] = ($byType[$label] ?? 0) + $count;
  124. }
  125. $byKp = (clone $baseQuery)
  126. ->selectRaw('kp_code, COUNT(*) as total')
  127. ->groupBy('kp_code')
  128. ->pluck('total', 'kp_code')
  129. ->toArray();
  130. $bySource = (clone $baseQuery)
  131. ->selectRaw('source, COUNT(*) as total')
  132. ->groupBy('source')
  133. ->pluck('total', 'source')
  134. ->toArray();
  135. return [
  136. 'total' => $total,
  137. 'by_difficulty' => $byDifficulty,
  138. 'by_type' => $byType,
  139. 'by_kp' => $byKp,
  140. 'by_source' => $bySource,
  141. ];
  142. }
  143. public function generateQuestions(array $params): array
  144. {
  145. $kpCode = $params['kp_code'] ?? null;
  146. // 允许 kp_code 为空,此时从所有可用题目中选择
  147. if (!$kpCode) {
  148. // 从 params 中获取 kp_codes 数组
  149. $kpCodes = $params['kp_codes'] ?? [];
  150. if (is_string($kpCodes)) {
  151. $kpCodes = array_map('trim', explode(',', $kpCodes));
  152. }
  153. if (is_array($kpCodes) && !empty($kpCodes)) {
  154. $kpCode = $kpCodes[0]; // 使用第一个知识点
  155. } else {
  156. // 如果没有指定知识点,从数据库中随机选择一个可用的知识点
  157. $availableKp = Question::query()
  158. ->whereNotNull('kp_code')
  159. ->where('kp_code', '!=', '')
  160. ->distinct()
  161. ->pluck('kp_code')
  162. ->first();
  163. if ($availableKp) {
  164. $kpCode = $availableKp;
  165. } else {
  166. return [
  167. 'success' => false,
  168. 'message' => '系统中没有可用的题目,请先添加题目数据',
  169. ];
  170. }
  171. }
  172. }
  173. $count = max(1, (int) ($params['count'] ?? 1));
  174. $keyword = (string) ($params['keyword'] ?? '');
  175. $type = $params['type'] ?? null;
  176. $difficulty = $params['difficulty'] ?? null;
  177. $skills = $params['skills'] ?? [];
  178. $solutionService = app(AiSolutionService::class);
  179. $created = [];
  180. DB::transaction(function () use (
  181. $count,
  182. $kpCode,
  183. $keyword,
  184. $type,
  185. $difficulty,
  186. $skills,
  187. $solutionService,
  188. &$created,
  189. $params
  190. ) {
  191. for ($i = 1; $i <= $count; $i++) {
  192. $questionCode = $this->generateQuestionCode();
  193. $stemSuffix = $keyword ? "({$keyword})" : '';
  194. $stem = "【AI生成】{$kpCode} 题目 {$i}{$stemSuffix}";
  195. $options = null;
  196. $answer = null;
  197. $questionType = $type ?? 'CALCULATION';
  198. if (in_array($questionType, ['CHOICE', 'MULTIPLE_CHOICE'], true)) {
  199. $options = [
  200. ['label' => 'A', 'text' => '选项 A'],
  201. ['label' => 'B', 'text' => '选项 B'],
  202. ['label' => 'C', 'text' => '选项 C'],
  203. ['label' => 'D', 'text' => '选项 D'],
  204. ];
  205. $answer = 'A';
  206. }
  207. $solution = $solutionService->generateSolution($stem, [
  208. 'kp_code' => $kpCode,
  209. 'difficulty' => $difficulty,
  210. 'question_type' => $questionType,
  211. ]);
  212. $question = Question::create([
  213. 'question_code' => $questionCode,
  214. 'kp_code' => $kpCode,
  215. 'stem' => $stem,
  216. 'options' => $options,
  217. 'answer' => $answer,
  218. 'solution' => $solution['solution'] ?? null,
  219. 'difficulty' => $difficulty,
  220. 'source' => 'ai::local',
  221. 'question_type' => $questionType,
  222. 'meta' => [
  223. 'skills' => $skills,
  224. 'prompt_template' => $params['prompt_template'] ?? null,
  225. 'strategy' => $params['strategy'] ?? null,
  226. 'generated_at' => now()->toDateTimeString(),
  227. 'solution_steps' => $solution['steps'] ?? [],
  228. ],
  229. ]);
  230. $created[] = $this->mapQuestion($question);
  231. }
  232. });
  233. return [
  234. 'success' => true,
  235. 'message' => '生成完成',
  236. 'count' => count($created),
  237. 'data' => $created,
  238. ];
  239. }
  240. public function importQuestions(array $questions): array
  241. {
  242. if (empty($questions)) {
  243. return [
  244. 'success' => false,
  245. 'message' => '题目为空',
  246. 'count' => 0,
  247. ];
  248. }
  249. $created = 0;
  250. DB::transaction(function () use ($questions, &$created) {
  251. foreach ($questions as $payload) {
  252. $questionCode = $payload['question_code'] ?? $this->generateQuestionCode();
  253. $question = Question::firstOrNew(['question_code' => $questionCode]);
  254. $question->fill($this->normalizePayload($payload));
  255. $question->save();
  256. $created++;
  257. }
  258. });
  259. return [
  260. 'success' => true,
  261. 'message' => '导入完成',
  262. 'count' => $created,
  263. ];
  264. }
  265. public function selectQuestionsForExam(int $totalQuestions, array $filters): array
  266. {
  267. $query = Question::query();
  268. if (!empty($filters['kp_codes'])) {
  269. $query->whereIn('kp_code', $filters['kp_codes']);
  270. }
  271. if (!empty($filters['skills'])) {
  272. $skills = array_values(array_filter($filters['skills']));
  273. if (!empty($skills)) {
  274. $query->where(function ($q) use ($skills) {
  275. foreach ($skills as $skill) {
  276. $q->orWhereJsonContains('meta->skills', $skill);
  277. }
  278. });
  279. }
  280. }
  281. $questions = $query->get();
  282. $selected = $this->applyRatioSelection($questions, $totalQuestions, $filters);
  283. return $this->mapQuestions(collect($selected));
  284. }
  285. public function getKnowledgePointOptions(): array
  286. {
  287. return KnowledgePoint::query()
  288. ->orderBy('kp_code')
  289. ->pluck('name', 'kp_code')
  290. ->toArray();
  291. }
  292. public function getSkillNameMapping(?string $kpCode = null): array
  293. {
  294. return [];
  295. }
  296. private function applyFilters($query, array $filters)
  297. {
  298. if (!empty($filters['kp_code'])) {
  299. $query->where('kp_code', $filters['kp_code']);
  300. }
  301. if (!empty($filters['difficulty'])) {
  302. $query->where('difficulty', $filters['difficulty']);
  303. }
  304. if (!empty($filters['type'])) {
  305. $query->where('question_type', $filters['type']);
  306. }
  307. if (!empty($filters['search'])) {
  308. $query->search($filters['search']);
  309. }
  310. return $query;
  311. }
  312. private function normalizePayload(array $payload): array
  313. {
  314. $normalized = [
  315. 'question_code' => $payload['question_code'] ?? null,
  316. 'kp_code' => $payload['kp_code'] ?? null,
  317. 'stem' => $payload['stem'] ?? ($payload['content'] ?? ''),
  318. 'options' => $payload['options'] ?? null,
  319. 'answer' => $payload['answer'] ?? null,
  320. 'solution' => $payload['solution'] ?? null,
  321. 'difficulty' => $payload['difficulty'] ?? null,
  322. 'source' => $payload['source'] ?? null,
  323. 'tags' => $payload['tags'] ?? null,
  324. 'question_type' => $payload['question_type'] ?? ($payload['type'] ?? null),
  325. 'meta' => $payload['meta'] ?? null,
  326. ];
  327. if (isset($payload['skills'])) {
  328. $meta = $normalized['meta'] ?? [];
  329. $meta['skills'] = is_array($payload['skills'])
  330. ? $payload['skills']
  331. : array_filter(array_map('trim', explode(',', (string) $payload['skills'])));
  332. $normalized['meta'] = $meta;
  333. }
  334. return array_filter($normalized, static fn ($value) => $value !== null);
  335. }
  336. private function mapQuestions(Collection $questions): array
  337. {
  338. $kpCodes = $questions->pluck('kp_code')->filter()->unique()->values();
  339. $kpMap = $this->resolveKnowledgePointNames($kpCodes->all());
  340. return $questions->map(function (Question $question) use ($kpMap) {
  341. $data = $this->mapQuestion($question);
  342. $data['kp_name'] = $kpMap[$question->kp_code] ?? null;
  343. return $data;
  344. })->values()->all();
  345. }
  346. private function mapQuestion(Question $question): array
  347. {
  348. $meta = $question->meta ?? [];
  349. $data = [
  350. 'id' => $question->id,
  351. 'question_code' => $question->question_code,
  352. 'kp_code' => $question->kp_code,
  353. 'stem' => $question->stem,
  354. 'options' => $question->options,
  355. 'answer' => $question->answer,
  356. 'solution' => $question->solution,
  357. 'difficulty' => $question->difficulty,
  358. 'source' => $question->source,
  359. 'tags' => $question->tags,
  360. 'type' => $question->question_type,
  361. 'question_type' => $question->question_type,
  362. 'skills' => $meta['skills'] ?? [],
  363. 'meta' => $meta,
  364. 'created_at' => $question->created_at?->toDateTimeString(),
  365. 'updated_at' => $question->updated_at?->toDateTimeString(),
  366. ];
  367. return MathFormulaProcessor::processQuestionData($data);
  368. }
  369. private function generateQuestionCode(): string
  370. {
  371. return 'Q' . Str::upper(Str::random(10));
  372. }
  373. private function mapQuestionTypeLabel(string $type): string
  374. {
  375. return match (strtoupper($type)) {
  376. 'CHOICE' => '选择题',
  377. 'MULTIPLE_CHOICE' => '多选题',
  378. 'FILL_IN_THE_BLANK', 'FILL' => '填空题',
  379. 'CALCULATION', 'WORD_PROBLEM', 'ANSWER' => '解答题',
  380. 'PROOF' => '证明题',
  381. default => '其他',
  382. };
  383. }
  384. private function applyRatioSelection(Collection $questions, int $totalQuestions, array $filters): array
  385. {
  386. $questionsByType = $questions->groupBy(fn (Question $q) => $q->question_type ?? 'CALCULATION');
  387. $questionsByDifficulty = $questions->groupBy(function (Question $q) {
  388. $difficulty = (float) ($q->difficulty ?? 0);
  389. if ($difficulty <= 0.4) {
  390. return 'easy';
  391. }
  392. if ($difficulty <= 0.7) {
  393. return 'medium';
  394. }
  395. return 'hard';
  396. });
  397. $typeRatio = $filters['question_type_ratio'] ?? [];
  398. $difficultyRatio = $filters['difficulty_ratio'] ?? [];
  399. $selected = collect();
  400. if (!empty($typeRatio)) {
  401. foreach ($typeRatio as $type => $ratio) {
  402. $bucket = $questionsByType->get($type, collect());
  403. $count = (int) round($totalQuestions * (float) $ratio);
  404. $selected = $selected->merge($bucket->shuffle()->take($count));
  405. }
  406. }
  407. if (!empty($difficultyRatio)) {
  408. foreach ($difficultyRatio as $key => $ratio) {
  409. $bucketKey = $this->normalizeDifficultyKey($key);
  410. $bucket = $questionsByDifficulty->get($bucketKey, collect());
  411. $count = (int) round($totalQuestions * (float) $ratio);
  412. $selected = $selected->merge($bucket->shuffle()->take($count));
  413. }
  414. }
  415. if ($selected->isEmpty()) {
  416. return $questions->shuffle()->take($totalQuestions)->values()->all();
  417. }
  418. if ($selected->count() < $totalQuestions) {
  419. $missing = $totalQuestions - $selected->count();
  420. $fill = $questions->diff($selected)->shuffle()->take($missing);
  421. $selected = $selected->merge($fill);
  422. }
  423. return $selected->values()->all();
  424. }
  425. private function normalizeDifficultyKey(string $key): string
  426. {
  427. if (in_array($key, ['easy', 'medium', 'hard'], true)) {
  428. return $key;
  429. }
  430. $value = (float) $key;
  431. if ($value <= 0.4) {
  432. return 'easy';
  433. }
  434. if ($value <= 0.7) {
  435. return 'medium';
  436. }
  437. return 'hard';
  438. }
  439. private function resolveKnowledgePointNames(array $kpCodes): array
  440. {
  441. $kpCodes = array_values(array_filter(array_unique($kpCodes)));
  442. if (empty($kpCodes)) {
  443. return [];
  444. }
  445. $cacheKey = 'kp-name-map-' . md5(implode('|', $kpCodes));
  446. return Cache::remember($cacheKey, now()->addMinutes(30), function () use ($kpCodes) {
  447. $kpMap = KnowledgePoint::query()
  448. ->whereIn('kp_code', $kpCodes)
  449. ->pluck('name', 'kp_code')
  450. ->toArray();
  451. $missing = array_values(array_filter($kpCodes, fn ($code) => empty($kpMap[$code])));
  452. if (empty($missing)) {
  453. return $kpMap;
  454. }
  455. try {
  456. $api = app(KnowledgeServiceApi::class);
  457. $all = $api->listKnowledgePoints();
  458. foreach ($all as $kp) {
  459. $code = $kp['kp_code'] ?? null;
  460. $name = $kp['cn_name'] ?? $kp['name'] ?? null;
  461. if ($code && $name && in_array($code, $missing, true)) {
  462. $kpMap[$code] = $name;
  463. }
  464. }
  465. } catch (\Throwable $e) {
  466. // Fallback: keep existing mapping
  467. }
  468. return $kpMap;
  469. });
  470. }
  471. }