MarkdownImportResource.php 31 KB

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