QuestionCandidateWorkbench.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. <?php
  2. namespace App\Filament\Pages;
  3. use App\Models\KnowledgePoint;
  4. use App\Models\PaperPart;
  5. use App\Models\PreQuestionCandidate;
  6. use App\Models\SourcePaper;
  7. use App\Services\AiKnowledgeService;
  8. use App\Services\AiSolutionService;
  9. use App\Services\QuestionGenerationService;
  10. use Filament\Pages\Page;
  11. use Illuminate\Support\Arr;
  12. class QuestionCandidateWorkbench extends Page
  13. {
  14. protected static ?string $navigationLabel = '题目人工补录';
  15. protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-pencil-square';
  16. protected static string|\UnitEnum|null $navigationGroup = '卷子导入流程';
  17. protected static ?int $navigationSort = 4;
  18. protected string $view = 'filament.pages.question-candidate-workbench';
  19. public string $search = '';
  20. public ?string $statusFilter = null;
  21. public ?int $sourcePaperFilter = null;
  22. public ?int $partFilter = null;
  23. public string $viewMode = 'list';
  24. public bool $dense = false;
  25. public string $kpSearch = '';
  26. public string $aiBatchMode = 'missing';
  27. public array $selectedIds = [];
  28. public ?int $currentId = null;
  29. public array $form = [
  30. 'question_type' => null,
  31. 'difficulty' => null,
  32. 'score' => null,
  33. 'kp_codes' => [],
  34. 'tags' => '',
  35. 'stem' => null,
  36. 'options' => '',
  37. 'images' => [],
  38. 'source_paper_id' => null,
  39. 'part_id' => null,
  40. 'order_index' => null,
  41. 'answer' => null,
  42. 'solution' => null,
  43. 'solution_steps' => '',
  44. ];
  45. public array $batch = [
  46. 'question_type' => null,
  47. 'difficulty' => null,
  48. 'score' => null,
  49. 'kp_codes' => [],
  50. 'tags' => '',
  51. 'part_id' => null,
  52. 'source_paper_id' => null,
  53. ];
  54. public function mount(): void
  55. {
  56. $first = $this->candidates()->first();
  57. if ($first) {
  58. $this->selectCandidate($first->id);
  59. }
  60. }
  61. public function candidates()
  62. {
  63. $query = PreQuestionCandidate::query()->with(['sourcePaper', 'part']);
  64. if ($this->search !== '') {
  65. $query->where(function ($q) {
  66. $q->where('stem', 'like', '%' . $this->search . '%')
  67. ->orWhere('raw_markdown', 'like', '%' . $this->search . '%');
  68. });
  69. }
  70. if ($this->statusFilter) {
  71. $query->where('status', $this->statusFilter);
  72. }
  73. if ($this->viewMode === 'review') {
  74. $query->where('status', 'pending');
  75. }
  76. if ($this->sourcePaperFilter) {
  77. $query->where('source_paper_id', $this->sourcePaperFilter);
  78. }
  79. if ($this->partFilter) {
  80. $query->where('part_id', $this->partFilter);
  81. }
  82. return $query->orderBy('sequence')->limit(120)->get();
  83. }
  84. public function selectCandidate(int $candidateId): void
  85. {
  86. $candidate = PreQuestionCandidate::query()->find($candidateId);
  87. if (!$candidate) {
  88. return;
  89. }
  90. $this->currentId = $candidateId;
  91. $meta = $candidate->meta ?? [];
  92. $this->form = [
  93. 'question_type' => Arr::get($meta, 'question_type'),
  94. 'difficulty' => Arr::get($meta, 'difficulty'),
  95. 'score' => Arr::get($meta, 'score'),
  96. 'kp_codes' => Arr::get($meta, 'kp_codes', []),
  97. 'tags' => implode(',', Arr::get($meta, 'tags', [])),
  98. 'stem' => $candidate->stem,
  99. 'options' => $candidate->options ? json_encode($candidate->options, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '',
  100. 'images' => is_array($candidate->images) ? implode(',', $candidate->images) : (string) ($candidate->images ?? ''),
  101. 'source_paper_id' => $candidate->source_paper_id,
  102. 'part_id' => $candidate->part_id,
  103. 'order_index' => $candidate->order_index,
  104. 'answer' => Arr::get($meta, 'answer'),
  105. 'solution' => Arr::get($meta, 'solution'),
  106. 'solution_steps' => json_encode(Arr::get($meta, 'solution_steps', []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
  107. ];
  108. }
  109. public function saveCandidate(): void
  110. {
  111. $candidate = $this->currentCandidate();
  112. if (!$candidate) {
  113. return;
  114. }
  115. $options = null;
  116. if ($this->form['options']) {
  117. $decoded = json_decode((string) $this->form['options'], true);
  118. if (json_last_error() === JSON_ERROR_NONE) {
  119. $options = $decoded;
  120. }
  121. }
  122. $steps = [];
  123. if ($this->form['solution_steps']) {
  124. $decoded = json_decode((string) $this->form['solution_steps'], true);
  125. if (json_last_error() === JSON_ERROR_NONE) {
  126. $steps = $decoded;
  127. }
  128. }
  129. $meta = $candidate->meta ?? [];
  130. $meta['question_type'] = $this->form['question_type'] ?? null;
  131. $meta['difficulty'] = $this->form['difficulty'] ?? null;
  132. $meta['score'] = $this->form['score'] ?? null;
  133. $meta['kp_codes'] = $this->form['kp_codes'] ?? [];
  134. $meta['tags'] = $this->explodeTags($this->form['tags'] ?? '');
  135. $meta['answer'] = $this->form['answer'] ?? null;
  136. $meta['solution'] = $this->form['solution'] ?? null;
  137. $meta['solution_steps'] = $steps;
  138. $candidate->update([
  139. 'stem' => $this->form['stem'],
  140. 'options' => $options,
  141. 'images' => $this->explodeTags((string) ($this->form['images'] ?? '')),
  142. 'source_paper_id' => $this->form['source_paper_id'],
  143. 'part_id' => $this->form['part_id'],
  144. 'order_index' => $this->form['order_index'],
  145. 'meta' => $meta,
  146. 'status' => 'reviewed',
  147. ]);
  148. }
  149. public function applyBatch(): void
  150. {
  151. if (empty($this->selectedIds)) {
  152. return;
  153. }
  154. foreach (PreQuestionCandidate::query()->whereIn('id', $this->selectedIds)->get() as $candidate) {
  155. $meta = $candidate->meta ?? [];
  156. if (!empty($this->batch['question_type'])) {
  157. $meta['question_type'] = $this->batch['question_type'];
  158. }
  159. if (!empty($this->batch['difficulty'])) {
  160. $meta['difficulty'] = $this->batch['difficulty'];
  161. }
  162. if (!empty($this->batch['score'])) {
  163. $meta['score'] = $this->batch['score'];
  164. }
  165. if (!empty($this->batch['kp_codes'])) {
  166. $meta['kp_codes'] = $this->batch['kp_codes'];
  167. }
  168. if (!empty($this->batch['tags'])) {
  169. $meta['tags'] = $this->explodeTags($this->batch['tags']);
  170. }
  171. $candidate->update([
  172. 'part_id' => $this->batch['part_id'] ?: $candidate->part_id,
  173. 'source_paper_id' => $this->batch['source_paper_id'] ?: $candidate->source_paper_id,
  174. 'meta' => $meta,
  175. 'status' => 'reviewed',
  176. ]);
  177. }
  178. }
  179. public function seedBatchFromCurrent(): void
  180. {
  181. $candidate = $this->currentCandidate();
  182. if (!$candidate) {
  183. return;
  184. }
  185. $meta = $candidate->meta ?? [];
  186. $this->batch = [
  187. 'question_type' => Arr::get($meta, 'question_type'),
  188. 'difficulty' => Arr::get($meta, 'difficulty'),
  189. 'score' => Arr::get($meta, 'score'),
  190. 'kp_codes' => Arr::get($meta, 'kp_codes', []),
  191. 'tags' => implode(',', Arr::get($meta, 'tags', [])),
  192. 'part_id' => $candidate->part_id,
  193. 'source_paper_id' => $candidate->source_paper_id,
  194. ];
  195. }
  196. public function aiAutoFill(): void
  197. {
  198. $candidate = $this->currentCandidate();
  199. if (!$candidate) {
  200. return;
  201. }
  202. $sourceText = $candidate->stem ?: (string) $candidate->raw_markdown;
  203. $result = app(QuestionGenerationService::class)->generateFromSource($sourceText);
  204. if (!($result['success'] ?? false)) {
  205. return;
  206. }
  207. $question = $result['question'] ?? [];
  208. $meta = $candidate->meta ?? [];
  209. $meta['question_type'] = $question['question_type'] ?? $meta['question_type'] ?? null;
  210. $meta['difficulty'] = $question['difficulty'] ?? $meta['difficulty'] ?? null;
  211. $meta['kp_codes'] = $question['knowledge_points'] ?? $meta['kp_codes'] ?? [];
  212. $meta['answer'] = $question['answer'] ?? $meta['answer'] ?? null;
  213. $meta['solution'] = $question['solution'] ?? $meta['solution'] ?? null;
  214. $meta['solution_steps'] = $question['solution_steps'] ?? $meta['solution_steps'] ?? [];
  215. $candidate->update([
  216. 'stem' => $question['stem'] ?? $candidate->stem,
  217. 'options' => $question['options'] ?? $candidate->options,
  218. 'meta' => $meta,
  219. ]);
  220. $this->selectCandidate($candidate->id);
  221. }
  222. public function aiBatchAutoFill(): void
  223. {
  224. if (empty($this->selectedIds)) {
  225. return;
  226. }
  227. $mode = $this->aiBatchMode;
  228. $candidates = PreQuestionCandidate::query()->whereIn('id', $this->selectedIds)->get();
  229. foreach ($candidates as $candidate) {
  230. $sourceText = $candidate->stem ?: (string) $candidate->raw_markdown;
  231. $result = app(QuestionGenerationService::class)->generateFromSource($sourceText);
  232. if (!($result['success'] ?? false)) {
  233. continue;
  234. }
  235. $question = $result['question'] ?? [];
  236. $meta = $candidate->meta ?? [];
  237. $meta['question_type'] = $this->fillValue($meta['question_type'] ?? null, $question['question_type'] ?? null, $mode);
  238. $meta['difficulty'] = $this->fillValue($meta['difficulty'] ?? null, $question['difficulty'] ?? null, $mode);
  239. $meta['kp_codes'] = $this->fillArray($meta['kp_codes'] ?? [], $question['knowledge_points'] ?? [], $mode);
  240. $meta['answer'] = $this->fillValue($meta['answer'] ?? null, $question['answer'] ?? null, $mode);
  241. $meta['solution'] = $this->fillValue($meta['solution'] ?? null, $question['solution'] ?? null, $mode);
  242. $meta['solution_steps'] = $this->fillArray($meta['solution_steps'] ?? [], $question['solution_steps'] ?? [], $mode);
  243. $updates = ['meta' => $meta];
  244. if ($mode === 'overwrite' || empty($candidate->stem)) {
  245. if (!empty($question['stem'])) {
  246. $updates['stem'] = $question['stem'];
  247. }
  248. }
  249. if ($mode === 'overwrite' || empty($candidate->options)) {
  250. if (!empty($question['options'])) {
  251. $updates['options'] = $question['options'];
  252. }
  253. }
  254. $candidate->update($updates);
  255. }
  256. }
  257. public function applyDifficultyByOrder(): void
  258. {
  259. if (empty($this->selectedIds)) {
  260. return;
  261. }
  262. $candidates = PreQuestionCandidate::query()
  263. ->whereIn('id', $this->selectedIds)
  264. ->get()
  265. ->groupBy(fn ($candidate) => $candidate->part_id ?: $candidate->source_paper_id);
  266. foreach ($candidates as $group) {
  267. $sorted = $group->sortBy(function ($candidate) {
  268. return $candidate->order_index ?? $candidate->sequence ?? $candidate->index ?? $candidate->id;
  269. })->values();
  270. $total = max(1, $sorted->count());
  271. foreach ($sorted as $index => $candidate) {
  272. $difficulty = (int) ceil((($index + 1) / $total) * 5);
  273. $difficulty = max(1, min(5, $difficulty));
  274. $meta = $candidate->meta ?? [];
  275. $meta['difficulty'] = $difficulty;
  276. $candidate->update(['meta' => $meta]);
  277. }
  278. }
  279. }
  280. public function aiMatchKnowledge(): void
  281. {
  282. $candidate = $this->currentCandidate();
  283. if (!$candidate) {
  284. return;
  285. }
  286. $text = $candidate->stem ?: (string) $candidate->raw_markdown;
  287. $matches = app(AiKnowledgeService::class)->matchKnowledgePointsByAi($text);
  288. $kpCodes = array_values(array_filter(array_map(fn ($item) => $item['kp_code'] ?? null, $matches)));
  289. $meta = $candidate->meta ?? [];
  290. $meta['kp_codes'] = $kpCodes;
  291. $candidate->update(['meta' => $meta]);
  292. $this->selectCandidate($candidate->id);
  293. }
  294. public function aiGenerateSolution(): void
  295. {
  296. $candidate = $this->currentCandidate();
  297. if (!$candidate) {
  298. return;
  299. }
  300. $result = app(AiSolutionService::class)->generateSolution($candidate->stem ?? '');
  301. $meta = $candidate->meta ?? [];
  302. $meta['solution'] = $result['solution'] ?? '';
  303. $meta['solution_steps'] = $result['steps'] ?? [];
  304. $candidate->update(['meta' => $meta]);
  305. $this->selectCandidate($candidate->id);
  306. }
  307. public function nextCandidate(): void
  308. {
  309. $ids = $this->candidates()->pluck('id')->values();
  310. $index = $ids->search($this->currentId);
  311. if ($index !== false && isset($ids[$index + 1])) {
  312. $this->selectCandidate($ids[$index + 1]);
  313. }
  314. }
  315. public function previousCandidate(): void
  316. {
  317. $ids = $this->candidates()->pluck('id')->values();
  318. $index = $ids->search($this->currentId);
  319. if ($index !== false && $index > 0) {
  320. $this->selectCandidate($ids[$index - 1]);
  321. }
  322. }
  323. public function knowledgePointOptions(): array
  324. {
  325. return KnowledgePoint::query()->orderBy('kp_code')->pluck('name', 'kp_code')->toArray();
  326. }
  327. public function knowledgePointTreeOptions(): array
  328. {
  329. $points = KnowledgePoint::query()
  330. ->orderBy('kp_code')
  331. ->get(['kp_code', 'name', 'parent_kp_code'])
  332. ->groupBy('parent_kp_code');
  333. $walk = function ($parent, int $depth) use (&$walk, $points): array {
  334. $items = [];
  335. foreach ($points->get($parent, collect()) as $point) {
  336. $indent = str_repeat('—', $depth);
  337. $label = trim($indent . ' ' . $point->name);
  338. $items[$point->kp_code] = $label;
  339. $items += $walk($point->kp_code, $depth + 1);
  340. }
  341. return $items;
  342. };
  343. return $walk(null, 0);
  344. }
  345. public function filteredKnowledgePointOptions(): array
  346. {
  347. $options = $this->knowledgePointTreeOptions();
  348. if ($this->kpSearch === '') {
  349. return $options;
  350. }
  351. $needle = mb_strtolower($this->kpSearch);
  352. return array_filter($options, fn ($label, $code) => str_contains(mb_strtolower($label), $needle) || str_contains(mb_strtolower($code), $needle), ARRAY_FILTER_USE_BOTH);
  353. }
  354. public function sourcePaperOptions(): array
  355. {
  356. return SourcePaper::query()->orderByDesc('id')->limit(200)->pluck('title', 'id')->toArray();
  357. }
  358. public function partOptions(): array
  359. {
  360. return PaperPart::query()->orderByDesc('id')->limit(200)->pluck('title', 'id')->toArray();
  361. }
  362. public function currentCandidate(): ?PreQuestionCandidate
  363. {
  364. return $this->currentId ? PreQuestionCandidate::query()->find($this->currentId) : null;
  365. }
  366. private function explodeTags(string $tags): array
  367. {
  368. return array_values(array_filter(array_map('trim', explode(',', $tags))));
  369. }
  370. private function fillValue($current, $incoming, string $mode)
  371. {
  372. if ($mode === 'overwrite') {
  373. return $incoming ?? $current;
  374. }
  375. return $current !== null && $current !== '' ? $current : $incoming;
  376. }
  377. private function fillArray(array $current, array $incoming, string $mode): array
  378. {
  379. if ($mode === 'overwrite') {
  380. return !empty($incoming) ? $incoming : $current;
  381. }
  382. return !empty($current) ? $current : $incoming;
  383. }
  384. }