MarkdownImportResource.php 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698
  1. <?php
  2. namespace App\Filament\Resources;
  3. use App\Filament\Resources\MarkdownImportResource\Pages;
  4. use App\Models\MarkdownImport;
  5. use BackedEnum;
  6. use Filament\Actions\BulkAction;
  7. use Filament\Actions\BulkActionGroup;
  8. use Filament\Actions\DeleteBulkAction;
  9. use Filament\Actions\EditAction;
  10. use Filament\Actions\Action;
  11. use Filament\Facades\Filament;
  12. use Filament\Notifications\Notification;
  13. use Filament\Forms\Components\FileUpload;
  14. use Filament\Forms\Components\Hidden;
  15. use Filament\Forms\Components\MarkdownEditor;
  16. use Filament\Schemas\Components\Section;
  17. use Filament\Forms\Components\Select;
  18. use Filament\Forms\Components\Toggle;
  19. use Filament\Forms\Components\TextInput;
  20. use Filament\Schemas\Components\Utilities\Get;
  21. use Filament\Schemas\Components\Utilities\Set;
  22. use Filament\Resources\Resource;
  23. use Filament\Schemas\Schema;
  24. use Filament\Tables;
  25. use Filament\Tables\Table;
  26. use Illuminate\Database\Eloquent\Builder;
  27. use Illuminate\Database\Eloquent\Model;
  28. use Illuminate\Support\Facades\Storage;
  29. use Illuminate\Support\Facades\DB;
  30. use Illuminate\Support\Collection;
  31. use UnitEnum;
  32. use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
  33. use App\Support\TextEncoding;
  34. use App\Rules\MarkdownFileExtension;
  35. use Filament\Tables\Columns\TextColumn;
  36. use Filament\Tables\Enums\FiltersLayout;
  37. class MarkdownImportResource extends Resource
  38. {
  39. protected static ?string $model = MarkdownImport::class;
  40. protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-arrow-up-tray';
  41. protected static ?string $navigationLabel = 'Markdown 导入';
  42. protected static ?string $modelLabel = 'Markdown 导入';
  43. protected static ?string $pluralModelLabel = 'Markdown 导入';
  44. protected static UnitEnum|string|null $navigationGroup = '卷子导入流程';
  45. protected static ?int $navigationSort = 1;
  46. protected static ?string $title = 'Markdown 试卷导入管理';
  47. protected static ?string $description = '导入 Markdown 格式的数学试卷,AI 智能识别题目,人工校对后入库';
  48. public static function mutateFormDataBeforeCreate(array $data): array
  49. {
  50. // 支持上传 markdown 文件:读取内容写入 original_markdown
  51. if (!empty($data['markdown_file']) && empty($data['original_markdown'])) {
  52. $path = $data['markdown_file'];
  53. if (is_string($path) && Storage::disk('local')->exists($path)) {
  54. $data['original_markdown'] = TextEncoding::toUtf8(Storage::disk('local')->get($path));
  55. }
  56. }
  57. // 文件名默认取上传文件名(优先原始文件名,其次取存储路径 basename)
  58. if (empty($data['file_name']) && !empty($data['markdown_file'])) {
  59. $storedNames = $data['uploaded_file_names'] ?? null;
  60. if (is_array($storedNames) && !empty($storedNames)) {
  61. $data['file_name'] = (string) array_values($storedNames)[0];
  62. } else {
  63. $path = is_array($data['markdown_file']) ? ($data['markdown_file'][0] ?? '') : (string) $data['markdown_file'];
  64. $data['file_name'] = $path !== '' ? basename($path) : null;
  65. }
  66. }
  67. // 文件名作为来源名称
  68. if (!empty($data['file_name'])) {
  69. $data['source_name'] = $data['file_name'];
  70. $data['source_type'] = 'other';
  71. }
  72. unset($data['markdown_file']);
  73. unset($data['uploaded_file_names']);
  74. return $data;
  75. }
  76. /**
  77. * 允许创建新的 Markdown 导入记录
  78. */
  79. public static function canCreate(): bool
  80. {
  81. return true;
  82. }
  83. public static function form(Schema $schema): Schema
  84. {
  85. return $schema
  86. ->schema([
  87. Section::make('上传与来源信息')
  88. ->schema([
  89. \Filament\Forms\Components\TextInput::make('file_name')
  90. ->label('文件名(来源名称)')
  91. ->required(fn (Get $get): bool => empty($get('markdown_file')))
  92. ->maxLength(255),
  93. FileUpload::make('markdown_file')
  94. ->label('Markdown 文件(可选)')
  95. ->disk('local')
  96. ->directory('imports/markdown')
  97. ->helperText('仅支持 .md / .markdown / .txt;上传后会自动读取内容并填充编辑器')
  98. ->maxSize(10 * 1024)
  99. ->storeFileNamesIn('uploaded_file_names')
  100. ->dehydrated(true)
  101. ->preserveFilenames()
  102. ->rules([new MarkdownFileExtension()])
  103. ->afterStateUpdated(function ($state, Set $set, Get $get): void {
  104. // 在提交表单前,FileUpload 的 state 可能还是 TemporaryUploadedFile(尚未保存到 disk)
  105. $first = is_array($state) ? ($state[0] ?? null) : $state;
  106. if ($first instanceof TemporaryUploadedFile) {
  107. $set('original_markdown', TextEncoding::toUtf8((string) @file_get_contents($first->getRealPath())));
  108. if (empty($get('file_name'))) {
  109. $set('file_name', $first->getClientOriginalName());
  110. }
  111. return;
  112. }
  113. $paths = is_array($state) ? $state : (empty($state) ? [] : [$state]);
  114. $path = (string) ($paths[0] ?? '');
  115. if ($path === '') {
  116. return;
  117. }
  118. // 已保存到 disk 后:读取文件内容填充编辑器
  119. if (Storage::disk('local')->exists($path)) {
  120. $set('original_markdown', TextEncoding::toUtf8(Storage::disk('local')->get($path)));
  121. }
  122. // 上传后的真实文件名:BaseFileUpload 会在保存时 storeFileName($storedFile, originalName)
  123. $storedNames = $get('uploaded_file_names');
  124. if (is_string($storedNames) && $storedNames !== '') {
  125. $set('file_name', $storedNames);
  126. } elseif (empty($get('file_name'))) {
  127. $set('file_name', basename($path));
  128. }
  129. }),
  130. Hidden::make('uploaded_file_names')
  131. ->dehydrated(true),
  132. ])
  133. ->columns(2),
  134. Section::make('解析规则(可选)')
  135. ->schema([
  136. Select::make('parse_mode')
  137. ->label('解析模式')
  138. ->options([
  139. 'strict' => '严格模式',
  140. 'relaxed' => '宽松模式',
  141. ])
  142. ->default('strict')
  143. ->dehydrated(false),
  144. TextInput::make('split_marker')
  145. ->label('分题符号')
  146. ->placeholder('如:---')
  147. ->dehydrated(false),
  148. TextInput::make('type_marker')
  149. ->label('题型标记')
  150. ->placeholder('如:#选择题')
  151. ->dehydrated(false),
  152. Toggle::make('auto_detect_images')
  153. ->label('自动识别图片')
  154. ->default(true)
  155. ->dehydrated(false),
  156. ])
  157. ->columns(2)
  158. ->collapsed(),
  159. Section::make('Markdown 内容')
  160. ->schema([
  161. MarkdownEditor::make('original_markdown')
  162. ->label('Markdown 内容(编辑器)')
  163. ->required(fn (Get $get): bool => empty($get('markdown_file')))
  164. ->columnSpanFull()
  165. // 固定编辑器高度,避免内容过长把页面撑开
  166. ->minHeight('45vh')
  167. ->maxHeight('45vh')
  168. ->toolbarButtons([
  169. 'bold',
  170. 'italic',
  171. 'strike',
  172. 'blockquote',
  173. 'bulletList',
  174. 'orderedList',
  175. 'link',
  176. 'codeBlock',
  177. 'table',
  178. 'undo',
  179. 'redo',
  180. ]),
  181. ]),
  182. ]);
  183. }
  184. public static function table(Table $table): Table
  185. {
  186. return $table
  187. ->columns([
  188. // 文件名列 - 固定宽度,可折行显示
  189. TextColumn::make('file_name')
  190. ->label('文件')
  191. ->searchable()
  192. ->sortable()
  193. ->weight('bold')
  194. ->color('gray-900')
  195. ->wrap()
  196. ->width('200px'),
  197. // 状态列 - 固定宽度,只显示基本状态
  198. TextColumn::make('current_status')
  199. ->label('状态')
  200. ->getStateUsing(function (?Model $record): string {
  201. if (!$record) return '—';
  202. return match ($record->status) {
  203. 'pending' => '⏳ 待处理',
  204. 'processing' => '🟡 处理中',
  205. 'parsed' => '✅ 已解析',
  206. 'reviewed' => '📝 已校对',
  207. 'completed' => '🎉 已完成',
  208. 'failed' => '❌ 处理失败',
  209. default => '—',
  210. };
  211. })
  212. ->badge()
  213. ->color(fn (?Model $record): string => match ($record?->status ?? '') {
  214. 'pending' => 'gray',
  215. 'processing' => 'warning',
  216. 'parsed' => 'success',
  217. 'reviewed' => 'primary',
  218. 'completed' => 'success',
  219. 'failed' => 'danger',
  220. default => 'gray',
  221. })
  222. ->width('120px'),
  223. // 详细信息列 - 显示完整进度信息,包括总数
  224. TextColumn::make('detailed_progress')
  225. ->label('详情')
  226. ->getStateUsing(function (?Model $record): string {
  227. if (!$record) return '—';
  228. return match ($record->status) {
  229. 'processing' => $record->progress_message ?: sprintf(
  230. 'AI 解析中:%d/%d 题',
  231. $record->progress_current ?? 0,
  232. $record->progress_total ?? 0
  233. ),
  234. 'parsed' => sprintf(
  235. '已解析 %d/%d 题,等待校对',
  236. $record->parsed_count ?? 0,
  237. $record->progress_total ?? 0
  238. ),
  239. 'reviewed' => sprintf(
  240. '已校对 %d/%d 题,待入库',
  241. $record->accepted_count ?? 0,
  242. $record->progress_total ?? 0
  243. ),
  244. 'completed' => sprintf(
  245. '成功入库 %d/%d 题',
  246. $record->accepted_count ?? 0,
  247. $record->progress_total ?? 0
  248. ),
  249. 'failed' => '处理失败:' . ($record->error_message ?: '未知错误'),
  250. 'pending' => '准备就绪,总计 ' . ($record->progress_total ?? 0) . ' 题',
  251. default => '—',
  252. };
  253. })
  254. ->wrap()
  255. ->color('gray-600')
  256. ->width('1fr'), // 占据剩余所有空间
  257. // 快速操作列 - 固定宽度
  258. TextColumn::make('quick_actions')
  259. ->label('操作')
  260. ->getStateUsing(function (?Model $record): string {
  261. if (!$record) return '—';
  262. return match ($record->status) {
  263. 'processing' => '🔄 处理中',
  264. 'parsed', 'reviewed' => '👁️ 查看校对',
  265. 'completed' => '📊 查看结果',
  266. 'failed' => '🔁 重试',
  267. 'pending' => '▶️ 开始',
  268. default => '—',
  269. };
  270. })
  271. ->url(function (?Model $record): ?string {
  272. if (!$record) return null;
  273. return match ($record->status) {
  274. 'parsed', 'reviewed' => route('filament.admin.resources.pre-question-candidates.index', [
  275. 'import_id' => $record->id,
  276. 'tab' => $record->status === 'reviewed' ? 'reviewed' : null
  277. ]),
  278. 'completed' => route('filament.admin.pages.markdown-import-workbench', [
  279. 'import_id' => $record->id,
  280. ]),
  281. default => null,
  282. };
  283. })
  284. ->color('primary')
  285. ->weight('medium')
  286. ->wrap()
  287. ->width('100px'),
  288. ])
  289. ->filters([
  290. Tables\Filters\SelectFilter::make('status')
  291. ->label('状态')
  292. ->options([
  293. 'pending' => '待处理',
  294. 'processing' => '处理中',
  295. 'parsed' => '已解析',
  296. 'reviewed' => '已校对',
  297. 'completed' => '已完成',
  298. 'failed' => '处理失败',
  299. ]),
  300. Tables\Filters\SelectFilter::make('source_type')
  301. ->label('来源类型')
  302. ->options([
  303. 'textbook' => '教材',
  304. 'exam' => '考试',
  305. 'other' => '其他',
  306. ]),
  307. Tables\Filters\SelectFilter::make('filename_parse')
  308. ->label('命名规范')
  309. ->options([
  310. 'valid' => '正常',
  311. 'invalid' => '不规范',
  312. ])
  313. ->query(function (Builder $query, array $data) {
  314. $value = $data['value'] ?? null;
  315. $driver = DB::getDriverName();
  316. $regex = '^.+_[0-9]+_[0-2]_.+_.+$';
  317. if ($value === 'valid') {
  318. if ($driver === 'mysql') {
  319. $query->whereRaw('file_name REGEXP ?', [$regex]);
  320. } else {
  321. $query->where('file_name', 'like', '%_%_%_%_%');
  322. }
  323. }
  324. if ($value === 'invalid') {
  325. if ($driver === 'mysql') {
  326. $query->where(function ($q) use ($regex) {
  327. $q->whereNull('file_name')->orWhereRaw('file_name NOT REGEXP ?', [$regex]);
  328. });
  329. } else {
  330. $query->where(function ($q) {
  331. $q->whereNull('file_name')->orWhere('file_name', 'not like', '%_%_%_%_%');
  332. });
  333. }
  334. }
  335. }),
  336. ], layout: FiltersLayout::AboveContentCollapsible)
  337. ->actions([
  338. // 第一行按钮 - 主要操作
  339. EditAction::make()
  340. ->label('编辑')
  341. ->size('sm'),
  342. Action::make('workbench')
  343. ->label('工作台')
  344. ->icon('heroicon-o-rectangle-stack')
  345. ->color('primary')
  346. ->size('sm')
  347. ->visible(fn (?Model $record): bool => !empty($record?->parseFilename()))
  348. ->url(fn (?Model $record): string => route('filament.admin.pages.markdown-import-workbench', [
  349. 'import_id' => $record?->id,
  350. ])),
  351. Action::make('review')
  352. ->label('校对')
  353. ->icon('heroicon-o-clipboard-document-list')
  354. ->color('success')
  355. ->size('sm')
  356. ->visible(fn (?Model $record): bool => in_array($record?->status, ['parsed', 'reviewed', 'completed']) && !empty($record?->parseFilename()))
  357. ->url(function (?Model $record): string {
  358. $importId = $record?->id;
  359. $status = $record?->status;
  360. if ($status === 'parsed') {
  361. return route('filament.admin.resources.pre-question-candidates.index', [
  362. 'import_id' => $importId
  363. ]);
  364. } elseif (in_array($status, ['reviewed', 'completed'])) {
  365. return route('filament.admin.resources.pre-question-candidates.index', [
  366. 'import_id' => $importId,
  367. 'tab' => 'reviewed'
  368. ]);
  369. }
  370. return route('filament.admin.resources.pre-question-candidates.index', [
  371. 'import_id' => $importId
  372. ]);
  373. }),
  374. Action::make('delete')
  375. ->label('删除')
  376. ->icon('heroicon-o-trash')
  377. ->color('danger')
  378. ->size('sm')
  379. ->requiresConfirmation()
  380. ->modalHeading('删除导入记录')
  381. ->modalDescription('确定要删除这条导入记录吗?此操作不可撤销。')
  382. ->action(function (?Model $record) {
  383. if ($record) {
  384. $record->delete();
  385. Notification::make()
  386. ->title('删除成功')
  387. ->success()
  388. ->send();
  389. }
  390. }),
  391. // 第二行按钮 - 处理操作
  392. Action::make('run_pipeline')
  393. ->label('全流程')
  394. ->icon('heroicon-o-play-circle')
  395. ->color('success')
  396. ->size('sm')
  397. ->requiresConfirmation()
  398. ->modalHeading('触发 Markdown 拆分 + AI 结构化')
  399. ->modalDescription('立即提交队列,按 source_file → source_paper → paper_part → candidate → AI 结构化 执行。')
  400. ->action(function (?Model $record) {
  401. if (!$record) {
  402. return;
  403. }
  404. dispatch(new \App\Jobs\ProcessMarkdownSplit($record->id));
  405. $record->update([
  406. 'status' => MarkdownImport::STATUS_PROCESSING,
  407. 'progress_stage' => MarkdownImport::STAGE_QUEUED,
  408. 'progress_message' => '已进入队列…',
  409. 'processing_started_at' => now(),
  410. 'processing_finished_at' => null,
  411. 'error_message' => null,
  412. ]);
  413. Notification::make()
  414. ->title('已提交解析队列')
  415. ->success()
  416. ->send();
  417. }),
  418. Action::make('parse')
  419. ->label('解析')
  420. ->icon('heroicon-o-cog-6-tooth')
  421. ->color('info')
  422. ->size('sm')
  423. ->visible(fn (?Model $record): bool => in_array($record?->status, ['pending', 'failed']))
  424. ->requiresConfirmation()
  425. ->modalHeading('解析 Markdown')
  426. ->modalDescription('将解析 Markdown 中的题目候选,并使用 AI 进行初步筛选。')
  427. ->action(function (?Model $record) {
  428. if ($record) {
  429. static::parseMarkdown($record);
  430. }
  431. }),
  432. Action::make('ai_parse')
  433. ->label('AI解析')
  434. ->icon('heroicon-o-sparkles')
  435. ->color('warning')
  436. ->size('sm')
  437. ->visible(fn (?Model $record): bool => in_array($record?->status, ['pending', 'processing', 'parsed', 'failed']))
  438. ->requiresConfirmation()
  439. ->modalHeading('重新执行 AI 解析')
  440. ->modalDescription('将对所有候选题重新进行 AI 结构化解析,清除之前的解析标记。此操作不会重新拆分题目。')
  441. ->action(function (?Model $record) {
  442. if (!$record) {
  443. return;
  444. }
  445. static::triggerAiParsing($record);
  446. }),
  447. ])
  448. ->bulkActions([
  449. BulkActionGroup::make([
  450. DeleteBulkAction::make(),
  451. BulkAction::make('bulk_ai_parse')
  452. ->label('批量 AI 解析')
  453. ->icon('heroicon-o-sparkles')
  454. ->color('warning')
  455. ->requiresConfirmation()
  456. ->modalHeading('批量执行 AI 解析')
  457. ->modalDescription('将对选中的所有记录重新执行 AI 结构化解析,清除之前的解析标记。')
  458. ->action(function (Collection $records) {
  459. foreach ($records as $record) {
  460. static::triggerAiParsing($record);
  461. }
  462. }),
  463. ]),
  464. ])
  465. ->recordClasses(fn (Model $record) => $record->status === 'failed' ? 'bg-rose-50/60' : null)
  466. ->defaultSort('created_at', 'desc')
  467. ->paginated([10, 25, 50, 100]);
  468. }
  469. public static function getEloquentQuery(): Builder
  470. {
  471. // 让 parsed_count / accepted_count 成为可排序的 SQL 字段(避免 order by accessor 报错)
  472. return parent::getEloquentQuery()
  473. ->withCount([
  474. 'candidates as parsed_count' => fn (Builder $query) => $query->where('status', '!=', 'superseded'),
  475. 'candidates as accepted_count' => fn (Builder $query) => $query
  476. ->where('status', '!=', 'superseded')
  477. ->where('is_question_candidate', true),
  478. ]);
  479. }
  480. public static function getPages(): array
  481. {
  482. return [
  483. 'index' => Pages\ListMarkdownImports::route('/'),
  484. 'create' => Pages\CreateMarkdownImport::route('/create'),
  485. 'edit' => Pages\EditMarkdownImport::route('/{record}/edit'),
  486. ];
  487. }
  488. /**
  489. * 解析 Markdown
  490. */
  491. public static function parseMarkdown(Model $record): void
  492. {
  493. try {
  494. // 验证状态
  495. if (!in_array($record->status, ['pending', 'failed'], true)) {
  496. Notification::make()
  497. ->title('只能解析待处理或失败状态的记录')
  498. ->warning()
  499. ->send();
  500. return;
  501. }
  502. // 验证 markdown 内容
  503. if (empty($record->original_markdown)) {
  504. Notification::make()
  505. ->title('Markdown 内容不能为空')
  506. ->warning()
  507. ->send();
  508. return;
  509. }
  510. // 失败状态重试:清空错误信息并重新进入待处理
  511. if ($record->status === 'failed') {
  512. $record->update([
  513. 'status' => 'pending',
  514. 'error_message' => null,
  515. ]);
  516. }
  517. // 先更新状态,确保列表页可见变化(避免“点了没反应”的体验)
  518. $record->update([
  519. 'status' => 'processing',
  520. 'progress_stage' => \App\Models\MarkdownImport::STAGE_QUEUED,
  521. 'progress_message' => '已提交解析任务,等待处理…',
  522. 'progress_current' => 0,
  523. 'progress_total' => 0,
  524. 'progress_updated_at' => now(),
  525. 'processing_started_at' => now(),
  526. 'processing_finished_at' => null,
  527. 'error_message' => null,
  528. ]);
  529. \Log::info('Markdown import parse queued', [
  530. 'import_id' => $record->id,
  531. 'status' => $record->status,
  532. 'stage' => $record->progress_stage,
  533. ]);
  534. // 派发异步任务
  535. \App\Jobs\ProcessMarkdownSplit::dispatch($record->id);
  536. Notification::make()
  537. ->title('已提交解析任务,正在后台处理...')
  538. ->body('列表页将自动刷新显示进度;若长期无进度,请确认 queue worker 正在运行。')
  539. ->success()
  540. ->send();
  541. } catch (\Exception $e) {
  542. Notification::make()
  543. ->title('解析失败:' . $e->getMessage())
  544. ->danger()
  545. ->send();
  546. }
  547. }
  548. /**
  549. * 重新执行 AI 解析
  550. */
  551. public static function triggerAiParsing(Model $record): void
  552. {
  553. try {
  554. // 检查是否有候选题
  555. $candidateCount = \App\Models\PreQuestionCandidate::where('import_id', $record->id)
  556. ->where('status', '!=', 'superseded')
  557. ->count();
  558. if ($candidateCount === 0) {
  559. Notification::make()
  560. ->title('没有找到候选题,无法执行 AI 解析')
  561. ->warning()
  562. ->send();
  563. return;
  564. }
  565. // 清理旧的队列任务
  566. \Illuminate\Support\Facades\DB::table('jobs')
  567. ->where('payload', 'like', '%"markdownImportId":' . $record->id . '%')
  568. ->orWhere('payload', 'like', '%"markdownImportId";i:' . $record->id . ';%')
  569. ->delete();
  570. // 清除所有候选题的 AI 解析标记
  571. $candidates = \App\Models\PreQuestionCandidate::where('import_id', $record->id)
  572. ->where('status', '!=', 'superseded')
  573. ->get();
  574. foreach ($candidates as $candidate) {
  575. $meta = $candidate->meta ?? [];
  576. unset($meta['ai_parsed'], $meta['ai_parsed_at']);
  577. $candidate->update([
  578. 'stem' => null,
  579. 'options' => null,
  580. 'images' => null,
  581. 'tables' => null,
  582. 'ai_confidence' => null,
  583. 'confidence' => null,
  584. 'status' => 'pending',
  585. 'meta' => $meta,
  586. ]);
  587. }
  588. // 更新导入记录状态
  589. $record->update([
  590. 'status' => 'processing',
  591. 'progress_stage' => \App\Models\MarkdownImport::STAGE_AI_PARSING,
  592. 'progress_message' => 'AI 解析中…',
  593. 'progress_current' => 0,
  594. 'progress_total' => $candidateCount,
  595. 'progress_updated_at' => now(),
  596. 'processing_started_at' => now(),
  597. 'processing_finished_at' => null,
  598. 'error_message' => null,
  599. ]);
  600. // 创建批次并派发 jobs
  601. $batchSize = 10;
  602. $batches = (int) ceil($candidateCount / $batchSize);
  603. for ($b = 0; $b < $batches; $b++) {
  604. $startSeq = ($b * $batchSize) + 1;
  605. $endSeq = min(($b + 1) * $batchSize, $candidateCount);
  606. \App\Jobs\ProcessMarkdownCandidateBatch::dispatch($record->id, $startSeq, $endSeq);
  607. }
  608. \Illuminate\Support\Facades\Log::info('AI parsing batches dispatched', [
  609. 'import_id' => $record->id,
  610. 'total_candidates' => $candidateCount,
  611. 'batch_size' => $batchSize,
  612. 'batches' => $batches,
  613. ]);
  614. Notification::make()
  615. ->title('已提交 AI 解析任务')
  616. ->body("共 {$candidateCount} 个候选题,已分为 {$batches} 个批次并发处理")
  617. ->success()
  618. ->send();
  619. } catch (\Exception $e) {
  620. Notification::make()
  621. ->title('AI 解析失败:' . $e->getMessage())
  622. ->danger()
  623. ->send();
  624. }
  625. }
  626. }