QuestionDetail.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. <?php
  2. namespace App\Filament\Pages;
  3. use App\Services\QuestionServiceApi;
  4. use App\Services\MistakeBookService;
  5. use App\Services\KnowledgeGraphService;
  6. use App\Services\QuestionPromptService;
  7. use App\Services\AiClientService;
  8. use App\Models\Question;
  9. use App\Models\Student;
  10. use Filament\Notifications\Notification;
  11. use Filament\Pages\Page;
  12. use Illuminate\Support\Facades\Request;
  13. use Illuminate\Support\Facades\Log;
  14. class QuestionDetail extends Page
  15. {
  16. protected static ?string $title = '题目详情';
  17. protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
  18. protected static ?string $navigationLabel = '题目详情';
  19. protected static string|\UnitEnum|null $navigationGroup = '题库管理';
  20. protected static ?int $navigationSort = 4;
  21. protected static ?string $slug = 'question-detail/{questionId?}';
  22. protected string $view = 'filament.pages.question-detail';
  23. public ?string $questionId = null;
  24. public ?string $sourceType = 'question';
  25. public ?string $mistakeId = null;
  26. public ?string $studentId = null;
  27. public array $questionData = [];
  28. public array $mistakeData = [];
  29. public array $relatedQuestions = [];
  30. public ?string $studentName = null;
  31. public array $historySummary = [];
  32. public string $answerOverride = '';
  33. public function mount(?string $questionId = null): void
  34. {
  35. // 检查是否是错题本来源(同时有mistake_id和student_id)
  36. $this->mistakeId = Request::get('mistake_id');
  37. $this->studentId = Request::get('student_id');
  38. $this->questionId = $questionId ?: Request::get('question_id');
  39. if ($this->mistakeId && $this->studentId) {
  40. $this->sourceType = 'mistake';
  41. $this->loadFromMistake();
  42. } else {
  43. $this->sourceType = 'question';
  44. $this->loadFromQuestionBank();
  45. }
  46. if (empty($this->questionData)) {
  47. $this->notifyAndRedirect(
  48. '未能加载到题目数据,请稍后重试',
  49. $this->sourceType === 'mistake'
  50. ? route('filament.admin.pages.mistake-book')
  51. : route('filament.admin.pages.question-management')
  52. );
  53. }
  54. }
  55. protected function loadFromQuestionBank(): void
  56. {
  57. if (!$this->questionId) {
  58. $this->notifyAndRedirect('缺少题目ID参数', route('filament.admin.pages.question-management'));
  59. return;
  60. }
  61. $question = $this->fetchQuestion($this->questionId);
  62. if ($question) {
  63. $this->questionData = $this->prepareQuestionDisplay($question);
  64. $this->answerOverride = (string) ($this->questionData['answer'] ?? '');
  65. $this->attachGlobalAccuracy();
  66. }
  67. }
  68. protected function loadFromMistake(): void
  69. {
  70. $mistakeService = app(MistakeBookService::class);
  71. $detail = $mistakeService->getMistakeDetail($this->mistakeId, $this->studentId);
  72. if (empty($detail)) {
  73. $this->notifyAndRedirect('未能获取错题详情', route('filament.admin.pages.mistake-book'));
  74. return;
  75. }
  76. $this->mistakeData = $detail;
  77. $this->questionId = $detail['question_id'] ?? $this->questionId;
  78. $this->studentName = $detail['student_name'] ?? null;
  79. $bankQuestion = $this->fetchQuestion($this->questionId);
  80. $fallbackQuestion = $detail['question'] ?? [];
  81. if (!$bankQuestion && empty($fallbackQuestion)) {
  82. $this->notifyAndRedirect('题目不存在或已被删除', route('filament.admin.pages.mistake-book'));
  83. return;
  84. }
  85. $question = array_merge($fallbackQuestion, $bankQuestion ?? []);
  86. $question = $this->prepareQuestionDisplay($question);
  87. $this->questionData = array_merge($question, [
  88. 'mistake_info' => [
  89. 'student_answer' => $detail['student_answer'] ?? '',
  90. 'correct' => (bool) ($detail['correct'] ?? false),
  91. 'score' => $detail['score'] ?? null,
  92. 'full_score' => $detail['full_score'] ?? null,
  93. 'partial_score_ratio' => $detail['partial_score_ratio'] ?? null,
  94. 'error_type' => $detail['error_type'] ?? '',
  95. 'mistake_category' => $detail['mistake_category'] ?? '',
  96. 'ai_analysis' => $detail['ai_analysis'] ?? [],
  97. 'created_at' => $detail['created_at'] ?? '',
  98. ],
  99. ]);
  100. $this->answerOverride = (string) ($this->questionData['answer'] ?? '');
  101. // 尝试从本地学生表补充姓名,避免仅展示ID
  102. if (!$this->studentName && $this->studentId) {
  103. try {
  104. $student = Student::find($this->studentId);
  105. if ($student && $student->name) {
  106. $this->studentName = $student->name;
  107. }
  108. } catch (\Throwable $e) {
  109. Log::warning('Load student name failed', [
  110. 'student_id' => $this->studentId,
  111. 'error' => $e->getMessage(),
  112. ]);
  113. }
  114. }
  115. $this->loadHistorySummary();
  116. $this->attachGlobalAccuracy();
  117. }
  118. protected function fetchQuestion(?string $questionId): ?array
  119. {
  120. if (!$questionId) {
  121. return null;
  122. }
  123. try {
  124. $service = app(QuestionServiceApi::class);
  125. $response = $service->getQuestionDetail($questionId);
  126. return $response['data'] ?? null;
  127. } catch (\Throwable $e) {
  128. Log::error('Failed to load question detail', [
  129. 'question_id' => $questionId,
  130. 'error' => $e->getMessage(),
  131. ]);
  132. return null;
  133. }
  134. }
  135. protected function notifyAndRedirect(string $message, string $route): void
  136. {
  137. Notification::make()
  138. ->title('提示')
  139. ->body($message)
  140. ->danger()
  141. ->send();
  142. $this->redirect($route);
  143. }
  144. public function getTitle(): string
  145. {
  146. if ($this->sourceType === 'mistake') {
  147. $label = $this->questionData['question_number'] ?? ('#' . ($this->mistakeId ?? ''));
  148. return '错题详情 - ' . $label;
  149. }
  150. if (!empty($this->questionData)) {
  151. return '题目详情 - ' . ($this->questionData['question_code'] ?? $this->questionId);
  152. }
  153. return '题目详情';
  154. }
  155. public function getBreadcrumbs(): array
  156. {
  157. $breadcrumbs = [
  158. [
  159. 'name' => '题库管理',
  160. 'url' => route('filament.admin.pages.question-management'),
  161. ],
  162. ];
  163. if ($this->sourceType === 'mistake') {
  164. $breadcrumbs[] = [
  165. 'name' => '错题本',
  166. 'url' => route('filament.admin.pages.mistake-book'),
  167. ];
  168. }
  169. $breadcrumbs[] = [
  170. 'name' => '题目详情',
  171. 'url' => '',
  172. ];
  173. return $breadcrumbs;
  174. }
  175. public function getDifficultyColor(): string
  176. {
  177. if (!isset($this->questionData['difficulty'])) {
  178. return 'bg-gray-100 text-gray-700';
  179. }
  180. $difficulty = (float) $this->questionData['difficulty'];
  181. if ($difficulty < 0.4) {
  182. return 'bg-green-100 text-green-700';
  183. } elseif ($difficulty < 0.7) {
  184. return 'bg-yellow-100 text-yellow-700';
  185. }
  186. return 'bg-red-100 text-red-700';
  187. }
  188. public function getDifficultyLabel(): string
  189. {
  190. if (!isset($this->questionData['difficulty'])) {
  191. return '未知';
  192. }
  193. $difficulty = (float) $this->questionData['difficulty'];
  194. if ($difficulty < 0.4) {
  195. return '简单';
  196. } elseif ($difficulty < 0.7) {
  197. return '中等';
  198. }
  199. return '困难';
  200. }
  201. public function saveAnswerOverride(): void
  202. {
  203. if (!$this->questionId) {
  204. return;
  205. }
  206. $question = Question::query()->find($this->questionId);
  207. if (!$question) {
  208. Notification::make()
  209. ->title('题目不存在')
  210. ->danger()
  211. ->send();
  212. return;
  213. }
  214. $question->update(['answer' => $this->answerOverride]);
  215. $this->questionData['answer'] = $this->answerOverride;
  216. Notification::make()
  217. ->title('答案已更新')
  218. ->success()
  219. ->send();
  220. }
  221. public function regenerateSolution(): void
  222. {
  223. if (!$this->questionId) {
  224. return;
  225. }
  226. $question = Question::query()->find($this->questionId);
  227. if (!$question) {
  228. Notification::make()
  229. ->title('题目不存在')
  230. ->danger()
  231. ->send();
  232. return;
  233. }
  234. $stem = (string) ($this->questionData['stem'] ?? '');
  235. $answer = (string) ($this->answerOverride ?: ($this->questionData['answer'] ?? ''));
  236. $images = $this->questionData['images']
  237. ?? ($this->questionData['meta']['images'] ?? []);
  238. if (is_string($images)) {
  239. $decoded = json_decode($images, true);
  240. $images = is_array($decoded) ? $decoded : [];
  241. }
  242. if ($stem === '' || $answer === '') {
  243. Notification::make()
  244. ->title('题干或答案为空,无法生成')
  245. ->warning()
  246. ->send();
  247. return;
  248. }
  249. try {
  250. $prompt = app(QuestionPromptService::class)->buildSolutionRegenPrompt($stem, $answer, $images);
  251. $result = app(AiClientService::class)->callJson($prompt);
  252. $solution = $result['solution'] ?? '';
  253. $steps = $result['steps'] ?? [];
  254. $meta = $question->meta ?? [];
  255. $meta['solution_steps'] = $steps;
  256. $question->update([
  257. 'solution' => $solution,
  258. 'answer' => $answer,
  259. 'meta' => $meta,
  260. ]);
  261. $this->questionData['solution'] = $solution;
  262. $this->questionData['answer'] = $answer;
  263. $this->questionData['meta']['solution_steps'] = $steps;
  264. Notification::make()
  265. ->title('解题思路已更新')
  266. ->success()
  267. ->send();
  268. } catch (\Throwable $e) {
  269. Notification::make()
  270. ->title('AI 生成失败')
  271. ->body($e->getMessage())
  272. ->danger()
  273. ->send();
  274. }
  275. }
  276. public function getKnowledgePointName(): string
  277. {
  278. if (!isset($this->questionData['kp_code'])) {
  279. return '';
  280. }
  281. $kpCode = $this->questionData['kp_code'];
  282. static $kpMap = null;
  283. if ($kpMap === null) {
  284. $kpMap = [];
  285. try {
  286. $service = app(KnowledgeGraphService::class);
  287. $resp = $service->listKnowledgePoints(1, 1000);
  288. foreach ($resp['data'] ?? [] as $kp) {
  289. $code = $kp['kp_code'] ?? $kp['id'] ?? null;
  290. if (!$code) {
  291. continue;
  292. }
  293. $kpMap[$code] = $kp['cn_name'] ?? $kp['name'] ?? $code;
  294. }
  295. } catch (\Throwable $e) {
  296. Log::warning('Load knowledge point names failed', [
  297. 'error' => $e->getMessage(),
  298. ]);
  299. }
  300. }
  301. return $kpMap[$kpCode] ?? $kpCode;
  302. }
  303. public function getSkillNames(): array
  304. {
  305. $skillCodes = $this->questionData['skills'] ?? [];
  306. if (empty($skillCodes) || !is_array($skillCodes)) {
  307. return [];
  308. }
  309. static $skillMap = null;
  310. if ($skillMap === null) {
  311. $skillMap = [];
  312. try {
  313. $service = app(KnowledgeGraphService::class);
  314. // 优先拉全量技能映射
  315. $resp = $service->listSkills(1, 1000);
  316. $skills = $resp['data'] ?? $resp ?? [];
  317. if (is_array($skills)) {
  318. foreach ($skills as $skill) {
  319. $code = $skill['code'] ?? null;
  320. if (!$code) {
  321. continue;
  322. }
  323. $skillMap[$code] = $skill['name'] ?? $code;
  324. }
  325. }
  326. // 如果仍为空,尝试按知识点单独获取
  327. if (empty($skillMap) && !empty($this->questionData['kp_code'])) {
  328. $kpSkills = $service->getSkillsByKnowledgePoint($this->questionData['kp_code']);
  329. foreach ($kpSkills as $skill) {
  330. $code = $skill['code'] ?? null;
  331. if (!$code) {
  332. continue;
  333. }
  334. $skillMap[$code] = $skill['name'] ?? $code;
  335. }
  336. }
  337. } catch (\Throwable $e) {
  338. Log::warning('Load skills failed', [
  339. 'error' => $e->getMessage(),
  340. ]);
  341. }
  342. }
  343. return array_map(function ($code) use ($skillMap) {
  344. return $skillMap[$code] ?? $code;
  345. }, $skillCodes);
  346. }
  347. protected function loadHistorySummary(): void
  348. {
  349. if (!$this->studentId || !$this->questionId) {
  350. return;
  351. }
  352. try {
  353. $service = app(MistakeBookService::class);
  354. $list = $service->listMistakes([
  355. 'student_id' => $this->studentId,
  356. 'per_page' => 100,
  357. ]);
  358. $data = $list['data'] ?? [];
  359. $filtered = array_values(array_filter($data, function ($item) {
  360. $qid = $item['question_id'] ?? ($item['question']['id'] ?? null);
  361. if ($qid === null) {
  362. return false;
  363. }
  364. return (string) $qid === (string) $this->questionId;
  365. }));
  366. if (empty($filtered)) {
  367. return;
  368. }
  369. $total = count($filtered);
  370. $correct = count(array_filter($filtered, fn ($item) => !empty($item['correct'])));
  371. $latest = collect($filtered)->sortByDesc('created_at')->first();
  372. $this->historySummary = [
  373. 'total' => $total,
  374. 'correct' => $correct,
  375. 'last_correct' => (bool) ($latest['correct'] ?? false),
  376. 'last_time' => $latest['created_at'] ?? null,
  377. ];
  378. } catch (\Throwable $e) {
  379. Log::warning('Load history summary failed', [
  380. 'student_id' => $this->studentId,
  381. 'question_id' => $this->questionId,
  382. 'error' => $e->getMessage(),
  383. ]);
  384. }
  385. }
  386. /**
  387. * 对题干/选项做展示友好化处理:拆分嵌入式选项,保留 display_stem 与 display_options
  388. */
  389. protected function prepareQuestionDisplay(array $question): array
  390. {
  391. $question['display_stem'] = $question['stem'] ?? '';
  392. // 规范化选项
  393. $options = $question['options'] ?? [];
  394. if (is_string($options)) {
  395. $decoded = json_decode($options, true);
  396. if (is_array($decoded)) {
  397. $options = $decoded;
  398. }
  399. }
  400. // 如果没有单独选项,但题干里嵌入了 A./B./C./D.,尝试提取
  401. if (empty($options) && !empty($question['stem']) && is_string($question['stem'])) {
  402. $stem = $question['stem'];
  403. $parsedOptions = [];
  404. $pattern = '/(?<![A-Za-z0-9])([A-D])[\\..、,,\\s]+\\s*(.+?)(?=(?<![A-Za-z0-9])[A-D][\\..、,,\\s]+|$)/su';
  405. if (preg_match_all($pattern, $stem, $matches, PREG_SET_ORDER)) {
  406. foreach ($matches as $match) {
  407. $text = trim($match[2]);
  408. if ($text !== '') {
  409. $parsedOptions[] = $text;
  410. }
  411. }
  412. // 只接受 2-4 个选项,避免误切分
  413. if (count($parsedOptions) >= 2 && count($parsedOptions) <= 4) {
  414. $options = $parsedOptions;
  415. $firstMatchPos = mb_strpos($stem, $matches[0][0]);
  416. if ($firstMatchPos !== false) {
  417. $question['display_stem'] = trim(mb_substr($stem, 0, $firstMatchPos));
  418. }
  419. }
  420. }
  421. }
  422. $question['display_options'] = $options;
  423. return $question;
  424. }
  425. protected function attachGlobalAccuracy(): void
  426. {
  427. if (!$this->questionId) {
  428. return;
  429. }
  430. try {
  431. $service = app(MistakeBookService::class);
  432. $accuracy = $service->getQuestionAccuracy($this->questionId);
  433. if ($accuracy !== null) {
  434. $this->questionData['global_accuracy'] = $accuracy;
  435. }
  436. } catch (\Throwable $e) {
  437. Log::warning('Attach global accuracy failed', [
  438. 'question_id' => $this->questionId,
  439. 'error' => $e->getMessage(),
  440. ]);
  441. }
  442. }
  443. }