generate_sample_placeholder_audit_pdf.php 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. <?php
  2. /**
  3. * 从占位符审计明细(ndjson)中按类型轮询抽样 N 道题,生成「题目质检」PDF(与 POST /api/questions/pdf 同源逻辑)。
  4. *
  5. * 用法:
  6. * php scripts/generate_sample_placeholder_audit_pdf.php [--count 30]
  7. * [--detail storage/app/audit_placeholder/rendered_placeholder_audit_details_*.ndjson]
  8. * [--connection mysql] [--table questions]
  9. *
  10. * 若不指定 --detail,则自动选用 storage/app/audit_placeholder 下最新的 rendered_placeholder_audit_details_*.ndjson。
  11. *
  12. * 抽样优先级(轮询):
  13. * internal_placeholder_token_leak → broken_left_right_split → span_then_dollar_before_han
  14. * → blank_between_math_segments → unbalanced_dollar_after_render → math_ends_with_operator_before_blank
  15. */
  16. declare(strict_types=1);
  17. require __DIR__.'/../vendor/autoload.php';
  18. $app = require __DIR__.'/../bootstrap/app.php';
  19. $kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
  20. $kernel->bootstrap();
  21. use App\Services\ExamPdfExportService;
  22. use Illuminate\Support\Facades\DB;
  23. $options = getopt('', [
  24. 'count::',
  25. 'detail::',
  26. 'connection::',
  27. 'table::',
  28. ]);
  29. $count = isset($options['count']) ? max(1, min(100, (int) $options['count'])) : 30;
  30. $connection = isset($options['connection']) ? trim((string) $options['connection']) : config('database.default');
  31. $table = isset($options['table']) ? trim((string) $options['table']) : 'questions';
  32. $detailPath = isset($options['detail']) ? trim((string) $options['detail']) : '';
  33. if ($detailPath === '') {
  34. $auditDir = dirname(__DIR__).'/storage/app/audit_placeholder';
  35. $glob = glob($auditDir.'/rendered_placeholder_audit_details_*.ndjson') ?: [];
  36. if ($glob === []) {
  37. fwrite(STDERR, "No detail ndjson found under {$auditDir}. Run scripts/audit_rendered_placeholder_integrity.php first.\n");
  38. exit(1);
  39. }
  40. usort($glob, static fn(string $a, string $b): int => strcmp($b, $a));
  41. $detailPath = $glob[0];
  42. }
  43. if (! is_readable($detailPath)) {
  44. fwrite(STDERR, "Cannot read detail file: {$detailPath}\n");
  45. exit(1);
  46. }
  47. $priorityTypes = [
  48. 'internal_placeholder_token_leak',
  49. 'broken_left_right_split',
  50. 'span_then_dollar_before_han',
  51. 'blank_between_math_segments',
  52. 'unbalanced_dollar_after_render',
  53. 'math_ends_with_operator_before_blank',
  54. ];
  55. $buckets = array_fill_keys($priorityTypes, []);
  56. $fh = fopen($detailPath, 'rb');
  57. if ($fh === false) {
  58. fwrite(STDERR, "Failed to open {$detailPath}\n");
  59. exit(1);
  60. }
  61. while (($line = fgets($fh)) !== false) {
  62. $line = trim($line);
  63. if ($line === '') {
  64. continue;
  65. }
  66. $row = json_decode($line, true);
  67. if (! is_array($row) || ! isset($row['issue'], $row['id'])) {
  68. continue;
  69. }
  70. $issue = (string) $row['issue'];
  71. $id = (int) $row['id'];
  72. if ($id <= 0 || ! isset($buckets[$issue])) {
  73. continue;
  74. }
  75. $buckets[$issue][] = $id;
  76. }
  77. fclose($fh);
  78. foreach ($buckets as $issue => &$ids) {
  79. $seen = [];
  80. $unique = [];
  81. foreach ($ids as $id) {
  82. if (! isset($seen[$id])) {
  83. $seen[$id] = true;
  84. $unique[] = $id;
  85. }
  86. }
  87. $ids = $unique;
  88. }
  89. unset($ids);
  90. $selectedOrder = [];
  91. $selectedSet = [];
  92. while (count($selectedOrder) < $count) {
  93. $progress = false;
  94. foreach ($priorityTypes as $type) {
  95. if (count($selectedOrder) >= $count) {
  96. break 2;
  97. }
  98. while ($buckets[$type] !== []) {
  99. $id = array_shift($buckets[$type]);
  100. if (! isset($selectedSet[$id])) {
  101. $selectedSet[$id] = true;
  102. $selectedOrder[] = $id;
  103. $progress = true;
  104. break;
  105. }
  106. }
  107. }
  108. if (! $progress) {
  109. break;
  110. }
  111. }
  112. if ($selectedOrder === []) {
  113. fwrite(STDERR, "No issue rows parsed from {$detailPath}; nothing to sample.\n");
  114. exit(1);
  115. }
  116. if (count($selectedOrder) < $count) {
  117. fwrite(STDERR, 'warning: only '.count($selectedOrder)." unique ids available (requested {$count}).\n");
  118. }
  119. $questions = DB::connection($connection)
  120. ->table($table)
  121. ->whereIn('id', $selectedOrder)
  122. ->get();
  123. $questionMap = [];
  124. foreach ($questions as $q) {
  125. $questionMap[(int) $q->id] = $q;
  126. }
  127. $missing = array_values(array_filter($selectedOrder, static fn(int $id): bool => ! isset($questionMap[$id])));
  128. if ($missing !== []) {
  129. fwrite(STDERR, 'warning: ids not found in '.$table.' ('.$connection.'): '.implode(',', $missing)."\n");
  130. }
  131. $groupedQuestions = groupQuestionsByType($questionMap, $selectedOrder);
  132. $paper = buildVirtualPaper('占位符抽样质检_'.count($selectedOrder).'题', 'placeholder_audit_sample', $groupedQuestions);
  133. /** @var ExamPdfExportService $pdf */
  134. $pdf = $app->make(ExamPdfExportService::class);
  135. $result = $pdf->generateQuestionCheckPdf(
  136. $paper,
  137. $groupedQuestions,
  138. ['name' => '质检抽样', 'grade' => '________'],
  139. ['name' => '________']
  140. );
  141. $manifestPath = dirname(__DIR__).'/storage/app/audit_placeholder/sample_pdf_manifest_'.date('Ymd_His').'.json';
  142. $manifest = [
  143. 'generated_at' => date('c'),
  144. 'detail_source' => $detailPath,
  145. 'connection' => $connection,
  146. 'table' => $table,
  147. 'requested_count' => $count,
  148. 'sampled_question_ids' => $selectedOrder,
  149. 'pdf_result' => $result,
  150. ];
  151. @mkdir(dirname($manifestPath), 0777, true);
  152. file_put_contents($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
  153. echo json_encode([
  154. 'detail_source' => $detailPath,
  155. 'manifest_path' => $manifestPath,
  156. 'sampled_ids' => $selectedOrder,
  157. 'pdf' => $result,
  158. ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)."\n";
  159. /**
  160. * @param array<int, object> $questionMap
  161. */
  162. function groupQuestionsByType(array $questionMap, array $originalOrder): array
  163. {
  164. $grouped = [
  165. 'choice' => [],
  166. 'fill' => [],
  167. 'answer' => [],
  168. ];
  169. $questionNumber = 1;
  170. foreach ($originalOrder as $id) {
  171. if (! isset($questionMap[$id])) {
  172. continue;
  173. }
  174. $q = $questionMap[$id];
  175. $type = normalizeQuestionType($q->question_type ?? null);
  176. $questionObj = (object) [
  177. 'id' => $q->id,
  178. 'question_number' => $questionNumber++,
  179. 'content' => $q->stem,
  180. 'options' => is_string($q->options) ? json_decode($q->options, true) : ($q->options ?? []),
  181. 'answer' => $q->answer,
  182. 'solution' => $q->solution,
  183. 'score' => getDefaultScore($type),
  184. 'difficulty' => $q->difficulty,
  185. 'kp_code' => $q->kp_code,
  186. ];
  187. $grouped[$type][] = $questionObj;
  188. }
  189. return $grouped;
  190. }
  191. function normalizeQuestionType(?string $type): string
  192. {
  193. if (! $type) {
  194. return 'answer';
  195. }
  196. $type = strtolower(trim($type));
  197. $typeMap = [
  198. 'choice' => 'choice',
  199. '选择题' => 'choice',
  200. 'single_choice' => 'choice',
  201. 'multiple_choice' => 'choice',
  202. 'fill' => 'fill',
  203. '填空题' => 'fill',
  204. 'blank' => 'fill',
  205. 'answer' => 'answer',
  206. '解答题' => 'answer',
  207. 'subjective' => 'answer',
  208. 'calculation' => 'answer',
  209. 'proof' => 'answer',
  210. ];
  211. return $typeMap[$type] ?? 'answer';
  212. }
  213. function getDefaultScore(string $type): int
  214. {
  215. return match ($type) {
  216. 'choice' => 5,
  217. 'fill' => 5,
  218. 'answer' => 10,
  219. default => 5,
  220. };
  221. }
  222. /**
  223. * @param array<string, array<int, object>> $groupedQuestions
  224. */
  225. function buildVirtualPaper(string $paperName, string $studentId, array $groupedQuestions): object
  226. {
  227. $totalScore = 0;
  228. $totalQuestions = 0;
  229. foreach ($groupedQuestions as $questions) {
  230. foreach ($questions as $q) {
  231. $totalScore += $q->score;
  232. $totalQuestions++;
  233. }
  234. }
  235. $paperId = $studentId.'_'.time().'_'.uniqid();
  236. return (object) [
  237. 'paper_id' => $paperId,
  238. 'paper_name' => $paperName,
  239. 'total_score' => $totalScore,
  240. 'total_questions' => $totalQuestions,
  241. 'created_at' => now()->toDateTimeString(),
  242. ];
  243. }