QuestionLocalService.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  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. if (!$kpCode) {
  147. return [
  148. 'success' => false,
  149. 'message' => '缺少知识点代码',
  150. ];
  151. }
  152. $count = max(1, (int) ($params['count'] ?? 1));
  153. $keyword = (string) ($params['keyword'] ?? '');
  154. $type = $params['type'] ?? null;
  155. $difficulty = $params['difficulty'] ?? null;
  156. $skills = $params['skills'] ?? [];
  157. $solutionService = app(AiSolutionService::class);
  158. $created = [];
  159. DB::transaction(function () use (
  160. $count,
  161. $kpCode,
  162. $keyword,
  163. $type,
  164. $difficulty,
  165. $skills,
  166. $solutionService,
  167. &$created,
  168. $params
  169. ) {
  170. for ($i = 1; $i <= $count; $i++) {
  171. $questionCode = $this->generateQuestionCode();
  172. $stemSuffix = $keyword ? "({$keyword})" : '';
  173. $stem = "【AI生成】{$kpCode} 题目 {$i}{$stemSuffix}";
  174. $options = null;
  175. $answer = null;
  176. $questionType = $type ?? 'CALCULATION';
  177. if (in_array($questionType, ['CHOICE', 'MULTIPLE_CHOICE'], true)) {
  178. $options = [
  179. ['label' => 'A', 'text' => '选项 A'],
  180. ['label' => 'B', 'text' => '选项 B'],
  181. ['label' => 'C', 'text' => '选项 C'],
  182. ['label' => 'D', 'text' => '选项 D'],
  183. ];
  184. $answer = 'A';
  185. }
  186. $solution = $solutionService->generateSolution($stem, [
  187. 'kp_code' => $kpCode,
  188. 'difficulty' => $difficulty,
  189. 'question_type' => $questionType,
  190. ]);
  191. $question = Question::create([
  192. 'question_code' => $questionCode,
  193. 'kp_code' => $kpCode,
  194. 'stem' => $stem,
  195. 'options' => $options,
  196. 'answer' => $answer,
  197. 'solution' => $solution['solution'] ?? null,
  198. 'difficulty' => $difficulty,
  199. 'source' => 'ai::local',
  200. 'question_type' => $questionType,
  201. 'meta' => [
  202. 'skills' => $skills,
  203. 'prompt_template' => $params['prompt_template'] ?? null,
  204. 'strategy' => $params['strategy'] ?? null,
  205. 'generated_at' => now()->toDateTimeString(),
  206. 'solution_steps' => $solution['steps'] ?? [],
  207. ],
  208. ]);
  209. $created[] = $this->mapQuestion($question);
  210. }
  211. });
  212. return [
  213. 'success' => true,
  214. 'message' => '生成完成',
  215. 'count' => count($created),
  216. 'data' => $created,
  217. ];
  218. }
  219. public function importQuestions(array $questions): array
  220. {
  221. if (empty($questions)) {
  222. return [
  223. 'success' => false,
  224. 'message' => '题目为空',
  225. 'count' => 0,
  226. ];
  227. }
  228. $created = 0;
  229. DB::transaction(function () use ($questions, &$created) {
  230. foreach ($questions as $payload) {
  231. $questionCode = $payload['question_code'] ?? $this->generateQuestionCode();
  232. $question = Question::firstOrNew(['question_code' => $questionCode]);
  233. $question->fill($this->normalizePayload($payload));
  234. $question->save();
  235. $created++;
  236. }
  237. });
  238. return [
  239. 'success' => true,
  240. 'message' => '导入完成',
  241. 'count' => $created,
  242. ];
  243. }
  244. public function selectQuestionsForExam(int $totalQuestions, array $filters): array
  245. {
  246. $query = Question::query();
  247. if (!empty($filters['kp_codes'])) {
  248. $query->whereIn('kp_code', $filters['kp_codes']);
  249. }
  250. if (!empty($filters['skills'])) {
  251. $skills = array_values(array_filter($filters['skills']));
  252. if (!empty($skills)) {
  253. $query->where(function ($q) use ($skills) {
  254. foreach ($skills as $skill) {
  255. $q->orWhereJsonContains('meta->skills', $skill);
  256. }
  257. });
  258. }
  259. }
  260. $questions = $query->get();
  261. $selected = $this->applyRatioSelection($questions, $totalQuestions, $filters);
  262. return $this->mapQuestions(collect($selected));
  263. }
  264. public function getKnowledgePointOptions(): array
  265. {
  266. return KnowledgePoint::query()
  267. ->orderBy('kp_code')
  268. ->pluck('name', 'kp_code')
  269. ->toArray();
  270. }
  271. public function getSkillNameMapping(?string $kpCode = null): array
  272. {
  273. return [];
  274. }
  275. private function applyFilters($query, array $filters)
  276. {
  277. if (!empty($filters['kp_code'])) {
  278. $query->where('kp_code', $filters['kp_code']);
  279. }
  280. if (!empty($filters['difficulty'])) {
  281. $query->where('difficulty', $filters['difficulty']);
  282. }
  283. if (!empty($filters['type'])) {
  284. $query->where('question_type', $filters['type']);
  285. }
  286. if (!empty($filters['search'])) {
  287. $query->search($filters['search']);
  288. }
  289. return $query;
  290. }
  291. private function normalizePayload(array $payload): array
  292. {
  293. $normalized = [
  294. 'question_code' => $payload['question_code'] ?? null,
  295. 'kp_code' => $payload['kp_code'] ?? null,
  296. 'stem' => $payload['stem'] ?? ($payload['content'] ?? ''),
  297. 'options' => $payload['options'] ?? null,
  298. 'answer' => $payload['answer'] ?? null,
  299. 'solution' => $payload['solution'] ?? null,
  300. 'difficulty' => $payload['difficulty'] ?? null,
  301. 'source' => $payload['source'] ?? null,
  302. 'tags' => $payload['tags'] ?? null,
  303. 'question_type' => $payload['question_type'] ?? ($payload['type'] ?? null),
  304. 'meta' => $payload['meta'] ?? null,
  305. ];
  306. if (isset($payload['skills'])) {
  307. $meta = $normalized['meta'] ?? [];
  308. $meta['skills'] = is_array($payload['skills'])
  309. ? $payload['skills']
  310. : array_filter(array_map('trim', explode(',', (string) $payload['skills'])));
  311. $normalized['meta'] = $meta;
  312. }
  313. return array_filter($normalized, static fn ($value) => $value !== null);
  314. }
  315. private function mapQuestions(Collection $questions): array
  316. {
  317. $kpCodes = $questions->pluck('kp_code')->filter()->unique()->values();
  318. $kpMap = $this->resolveKnowledgePointNames($kpCodes->all());
  319. return $questions->map(function (Question $question) use ($kpMap) {
  320. $data = $this->mapQuestion($question);
  321. $data['kp_name'] = $kpMap[$question->kp_code] ?? null;
  322. return $data;
  323. })->values()->all();
  324. }
  325. private function mapQuestion(Question $question): array
  326. {
  327. $meta = $question->meta ?? [];
  328. $data = [
  329. 'id' => $question->id,
  330. 'question_code' => $question->question_code,
  331. 'kp_code' => $question->kp_code,
  332. 'stem' => $question->stem,
  333. 'options' => $question->options,
  334. 'answer' => $question->answer,
  335. 'solution' => $question->solution,
  336. 'difficulty' => $question->difficulty,
  337. 'source' => $question->source,
  338. 'tags' => $question->tags,
  339. 'type' => $question->question_type,
  340. 'question_type' => $question->question_type,
  341. 'skills' => $meta['skills'] ?? [],
  342. 'meta' => $meta,
  343. 'created_at' => $question->created_at?->toDateTimeString(),
  344. 'updated_at' => $question->updated_at?->toDateTimeString(),
  345. ];
  346. return MathFormulaProcessor::processQuestionData($data);
  347. }
  348. private function generateQuestionCode(): string
  349. {
  350. return 'Q' . Str::upper(Str::random(10));
  351. }
  352. private function mapQuestionTypeLabel(string $type): string
  353. {
  354. return match (strtoupper($type)) {
  355. 'CHOICE' => '选择题',
  356. 'MULTIPLE_CHOICE' => '多选题',
  357. 'FILL_IN_THE_BLANK', 'FILL' => '填空题',
  358. 'CALCULATION', 'WORD_PROBLEM', 'ANSWER' => '解答题',
  359. 'PROOF' => '证明题',
  360. default => '其他',
  361. };
  362. }
  363. private function applyRatioSelection(Collection $questions, int $totalQuestions, array $filters): array
  364. {
  365. $questionsByType = $questions->groupBy(fn (Question $q) => $q->question_type ?? 'CALCULATION');
  366. $questionsByDifficulty = $questions->groupBy(function (Question $q) {
  367. $difficulty = (float) ($q->difficulty ?? 0);
  368. if ($difficulty <= 0.4) {
  369. return 'easy';
  370. }
  371. if ($difficulty <= 0.7) {
  372. return 'medium';
  373. }
  374. return 'hard';
  375. });
  376. $typeRatio = $filters['question_type_ratio'] ?? [];
  377. $difficultyRatio = $filters['difficulty_ratio'] ?? [];
  378. $selected = collect();
  379. if (!empty($typeRatio)) {
  380. foreach ($typeRatio as $type => $ratio) {
  381. $bucket = $questionsByType->get($type, collect());
  382. $count = (int) round($totalQuestions * (float) $ratio);
  383. $selected = $selected->merge($bucket->shuffle()->take($count));
  384. }
  385. }
  386. if (!empty($difficultyRatio)) {
  387. foreach ($difficultyRatio as $key => $ratio) {
  388. $bucketKey = $this->normalizeDifficultyKey($key);
  389. $bucket = $questionsByDifficulty->get($bucketKey, collect());
  390. $count = (int) round($totalQuestions * (float) $ratio);
  391. $selected = $selected->merge($bucket->shuffle()->take($count));
  392. }
  393. }
  394. if ($selected->isEmpty()) {
  395. return $questions->shuffle()->take($totalQuestions)->values()->all();
  396. }
  397. if ($selected->count() < $totalQuestions) {
  398. $missing = $totalQuestions - $selected->count();
  399. $fill = $questions->diff($selected)->shuffle()->take($missing);
  400. $selected = $selected->merge($fill);
  401. }
  402. return $selected->values()->all();
  403. }
  404. private function normalizeDifficultyKey(string $key): string
  405. {
  406. if (in_array($key, ['easy', 'medium', 'hard'], true)) {
  407. return $key;
  408. }
  409. $value = (float) $key;
  410. if ($value <= 0.4) {
  411. return 'easy';
  412. }
  413. if ($value <= 0.7) {
  414. return 'medium';
  415. }
  416. return 'hard';
  417. }
  418. private function resolveKnowledgePointNames(array $kpCodes): array
  419. {
  420. $kpCodes = array_values(array_filter(array_unique($kpCodes)));
  421. if (empty($kpCodes)) {
  422. return [];
  423. }
  424. $cacheKey = 'kp-name-map-' . md5(implode('|', $kpCodes));
  425. return Cache::remember($cacheKey, now()->addMinutes(30), function () use ($kpCodes) {
  426. $kpMap = KnowledgePoint::query()
  427. ->whereIn('kp_code', $kpCodes)
  428. ->pluck('name', 'kp_code')
  429. ->toArray();
  430. $missing = array_values(array_filter($kpCodes, fn ($code) => empty($kpMap[$code])));
  431. if (empty($missing)) {
  432. return $kpMap;
  433. }
  434. try {
  435. $api = app(KnowledgeServiceApi::class);
  436. $all = $api->listKnowledgePoints();
  437. foreach ($all as $kp) {
  438. $code = $kp['kp_code'] ?? null;
  439. $name = $kp['cn_name'] ?? $kp['name'] ?? null;
  440. if ($code && $name && in_array($code, $missing, true)) {
  441. $kpMap[$code] = $name;
  442. }
  443. }
  444. } catch (\Throwable $e) {
  445. // Fallback: keep existing mapping
  446. }
  447. return $kpMap;
  448. });
  449. }
  450. }