| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- <?php
- /**
- * 从占位符审计明细(ndjson)中按类型轮询抽样 N 道题,生成「题目质检」PDF(与 POST /api/questions/pdf 同源逻辑)。
- *
- * 用法:
- * php scripts/generate_sample_placeholder_audit_pdf.php [--count 30]
- * [--detail storage/app/audit_placeholder/rendered_placeholder_audit_details_*.ndjson]
- * [--connection mysql] [--table questions]
- *
- * 若不指定 --detail,则自动选用 storage/app/audit_placeholder 下最新的 rendered_placeholder_audit_details_*.ndjson。
- *
- * 抽样优先级(轮询):
- * internal_placeholder_token_leak → broken_left_right_split → span_then_dollar_before_han
- * → blank_between_math_segments → unbalanced_dollar_after_render → math_ends_with_operator_before_blank
- */
- declare(strict_types=1);
- require __DIR__.'/../vendor/autoload.php';
- $app = require __DIR__.'/../bootstrap/app.php';
- $kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
- $kernel->bootstrap();
- use App\Services\ExamPdfExportService;
- use Illuminate\Support\Facades\DB;
- $options = getopt('', [
- 'count::',
- 'detail::',
- 'connection::',
- 'table::',
- ]);
- $count = isset($options['count']) ? max(1, min(100, (int) $options['count'])) : 30;
- $connection = isset($options['connection']) ? trim((string) $options['connection']) : config('database.default');
- $table = isset($options['table']) ? trim((string) $options['table']) : 'questions';
- $detailPath = isset($options['detail']) ? trim((string) $options['detail']) : '';
- if ($detailPath === '') {
- $auditDir = dirname(__DIR__).'/storage/app/audit_placeholder';
- $glob = glob($auditDir.'/rendered_placeholder_audit_details_*.ndjson') ?: [];
- if ($glob === []) {
- fwrite(STDERR, "No detail ndjson found under {$auditDir}. Run scripts/audit_rendered_placeholder_integrity.php first.\n");
- exit(1);
- }
- usort($glob, static fn(string $a, string $b): int => strcmp($b, $a));
- $detailPath = $glob[0];
- }
- if (! is_readable($detailPath)) {
- fwrite(STDERR, "Cannot read detail file: {$detailPath}\n");
- exit(1);
- }
- $priorityTypes = [
- 'internal_placeholder_token_leak',
- 'broken_left_right_split',
- 'span_then_dollar_before_han',
- 'blank_between_math_segments',
- 'unbalanced_dollar_after_render',
- 'math_ends_with_operator_before_blank',
- ];
- $buckets = array_fill_keys($priorityTypes, []);
- $fh = fopen($detailPath, 'rb');
- if ($fh === false) {
- fwrite(STDERR, "Failed to open {$detailPath}\n");
- exit(1);
- }
- while (($line = fgets($fh)) !== false) {
- $line = trim($line);
- if ($line === '') {
- continue;
- }
- $row = json_decode($line, true);
- if (! is_array($row) || ! isset($row['issue'], $row['id'])) {
- continue;
- }
- $issue = (string) $row['issue'];
- $id = (int) $row['id'];
- if ($id <= 0 || ! isset($buckets[$issue])) {
- continue;
- }
- $buckets[$issue][] = $id;
- }
- fclose($fh);
- foreach ($buckets as $issue => &$ids) {
- $seen = [];
- $unique = [];
- foreach ($ids as $id) {
- if (! isset($seen[$id])) {
- $seen[$id] = true;
- $unique[] = $id;
- }
- }
- $ids = $unique;
- }
- unset($ids);
- $selectedOrder = [];
- $selectedSet = [];
- while (count($selectedOrder) < $count) {
- $progress = false;
- foreach ($priorityTypes as $type) {
- if (count($selectedOrder) >= $count) {
- break 2;
- }
- while ($buckets[$type] !== []) {
- $id = array_shift($buckets[$type]);
- if (! isset($selectedSet[$id])) {
- $selectedSet[$id] = true;
- $selectedOrder[] = $id;
- $progress = true;
- break;
- }
- }
- }
- if (! $progress) {
- break;
- }
- }
- if ($selectedOrder === []) {
- fwrite(STDERR, "No issue rows parsed from {$detailPath}; nothing to sample.\n");
- exit(1);
- }
- if (count($selectedOrder) < $count) {
- fwrite(STDERR, 'warning: only '.count($selectedOrder)." unique ids available (requested {$count}).\n");
- }
- $questions = DB::connection($connection)
- ->table($table)
- ->whereIn('id', $selectedOrder)
- ->get();
- $questionMap = [];
- foreach ($questions as $q) {
- $questionMap[(int) $q->id] = $q;
- }
- $missing = array_values(array_filter($selectedOrder, static fn(int $id): bool => ! isset($questionMap[$id])));
- if ($missing !== []) {
- fwrite(STDERR, 'warning: ids not found in '.$table.' ('.$connection.'): '.implode(',', $missing)."\n");
- }
- $groupedQuestions = groupQuestionsByType($questionMap, $selectedOrder);
- $paper = buildVirtualPaper('占位符抽样质检_'.count($selectedOrder).'题', 'placeholder_audit_sample', $groupedQuestions);
- /** @var ExamPdfExportService $pdf */
- $pdf = $app->make(ExamPdfExportService::class);
- $result = $pdf->generateQuestionCheckPdf(
- $paper,
- $groupedQuestions,
- ['name' => '质检抽样', 'grade' => '________'],
- ['name' => '________']
- );
- $manifestPath = dirname(__DIR__).'/storage/app/audit_placeholder/sample_pdf_manifest_'.date('Ymd_His').'.json';
- $manifest = [
- 'generated_at' => date('c'),
- 'detail_source' => $detailPath,
- 'connection' => $connection,
- 'table' => $table,
- 'requested_count' => $count,
- 'sampled_question_ids' => $selectedOrder,
- 'pdf_result' => $result,
- ];
- @mkdir(dirname($manifestPath), 0777, true);
- file_put_contents($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
- echo json_encode([
- 'detail_source' => $detailPath,
- 'manifest_path' => $manifestPath,
- 'sampled_ids' => $selectedOrder,
- 'pdf' => $result,
- ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)."\n";
- /**
- * @param array<int, object> $questionMap
- */
- function groupQuestionsByType(array $questionMap, array $originalOrder): array
- {
- $grouped = [
- 'choice' => [],
- 'fill' => [],
- 'answer' => [],
- ];
- $questionNumber = 1;
- foreach ($originalOrder as $id) {
- if (! isset($questionMap[$id])) {
- continue;
- }
- $q = $questionMap[$id];
- $type = normalizeQuestionType($q->question_type ?? null);
- $questionObj = (object) [
- 'id' => $q->id,
- 'question_number' => $questionNumber++,
- 'content' => $q->stem,
- 'options' => is_string($q->options) ? json_decode($q->options, true) : ($q->options ?? []),
- 'answer' => $q->answer,
- 'solution' => $q->solution,
- 'score' => getDefaultScore($type),
- 'difficulty' => $q->difficulty,
- 'kp_code' => $q->kp_code,
- ];
- $grouped[$type][] = $questionObj;
- }
- return $grouped;
- }
- function normalizeQuestionType(?string $type): string
- {
- if (! $type) {
- return 'answer';
- }
- $type = strtolower(trim($type));
- $typeMap = [
- 'choice' => 'choice',
- '选择题' => 'choice',
- 'single_choice' => 'choice',
- 'multiple_choice' => 'choice',
- 'fill' => 'fill',
- '填空题' => 'fill',
- 'blank' => 'fill',
- 'answer' => 'answer',
- '解答题' => 'answer',
- 'subjective' => 'answer',
- 'calculation' => 'answer',
- 'proof' => 'answer',
- ];
- return $typeMap[$type] ?? 'answer';
- }
- function getDefaultScore(string $type): int
- {
- return match ($type) {
- 'choice' => 5,
- 'fill' => 5,
- 'answer' => 10,
- default => 5,
- };
- }
- /**
- * @param array<string, array<int, object>> $groupedQuestions
- */
- function buildVirtualPaper(string $paperName, string $studentId, array $groupedQuestions): object
- {
- $totalScore = 0;
- $totalQuestions = 0;
- foreach ($groupedQuestions as $questions) {
- foreach ($questions as $q) {
- $totalScore += $q->score;
- $totalQuestions++;
- }
- }
- $paperId = $studentId.'_'.time().'_'.uniqid();
- return (object) [
- 'paper_id' => $paperId,
- 'paper_name' => $paperName,
- 'total_score' => $totalScore,
- 'total_questions' => $totalQuestions,
- 'created_at' => now()->toDateTimeString(),
- ];
- }
|