option('since') ? Carbon::parse((string) $this->option('since'))->startOfDay() : null; $qbId = $this->option('question-bank-id'); $minAttempts = (int) $this->option('min-attempts'); if ($qbId !== null && $qbId !== '' && $minAttempts === 5) { $minAttempts = 1; } $report = $analyzer->run([ 'min_attempts' => $minAttempts, 'since' => $since, 'include_mistakes' => ! $this->option('no-mistakes'), 'calibration_min_attempts' => (int) $this->option('calibration-min-attempts'), 'alpha' => (float) $this->option('alpha'), 'max_step' => (float) $this->option('max-step'), 'half_life_days' => (int) $this->option('half-life-days'), 'student_id' => $this->option('student'), 'question_bank_id' => $qbId !== null && $qbId !== '' ? (int) $qbId : null, 'question_code' => $this->option('question-code') ? (string) $this->option('question-code') : null, ]); if (! ($report['ok'] ?? false)) { $this->error($report['error'] ?? '分析失败'); return self::FAILURE; } $rows = collect($report['per_question'] ?? []); $meta = $report['meta'] ?? []; $this->line('筛选条件: min_attempts='.($meta['min_attempts'] ?? '') .($meta['since'] ? ' since='.$meta['since'] : '') .($meta['student_id'] ? ' student_id='.$meta['student_id'] : '') .($meta['question_bank_id'] ? ' question_bank_id='.$meta['question_bank_id'] : '')); $constraints = $meta['calibration_constraints'] ?? []; if ($constraints !== []) { $this->line( '动态约束: stratified_by='.$constraints['stratified_by'] .' min='.$constraints['min_attempts'] .' alpha='.$constraints['alpha'] .' max_step='.$constraints['max_step'] .' half_life_days='.$constraints['time_decay_half_life_days'] ); } $this->line('命中题数(聚合行数): '.($meta['question_rows'] ?? $rows->count())); $this->newLine(); $sort = (string) $this->option('sort'); $rows = match ($sort) { 'gap_desc' => $rows->sortByDesc(fn ($r) => abs((float) ($r['calibration_gap'] ?? 0)))->values(), 'gap_asc' => $rows->sortBy(fn ($r) => abs((float) ($r['calibration_gap'] ?? 0)))->values(), default => $rows->sortByDesc('attempts')->values(), }; $withStem = (bool) $this->option('with-stem'); $stemById = []; if ($withStem && $rows->isNotEmpty()) { $ids = $rows->pluck('question_bank_id')->unique()->filter()->all(); $stemById = DB::table('questions') ->whereIn('id', $ids) ->pluck('stem', 'id') ->all(); } $limit = (int) $this->option('limit'); $slice = $limit === 0 ? $rows : $rows->take($limit); $tableRows = $slice->map(function (array $r) use ($withStem, $stemById) { $norm = $r['bank_difficulty_normalized']; $emp = $r['empirical_error_rate']; $line = [ (string) ($r['question_bank_id'] ?? ''), (string) ($r['question_code'] ?? ''), (string) ($r['attempts'] ?? ''), (string) ($r['correct_count'] ?? ''), (string) ($r['wrong_count'] ?? ''), $r['accuracy'] !== null ? (string) $r['accuracy'] : '', $r['bank_difficulty'] !== null ? (string) $r['bank_difficulty'] : '', $norm !== null ? (string) $norm : '', $r['avg_paper_question_difficulty'] !== null ? (string) round((float) $r['avg_paper_question_difficulty'], 4) : '', $emp !== null ? (string) round((float) $emp, 4) : '', $r['calibration_gap'] !== null ? (string) $r['calibration_gap'] : '', isset($r['calibration_weighted_error_rate']) ? (string) $r['calibration_weighted_error_rate'] : '', isset($r['calibration_effective_attempts']) ? (string) $r['calibration_effective_attempts'] : '', (string) (($r['calibration_recommendation']['action'] ?? 'hold')), isset($r['calibration_recommendation']['delta']) ? (string) $r['calibration_recommendation']['delta'] : '', isset($r['calibration_recommendation']['suggested_difficulty']) ? (string) $r['calibration_recommendation']['suggested_difficulty'] : '', (string) ($r['mistake_records_count'] ?? '0'), ]; if ($withStem) { $id = (int) ($r['question_bank_id'] ?? 0); $raw = $stemById[$id] ?? ''; $text = $raw !== '' ? mb_substr(trim(strip_tags($raw)), 0, 60) : ''; $line[] = $text; } return $line; })->all(); $headers = [ '题库id', 'question_code', '作答次数', '对', '错', '正确率', '题库difficulty(原值)', '题库难度(0~1)', '卷面难度均值', '实测错误率', '标定-实测差(gap)', '分层时衰错误率', '有效样本(时衰)', '建议动作', '建议delta', '建议新难度', '错题本行数', ]; if ($withStem) { $headers[] = '题干前60字'; } $this->info('【每一道做过且已判分的题】——以下为聚合结果(同一题库 id 跨所有相关试卷合并)'); $this->table($headers, $tableRows); if ($limit > 0 && $rows->count() > $limit) { $this->comment('仅显示前 '.$limit.' 行,共 '.$rows->count().' 行;加 --limit=0 可输出全部(建议配合 --csv)。'); } $this->newLine(); $qb = $meta['question_bank_id'] ?? null; if ($qb !== null) { $q = Question::query()->find($qb); if ($q) { $this->info('【本题题干节选】question_bank_id='.$qb); $this->line(mb_substr(trim(strip_tags((string) $q->stem)), 0, 400)); $this->newLine(); } } if ($this->option('with-aggregate')) { $s = $report['summary'] ?? []; $this->line('Pearson(题库0~1难度 vs 实测错误率): '.json_encode($s['pearson_bank_difficulty_vs_empirical_error_rate'] ?? null)); $this->line($s['interpretation'] ?? ''); $this->line('Pearson(学案档位 vs 单次是否错): '.json_encode($s['pearson_paper_difficulty_category_vs_incorrect'] ?? null)); $paper = $report['paper_difficulty_category_vs_incorrect_rate'] ?? []; $this->table( ['学案档位(0-4)', '条数', '错误率'], collect($paper['by_category'] ?? [])->map(fn ($r) => [ $r['difficulty_category_numeric'] ?? 'unknown', $r['n'], $r['incorrect_rate'], ])->all() ); $this->table( ['bin_min', 'bin_max', '题数', '总作答', '平均正确率'], collect($report['bins_by_bank_difficulty'] ?? [])->map(fn ($b) => [ $b['min'], $b['max'], $b['n_questions'], $b['total_attempts'], $b['mean_accuracy'], ])->all() ); $this->newLine(); } $jsonPath = $this->option('json'); if ($jsonPath) { File::put($jsonPath, json_encode($report, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); $this->info('JSON: '.$jsonPath); } $csvPath = $this->option('csv'); if ($csvPath) { $this->writeCsv($csvPath, $report['per_question'] ?? []); $this->info('CSV: '.$csvPath); } return self::SUCCESS; } /** * @param list> $rows */ private function writeCsv(string $path, array $rows): void { $fh = fopen($path, 'wb'); if ($fh === false) { $this->error('无法写入 CSV'); return; } $headers = [ 'question_bank_id', 'question_code', 'attempts', 'correct_count', 'wrong_count', 'accuracy', 'bank_difficulty', 'bank_difficulty_normalized', 'avg_paper_question_difficulty', 'empirical_error_rate', 'calibration_gap', 'calibration_weighted_error_rate', 'calibration_effective_attempts', 'recommended_action', 'recommended_delta', 'suggested_difficulty', 'mistake_records_count', ]; fputcsv($fh, $headers); foreach ($rows as $r) { fputcsv($fh, [ $r['question_bank_id'] ?? '', $r['question_code'] ?? '', $r['attempts'] ?? '', $r['correct_count'] ?? '', $r['wrong_count'] ?? '', $r['accuracy'] ?? '', $r['bank_difficulty'] ?? '', $r['bank_difficulty_normalized'] ?? '', $r['avg_paper_question_difficulty'] ?? '', $r['empirical_error_rate'] ?? '', $r['calibration_gap'] ?? '', $r['calibration_weighted_error_rate'] ?? '', $r['calibration_effective_attempts'] ?? '', $r['calibration_recommendation']['action'] ?? '', $r['calibration_recommendation']['delta'] ?? '', $r['calibration_recommendation']['suggested_difficulty'] ?? '', $r['mistake_records_count'] ?? '', ]); } fclose($fh); } }