| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- <?php
- namespace App\Filament\Pages;
- use App\Models\KnowledgePoint;
- use App\Models\PaperPart;
- use App\Models\PreQuestionCandidate;
- use App\Models\SourcePaper;
- use App\Services\AiKnowledgeService;
- use App\Services\AiSolutionService;
- use App\Services\QuestionGenerationService;
- use Filament\Pages\Page;
- use Illuminate\Support\Arr;
- class QuestionCandidateWorkbench extends Page
- {
- protected static ?string $navigationLabel = '题目人工补录';
- protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-pencil-square';
- protected static string|\UnitEnum|null $navigationGroup = '卷子导入流程';
- protected static ?int $navigationSort = 4;
- protected string $view = 'filament.pages.question-candidate-workbench';
- public string $search = '';
- public ?string $statusFilter = null;
- public ?int $sourcePaperFilter = null;
- public ?int $partFilter = null;
- public string $viewMode = 'list';
- public bool $dense = false;
- public string $kpSearch = '';
- public string $aiBatchMode = 'missing';
- public array $selectedIds = [];
- public ?int $currentId = null;
- public array $form = [
- 'question_type' => null,
- 'difficulty' => null,
- 'score' => null,
- 'kp_codes' => [],
- 'tags' => '',
- 'stem' => null,
- 'options' => '',
- 'images' => [],
- 'source_paper_id' => null,
- 'part_id' => null,
- 'order_index' => null,
- 'answer' => null,
- 'solution' => null,
- 'solution_steps' => '',
- ];
- public array $batch = [
- 'question_type' => null,
- 'difficulty' => null,
- 'score' => null,
- 'kp_codes' => [],
- 'tags' => '',
- 'part_id' => null,
- 'source_paper_id' => null,
- ];
- public function mount(): void
- {
- $first = $this->candidates()->first();
- if ($first) {
- $this->selectCandidate($first->id);
- }
- }
- public function candidates()
- {
- $query = PreQuestionCandidate::query()->with(['sourcePaper', 'part']);
- if ($this->search !== '') {
- $query->where(function ($q) {
- $q->where('stem', 'like', '%' . $this->search . '%')
- ->orWhere('raw_markdown', 'like', '%' . $this->search . '%');
- });
- }
- if ($this->statusFilter) {
- $query->where('status', $this->statusFilter);
- }
- if ($this->viewMode === 'review') {
- $query->where('status', 'pending');
- }
- if ($this->sourcePaperFilter) {
- $query->where('source_paper_id', $this->sourcePaperFilter);
- }
- if ($this->partFilter) {
- $query->where('part_id', $this->partFilter);
- }
- return $query->orderBy('sequence')->limit(120)->get();
- }
- public function selectCandidate(int $candidateId): void
- {
- $candidate = PreQuestionCandidate::query()->find($candidateId);
- if (!$candidate) {
- return;
- }
- $this->currentId = $candidateId;
- $meta = $candidate->meta ?? [];
- $this->form = [
- 'question_type' => Arr::get($meta, 'question_type'),
- 'difficulty' => Arr::get($meta, 'difficulty'),
- 'score' => Arr::get($meta, 'score'),
- 'kp_codes' => Arr::get($meta, 'kp_codes', []),
- 'tags' => implode(',', Arr::get($meta, 'tags', [])),
- 'stem' => $candidate->stem,
- 'options' => $candidate->options ? json_encode($candidate->options, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '',
- 'images' => is_array($candidate->images) ? implode(',', $candidate->images) : (string) ($candidate->images ?? ''),
- 'source_paper_id' => $candidate->source_paper_id,
- 'part_id' => $candidate->part_id,
- 'order_index' => $candidate->order_index,
- 'answer' => Arr::get($meta, 'answer'),
- 'solution' => Arr::get($meta, 'solution'),
- 'solution_steps' => json_encode(Arr::get($meta, 'solution_steps', []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
- ];
- }
- public function saveCandidate(): void
- {
- $candidate = $this->currentCandidate();
- if (!$candidate) {
- return;
- }
- $options = null;
- if ($this->form['options']) {
- $decoded = json_decode((string) $this->form['options'], true);
- if (json_last_error() === JSON_ERROR_NONE) {
- $options = $decoded;
- }
- }
- $steps = [];
- if ($this->form['solution_steps']) {
- $decoded = json_decode((string) $this->form['solution_steps'], true);
- if (json_last_error() === JSON_ERROR_NONE) {
- $steps = $decoded;
- }
- }
- $meta = $candidate->meta ?? [];
- $meta['question_type'] = $this->form['question_type'] ?? null;
- $meta['difficulty'] = $this->form['difficulty'] ?? null;
- $meta['score'] = $this->form['score'] ?? null;
- $meta['kp_codes'] = $this->form['kp_codes'] ?? [];
- $meta['tags'] = $this->explodeTags($this->form['tags'] ?? '');
- $meta['answer'] = $this->form['answer'] ?? null;
- $meta['solution'] = $this->form['solution'] ?? null;
- $meta['solution_steps'] = $steps;
- $candidate->update([
- 'stem' => $this->form['stem'],
- 'options' => $options,
- 'images' => $this->explodeTags((string) ($this->form['images'] ?? '')),
- 'source_paper_id' => $this->form['source_paper_id'],
- 'part_id' => $this->form['part_id'],
- 'order_index' => $this->form['order_index'],
- 'meta' => $meta,
- 'status' => 'reviewed',
- ]);
- }
- public function applyBatch(): void
- {
- if (empty($this->selectedIds)) {
- return;
- }
- foreach (PreQuestionCandidate::query()->whereIn('id', $this->selectedIds)->get() as $candidate) {
- $meta = $candidate->meta ?? [];
- if (!empty($this->batch['question_type'])) {
- $meta['question_type'] = $this->batch['question_type'];
- }
- if (!empty($this->batch['difficulty'])) {
- $meta['difficulty'] = $this->batch['difficulty'];
- }
- if (!empty($this->batch['score'])) {
- $meta['score'] = $this->batch['score'];
- }
- if (!empty($this->batch['kp_codes'])) {
- $meta['kp_codes'] = $this->batch['kp_codes'];
- }
- if (!empty($this->batch['tags'])) {
- $meta['tags'] = $this->explodeTags($this->batch['tags']);
- }
- $candidate->update([
- 'part_id' => $this->batch['part_id'] ?: $candidate->part_id,
- 'source_paper_id' => $this->batch['source_paper_id'] ?: $candidate->source_paper_id,
- 'meta' => $meta,
- 'status' => 'reviewed',
- ]);
- }
- }
- public function seedBatchFromCurrent(): void
- {
- $candidate = $this->currentCandidate();
- if (!$candidate) {
- return;
- }
- $meta = $candidate->meta ?? [];
- $this->batch = [
- 'question_type' => Arr::get($meta, 'question_type'),
- 'difficulty' => Arr::get($meta, 'difficulty'),
- 'score' => Arr::get($meta, 'score'),
- 'kp_codes' => Arr::get($meta, 'kp_codes', []),
- 'tags' => implode(',', Arr::get($meta, 'tags', [])),
- 'part_id' => $candidate->part_id,
- 'source_paper_id' => $candidate->source_paper_id,
- ];
- }
- public function aiAutoFill(): void
- {
- $candidate = $this->currentCandidate();
- if (!$candidate) {
- return;
- }
- $sourceText = $candidate->stem ?: (string) $candidate->raw_markdown;
- $result = app(QuestionGenerationService::class)->generateFromSource($sourceText);
- if (!($result['success'] ?? false)) {
- return;
- }
- $question = $result['question'] ?? [];
- $meta = $candidate->meta ?? [];
- $meta['question_type'] = $question['question_type'] ?? $meta['question_type'] ?? null;
- $meta['difficulty'] = $question['difficulty'] ?? $meta['difficulty'] ?? null;
- $meta['kp_codes'] = $question['knowledge_points'] ?? $meta['kp_codes'] ?? [];
- $meta['answer'] = $question['answer'] ?? $meta['answer'] ?? null;
- $meta['solution'] = $question['solution'] ?? $meta['solution'] ?? null;
- $meta['solution_steps'] = $question['solution_steps'] ?? $meta['solution_steps'] ?? [];
- $candidate->update([
- 'stem' => $question['stem'] ?? $candidate->stem,
- 'options' => $question['options'] ?? $candidate->options,
- 'meta' => $meta,
- ]);
- $this->selectCandidate($candidate->id);
- }
- public function aiBatchAutoFill(): void
- {
- if (empty($this->selectedIds)) {
- return;
- }
- $mode = $this->aiBatchMode;
- $candidates = PreQuestionCandidate::query()->whereIn('id', $this->selectedIds)->get();
- foreach ($candidates as $candidate) {
- $sourceText = $candidate->stem ?: (string) $candidate->raw_markdown;
- $result = app(QuestionGenerationService::class)->generateFromSource($sourceText);
- if (!($result['success'] ?? false)) {
- continue;
- }
- $question = $result['question'] ?? [];
- $meta = $candidate->meta ?? [];
- $meta['question_type'] = $this->fillValue($meta['question_type'] ?? null, $question['question_type'] ?? null, $mode);
- $meta['difficulty'] = $this->fillValue($meta['difficulty'] ?? null, $question['difficulty'] ?? null, $mode);
- $meta['kp_codes'] = $this->fillArray($meta['kp_codes'] ?? [], $question['knowledge_points'] ?? [], $mode);
- $meta['answer'] = $this->fillValue($meta['answer'] ?? null, $question['answer'] ?? null, $mode);
- $meta['solution'] = $this->fillValue($meta['solution'] ?? null, $question['solution'] ?? null, $mode);
- $meta['solution_steps'] = $this->fillArray($meta['solution_steps'] ?? [], $question['solution_steps'] ?? [], $mode);
- $updates = ['meta' => $meta];
- if ($mode === 'overwrite' || empty($candidate->stem)) {
- if (!empty($question['stem'])) {
- $updates['stem'] = $question['stem'];
- }
- }
- if ($mode === 'overwrite' || empty($candidate->options)) {
- if (!empty($question['options'])) {
- $updates['options'] = $question['options'];
- }
- }
- $candidate->update($updates);
- }
- }
- public function applyDifficultyByOrder(): void
- {
- if (empty($this->selectedIds)) {
- return;
- }
- $candidates = PreQuestionCandidate::query()
- ->whereIn('id', $this->selectedIds)
- ->get()
- ->groupBy(fn ($candidate) => $candidate->part_id ?: $candidate->source_paper_id);
- foreach ($candidates as $group) {
- $sorted = $group->sortBy(function ($candidate) {
- return $candidate->order_index ?? $candidate->sequence ?? $candidate->index ?? $candidate->id;
- })->values();
- $total = max(1, $sorted->count());
- foreach ($sorted as $index => $candidate) {
- $difficulty = (int) ceil((($index + 1) / $total) * 5);
- $difficulty = max(1, min(5, $difficulty));
- $meta = $candidate->meta ?? [];
- $meta['difficulty'] = $difficulty;
- $candidate->update(['meta' => $meta]);
- }
- }
- }
- public function aiMatchKnowledge(): void
- {
- $candidate = $this->currentCandidate();
- if (!$candidate) {
- return;
- }
- $text = $candidate->stem ?: (string) $candidate->raw_markdown;
- $matches = app(AiKnowledgeService::class)->matchKnowledgePointsByAi($text);
- $kpCodes = array_values(array_filter(array_map(fn ($item) => $item['kp_code'] ?? null, $matches)));
- $meta = $candidate->meta ?? [];
- $meta['kp_codes'] = $kpCodes;
- $candidate->update(['meta' => $meta]);
- $this->selectCandidate($candidate->id);
- }
- public function aiGenerateSolution(): void
- {
- $candidate = $this->currentCandidate();
- if (!$candidate) {
- return;
- }
- $result = app(AiSolutionService::class)->generateSolution($candidate->stem ?? '');
- $meta = $candidate->meta ?? [];
- $meta['solution'] = $result['solution'] ?? '';
- $meta['solution_steps'] = $result['steps'] ?? [];
- $candidate->update(['meta' => $meta]);
- $this->selectCandidate($candidate->id);
- }
- public function nextCandidate(): void
- {
- $ids = $this->candidates()->pluck('id')->values();
- $index = $ids->search($this->currentId);
- if ($index !== false && isset($ids[$index + 1])) {
- $this->selectCandidate($ids[$index + 1]);
- }
- }
- public function previousCandidate(): void
- {
- $ids = $this->candidates()->pluck('id')->values();
- $index = $ids->search($this->currentId);
- if ($index !== false && $index > 0) {
- $this->selectCandidate($ids[$index - 1]);
- }
- }
- public function knowledgePointOptions(): array
- {
- return KnowledgePoint::query()->orderBy('kp_code')->pluck('name', 'kp_code')->toArray();
- }
- public function knowledgePointTreeOptions(): array
- {
- $points = KnowledgePoint::query()
- ->orderBy('kp_code')
- ->get(['kp_code', 'name', 'parent_kp_code'])
- ->groupBy('parent_kp_code');
- $walk = function ($parent, int $depth) use (&$walk, $points): array {
- $items = [];
- foreach ($points->get($parent, collect()) as $point) {
- $indent = str_repeat('—', $depth);
- $label = trim($indent . ' ' . $point->name);
- $items[$point->kp_code] = $label;
- $items += $walk($point->kp_code, $depth + 1);
- }
- return $items;
- };
- return $walk(null, 0);
- }
- public function filteredKnowledgePointOptions(): array
- {
- $options = $this->knowledgePointTreeOptions();
- if ($this->kpSearch === '') {
- return $options;
- }
- $needle = mb_strtolower($this->kpSearch);
- return array_filter($options, fn ($label, $code) => str_contains(mb_strtolower($label), $needle) || str_contains(mb_strtolower($code), $needle), ARRAY_FILTER_USE_BOTH);
- }
- public function sourcePaperOptions(): array
- {
- return SourcePaper::query()->orderByDesc('id')->limit(200)->pluck('title', 'id')->toArray();
- }
- public function partOptions(): array
- {
- return PaperPart::query()->orderByDesc('id')->limit(200)->pluck('title', 'id')->toArray();
- }
- public function currentCandidate(): ?PreQuestionCandidate
- {
- return $this->currentId ? PreQuestionCandidate::query()->find($this->currentId) : null;
- }
- private function explodeTags(string $tags): array
- {
- return array_values(array_filter(array_map('trim', explode(',', $tags))));
- }
- private function fillValue($current, $incoming, string $mode)
- {
- if ($mode === 'overwrite') {
- return $incoming ?? $current;
- }
- return $current !== null && $current !== '' ? $current : $incoming;
- }
- private function fillArray(array $current, array $incoming, string $mode): array
- {
- if ($mode === 'overwrite') {
- return !empty($incoming) ? $incoming : $current;
- }
- return !empty($current) ? $current : $incoming;
- }
- }
|