AI项目博客
记录 AI 工程实践与大模型应用

从零搭建 AI 在线考试系统(四):判分阅卷与统计分析

这一篇实现交卷之后的所有环节:判分、人工阅卷、成绩展示、错题归集、统计分析。核心矛盾是——怎么在「给考生更多信息」和「不泄露答案」之间找平衡。

示意图

一、判分器的完整实现

先把判分器整个放出来,因为这个类的设计决定了后面所有环节的正确性。

package com.laijun.exam.take.grader;

/**

 * 客观题自动判分器。

 *

 * <p>判分口径必须与题库保存时的「答案归一化」完全一致

 * (见 ExamQuestionServiceImpl#normalizeAnswer),否则会出现「答案对却判错」:</p>

 * <ul>

 *   <li><b>单选</b>:单个选项 key,转大写后字符串比较</li>

 *   <li><b>多选</b>:选项 key 去重后按字母升序逗号拼接,字符串比较</li>

 *   <li><b>判断</b>:1 对 / 0 错</li>

 *   <li><b>填空</b>:各空答案用 | 分隔;按空逐一对齐比较,忽略大小写与首尾空白</li>

 * </ul>

 *

 * <p>判分策略默认「全对给分」;当考试开启「多选半对给分 / 填空按空给分」时,

 * 多选漏选得半分、填空按答对空数比例折算。策略随组卷快照定格,

 * 历史成绩不受后续配置改动影响。</p>

 */

@Component

public class ExamAutoGrader {

    private static final String BLANK_SEPARATOR = "|";   // 填空题多空分隔符

    private static final String ANSWER_SEPARATOR = ",";  // 多选答案分隔符

    /** 整卷判分 */

    public ExamGradingResult grade(ExamPaperSnapshot snapshot, Map<Long, String> answers) {

        List<ExamSnapshotQuestion> questions = snapshot == null || snapshot.getQuestions() == null

                ? List.of() : snapshot.getQuestions();

        // 判分策略随快照定格,与考生作答时的考试配置一致

        boolean multiplePartial = isFlagOn(snapshot == null ? null : snapshot.getMultiplePartialScore());

        boolean blankPartial = isFlagOn(snapshot == null ? null : snapshot.getBlankPartialScore());

        List<ExamQuestionGrade> details = new ArrayList<>(questions.size());

        BigDecimal total = BigDecimal.ZERO;

        int correctCount = 0;

        int blankCount = 0;

        int manualCount = 0;

        for (ExamSnapshotQuestion question : questions) {

            String raw = answers == null ? null : answers.get(question.getQuestionId());

            ExamQuestionGrade grade = gradeOne(question, raw, multiplePartial, blankPartial);

            details.add(grade);

            // 无条件累加:半对给分时 score 可能介于 0 与满分之间,不能再以 correct 作为累加条件

            total = total.add(grade.getScore() == null ? BigDecimal.ZERO : grade.getScore());

            if (Boolean.TRUE.equals(grade.getCorrect())) {

                correctCount++;

            }

            if (!StringUtils.hasText(grade.getUserAnswer())) {

                blankCount++;

            }

            if (Boolean.TRUE.equals(grade.getManual())) {

                manualCount++;

            }

        }

        ExamGradingResult result = new ExamGradingResult();

        result.setDetails(details);

        result.setTotalScore(total);

        result.setCorrectCount(correctCount);

        result.setBlankCount(blankCount);

        result.setManualCount(manualCount);

        return result;

    }

    /** 单题判分 */

    private ExamQuestionGrade gradeOne(ExamSnapshotQuestion question, String rawAnswer,

                                       boolean multiplePartial, boolean blankPartial) {

        QuestionTypeEnum type = QuestionTypeEnum.fromCode(question.getQuestionType());

        BigDecimal fullScore = question.getScore() == null ? BigDecimal.ZERO : question.getScore();

        ExamQuestionGrade grade = new ExamQuestionGrade();

        grade.setQuestionId(question.getQuestionId());

        grade.setQuestionType(question.getQuestionType());

        if (type == null || !StringUtils.hasText(rawAnswer)) {

            // 题型未知或未作答:不得分,但保留原始作答便于查卷

            grade.setUserAnswer(StringUtils.hasText(rawAnswer) ? rawAnswer.trim() : null);

            grade.setCorrect(false);

            grade.setScore(BigDecimal.ZERO);

            return grade;

        }

        if (type == QuestionTypeEnum.SHORT_ANSWER) {

            // 主观题不可自动判分:原样保留考生文本,得分暂记 0,标记为待人工阅卷

            grade.setUserAnswer(rawAnswer.trim());

            grade.setCorrect(false);

            grade.setScore(BigDecimal.ZERO);

            grade.setManual(true);

            return grade;

        }

        String normalized = normalize(type, rawAnswer);

        String expected = normalizeExpected(type, question.getAnswer());

        boolean fullyCorrect = normalized != null && normalized.equals(expected);

        grade.setUserAnswer(normalized);

        grade.setCorrect(fullyCorrect);

        grade.setScore(fullyCorrect ? fullScore

                : partialScore(type, normalized, expected, fullScore, multiplePartial, blankPartial));

        return grade;

    }

    /** 部分得分(仅在未全对时调用) */

    private BigDecimal partialScore(QuestionTypeEnum type, String normalized, String expected,

                                    BigDecimal fullScore,

                                    boolean multiplePartial, boolean blankPartial) {

        if (type == QuestionTypeEnum.MULTIPLE && multiplePartial) {

            return multiplePartialScore(normalized, expected, fullScore);

        }

        if (type == QuestionTypeEnum.BLANK && blankPartial) {

            return blankPartialScore(normalized, expected, fullScore);

        }

        return BigDecimal.ZERO;   // 默认「全对给分」

    }

    /** 多选半对给分:漏选(所选为标准答案的真子集)给一半,错选不得分 */

    private BigDecimal multiplePartialScore(String normalized, String expected, BigDecimal fullScore) {

        if (normalized == null || expected == null) {

            return BigDecimal.ZERO;

        }

        Set<String> user = keySet(normalized);

        Set<String> all = keySet(expected);

        if (user.isEmpty() || user.size() >= all.size()) {

            return BigDecimal.ZERO;

        }

        return all.containsAll(user)

                ? fullScore.divide(BigDecimal.valueOf(2), 2, RoundingMode.HALF_UP)

                : BigDecimal.ZERO;

    }

    /** 填空按空给分:按「答对空数 / 标准空数」折算 */

    private BigDecimal blankPartialScore(String normalized, String expected, BigDecimal fullScore) {

        if (normalized == null || expected == null) {

            return BigDecimal.ZERO;

        }

        String[] userBlanks = normalized.split("\" + BLANK_SEPARATOR, -1);

        String[] expectedBlanks = expected.split("\" + BLANK_SEPARATOR, -1);

        if (expectedBlanks.length == 0) {

            return BigDecimal.ZERO;

        }

        int matched = 0;

        for (int i = 0; i < expectedBlanks.length; i++) {

            String userBlank = i < userBlanks.length ? userBlanks[i] : "";

            if (!expectedBlanks[i].isEmpty() && expectedBlanks[i].equals(userBlank)) {

                matched++;

            }

        }

        if (matched <= 0) {

            return BigDecimal.ZERO;

        }

        return fullScore.multiply(BigDecimal.valueOf(matched))

                .divide(BigDecimal.valueOf(expectedBlanks.length), 2, RoundingMode.HALF_UP);

    }

    /** 归一化考生作答(与题库保存口径一致) */

    private String normalize(QuestionTypeEnum type, String rawAnswer) {

        String trimmed = rawAnswer.trim();

        if (trimmed.isEmpty()) {

            return null;

        }

        return switch (type) {

            case SINGLE -> trimmed.toUpperCase();

            case MULTIPLE -> normalizeKeySet(trimmed);

            case JUDGE -> trimmed;

            case BLANK -> normalizeBlanks(trimmed);

            // 简答题不走自动判分(gradeOne 已提前返回),此处仅保证 switch 完备

            case SHORT_ANSWER -> trimmed;

        };

    }

    /**

     * 归一化标准答案。

     * <p>标准答案入库时已按同一口径归一化,这里再走一遍是为了兼容

     * 题库早期数据或人工订正时的宽松写法(如多选写成 "C,A")。</p>

     */

    private String normalizeExpected(QuestionTypeEnum type, String expected) {

        if (!StringUtils.hasText(expected)) {

            return null;

        }

        return normalize(type, expected);

    }

    /** 多选归一化:按逗号拆分、去重、字母升序拼接 */

    private String normalizeKeySet(String raw) {

        Set<String> keys = new LinkedHashSet<>();

        for (String part : raw.split(ANSWER_SEPARATOR, -1)) {

            if (StringUtils.hasText(part)) {

                keys.add(part.trim().toUpperCase());

            }

        }

        if (keys.isEmpty()) {

            return null;

        }

        List<String> sorted = new ArrayList<>(keys);

        sorted.sort(Comparator.naturalOrder());

        return String.join(ANSWER_SEPARATOR, sorted);

    }

    /** 填空归一化:按空拆分、逐空 trim + 小写、用 | 重新拼接 */

    private String normalizeBlanks(String raw) {

        List<String> blanks = new ArrayList<>();

        for (String part : raw.split("\" + BLANK_SEPARATOR, -1)) {

            blanks.add(part.trim().toLowerCase());

        }

        return String.join(BLANK_SEPARATOR, blanks);

    }

}

1.1 为什么归一化口径这么重要

看判分的最后一步:normalized.equals(expected)——就是一个字符串相等判断。

能做到这么简单,前提是「考生作答」和「标准答案」都经过了同一套归一化。如果口径不一致:保存时存的是 C,A,判分时按 A,C 比较,就会出现”答案对却判错”。这类 bug 在测试环境极难触发(要恰好构造出乱序多选、或大小写不同的填空),上线后却会成片爆发。

所以口径必须单点产出:题库保存时由 ExamQuestionServiceImpl#normalizeAnswer 归一化,判分时由 ExamAutoGrader#normalize 归一化,两处的实现必须保持同步。代码注释里特意标了这层依赖关系。

二、交卷时判分

判分发生在交卷的那一刻,逻辑已经在上一篇的 doSubmit 里:

ExamPaperSnapshot snapshot = parseSnapshot(record.getPaperSnapshot());

Map<Long, String> answers = examDraftStore.loadDrafts(record.getId());

ExamGradingResult grading = examAutoGrader.grade(snapshot, answers);

// 先清旧明细:强制交卷与手动交卷可能并发到同一答卷,保证幂等

examAnswerService.remove(new LambdaQueryWrapper<ExamAnswerEntity>()

        .eq(ExamAnswerEntity::getRecordId, record.getId()));

examAnswerService.saveBatch(buildAnswers(record.getId(), grading.getDetails()));

record.setTotalScore(grading.getTotalScore());

record.setPassFlag(passed ? 1 : 0);

record.setManualPending(grading.getManualCount() > 0 ? 1 : 0);

答题明细的落库:

private List<ExamAnswerEntity> buildAnswers(Long recordId, List<ExamQuestionGrade> grades) {

    List<ExamAnswerEntity> entities = new ArrayList<>();

    if (CollectionUtils.isEmpty(grades)) {

        return entities;

    }

    for (ExamQuestionGrade grade : grades) {

        ExamAnswerEntity entity = new ExamAnswerEntity();

        entity.setRecordId(recordId);

        entity.setQuestionId(grade.getQuestionId());

        entity.setQuestionType(grade.getQuestionType());

        entity.setUserAnswer(grade.getUserAnswer());

        entity.setIsCorrect(Boolean.TRUE.equals(grade.getCorrect()) ? 1 : 0);

        entity.setScore(grade.getScore() == null ? BigDecimal.ZERO : grade.getScore());

        // 主观题本次未评分(待人工阅卷),客观题交卷即已判分

        entity.setGraded(Boolean.TRUE.equals(grade.getManual()) ? 0 : 1);

        entities.add(entity);

    }

    return entities;

}

graded 字段是区分两种状态的关键:客观题交卷即判分(graded=1),简答题等待阅卷(graded=0)。

三、成绩必须回读,不能复用返回值

这是个容易做错的地方。直觉做法是:交卷接口返回成绩,前端拿到后存起来,结果页显示这份数据。但这样会有问题——刷新页面、换个标签页、过几天从「我的考试」点进来,成绩就没了,因为它是随响应传递的临时状态。

/**

 * 按答卷Id查询交卷结果。

 *

 * <p><b>为什么必须回读而不是复用交卷时的返回值</b>:判分只在交卷那一刻发生一次,

 * 之后的所有进入(刷新结果页、换标签页、过几天从「我的考试」点进来)都只能从

 * 答卷与答题明细回读。否则成绩就只存在于那一次 HTTP 响应里,一刷新即丢失。</p>

 */

@Override

public ExamSubmitResultVO result(Long recordId) {

    ExamRecordEntity record = requireOwnRecord(recordId);

    // 得分 / 及格标志直接取答卷字段,答对数由答题明细统计

    // ...

}

对应两个接口:

GET /exam/take/result/{recordId}     成绩页(从库回读)

GET /exam/take/review/{recordId}     答题回顾(快照 + 明细合并)

同时在「我的考试」列表里返回 latest_record_id(最近一次已交卷答卷),作为成绩页的入口。这样无论什么时候进来,看到的都是同一份持久化结果。

归属校验与其它考生端接口一致:答卷必须属于当前登录用户。

成绩查询

图:成绩查询,只展示已交卷/超时交卷的答卷。

四、人工阅卷

@Override

@Transactional(rollbackFor = Exception.class)

public void manualGrade(ExamManualGradeBO bo) {

    ExamRecordEntity record = getOrThrow(bo.getRecordId(), "答卷不存在,ID=" + bo.getRecordId());

    RecordStatusEnum status = RecordStatusEnum.fromCode(record.getStatus());

    if (status == null || !status.isFinished()) {

        throw new PlatformException("答卷尚未交卷,不能阅卷");

    }

    ExamPaperSnapshot snapshot = parseSnapshot(record.getPaperSnapshot());

    // 建立两个索引,避免循环里反复查库:快照题目 + 答题明细

    Map<Long, ExamSnapshotQuestion> questionIndex = new HashMap<>(

            snapshot.getQuestions() == null ? 16 : snapshot.getQuestions().size());

    if (snapshot.getQuestions() != null) {

        for (ExamSnapshotQuestion question : snapshot.getQuestions()) {

            questionIndex.put(question.getQuestionId(), question);

        }

    }

    Map<Long, ExamAnswerEntity> answerIndex = new HashMap<>();

    for (ExamAnswerEntity answer : examAnswerService.lambdaQuery()

            .eq(ExamAnswerEntity::getRecordId, record.getId())

            .list()) {

        answerIndex.put(answer.getId(), answer);

    }

    for (ExamManualGradeItemBO item : bo.getItems()) {

        ExamAnswerEntity answer = answerIndex.get(item.getAnswerId());

        if (answer == null) {

            throw new PlatformException("答题明细不存在,ID=" + item.getAnswerId());

        }

        ExamSnapshotQuestion question = questionIndex.get(answer.getQuestionId());

        if (question == null || !QuestionTypeEnum.SHORT_ANSWER.getCode()

                .equals(question.getQuestionType())) {

            throw new PlatformException("只有简答题可以人工阅卷");

        }

        BigDecimal maxScore = question.getScore() == null ? BigDecimal.ZERO : question.getScore();

        BigDecimal score = item.getScore() == null ? BigDecimal.ZERO : item.getScore();

        // 得分必须落在 0 ~ 本题满分之间

        if (score.compareTo(BigDecimal.ZERO) < 0 || score.compareTo(maxScore) > 0) {

            throw new PlatformException("第 " + question.getSortNo() + " 题得分须在 0 ~ "

                    + maxScore.stripTrailingZeros().toPlainString() + " 之间");

        }

        answer.setScore(score);

        // 简答题没有「部分正确」概念,按是否给满折算正确与否(用于答对数统计)

        answer.setIsCorrect(score.compareTo(maxScore) >= 0 ? 1 : 0);

        answer.setGraded(1);

        examAnswerService.updateById(answer);

    }

    // 重算:客观题得分 + 已阅主观题得分;仍有未阅题则保持「待阅卷」

    recalculate(record, snapshot);

}

重算逻辑单独抽出来,因为阅卷有两条路径(逐份阅卷、按题批量给分),两条路径必须共用同一段重算:

/**

 * 重算答卷总分 / 及格标志 / 待阅标志。

 *

 * <p>逐份阅卷与按题批量给分共用同一段重算逻辑:两条路径若各写一份,

 * 口径一旦调整(例如「仍有未阅题则保持待阅」)就会出现两处不一致,

 * 而这类不一致会直接表现为成绩数字打架。</p>

 */

private void recalculate(ExamRecordEntity record, ExamPaperSnapshot snapshot) {

    List<ExamAnswerEntity> latest = examAnswerService.lambdaQuery()

            .eq(ExamAnswerEntity::getRecordId, record.getId())

            .list();

    BigDecimal total = latest.stream()

            .map(item -> item.getScore() == null ? BigDecimal.ZERO : item.getScore())

            .reduce(BigDecimal.ZERO, BigDecimal::add);

    boolean stillPending = latest.stream()

            .anyMatch(item -> !Integer.valueOf(1).equals(item.getGraded()));

    BigDecimal passScore = snapshot.getPassScore();

    record.setTotalScore(total);

    record.setPassFlag(passScore != null && total.compareTo(passScore) >= 0 ? 1 : 0);

    record.setManualPending(stillPending ? 1 : 0);

    updateById(record);

}

4.1 交卷不阻塞等阅卷

关键设计:简答题不阻塞交卷。

交卷时 ExamAutoGrader 对简答题原样保留考生文本、得分记 0,并打上 manual 标记。当 manualCount > 0 时,答卷置 manual_pending = 1。

也就是说:考生体验是「交卷即完成」,总分暂时只含客观题。不会出现”交卷后转圈等待老师阅卷”这种糟糕体验。管理端用 manualPending 过滤出待阅卷的答卷。

五、答案可见性策略

这是本篇最需要权衡的地方。考生答完想看标准答案,这很合理。但如果一场考试允许考 2 次(attempt_limit > 1),第一轮结束就看到答案,第二轮直接抄——考试就失去意义了。

/**

 * 是否可向考生公开标准答案与解析。

 *

 * <p>满足任一条件即可公开(此时考生已无重考机会,公开不会泄题):</p>

 * <ol>

 *     <li>考试状态为「已结束」;</li>

 *     <li>当前时间已过考试结束时间(定时任务尚未把状态翻到已结束的窗口期);</li>

 *     <li>该考生在本场考试的作答次数已达到次数上限。</li>

 * </ol>

 */

private boolean canRevealAnswer(ExamExamEntity exam, ExamRecordEntity record) {

    if (exam == null) {

        return false;

    }

    if (ExamStatusEnum.FINISHED.getCode().equals(exam.getStatus())) {

        return true;

    }

    LocalDateTime now = LocalDateTime.now();

    if (exam.getEndTime() != null && !now.isBefore(exam.getEndTime())) {

        return true;

    }

    int attemptLimit = exam.getAttemptLimit() == null ? 1 : exam.getAttemptLimit();

    return countAttempts(record.getExamId(), record.getUserId()) >= attemptLimit;

}

条件 2 看起来冗余,其实是补漏。状态翻转靠定时任务,可能滞后一分钟。如果只判断状态,考生就能在这段窗口里看到答案——而这个窗口刚好紧跟在”考试结束”之后,正是最容易被钻的时候。

未公开时,回顾页仍然展示题干、选项、本人作答、对错、本题得分,只是隐藏标准答案与解析,并给出「考试结束后可查看」的提示。考生能确认自己哪题错了,但看不到正确答案。

5.1 回顾页只读不重算

考生端回顾接口的数据取自答卷里定格的组卷快照与已落库的明细,只读不重算。这样它与管理端的成绩详情同源,判分口径天然一致——不会出现”考生看到的分数和老师看到的不一样”这种最尴尬的情况。

归属校验:不挂 RBAC 权限点,靠「登录拦截 + 答卷归属校验 + 状态校验」三层保证。

六、错题本

错题本以 exam_answer 为驱动表,按 question_id 聚合出「本人答错过的题」,附带答错次数与最近答错时间:

<!-- 只归集已结束考试的错题,保证可以安全下发答案与解析 -->

SELECT ...

FROM exam_answer a

    JOIN exam_record r ON r.id = a.record_id

    JOIN exam_exam e ON e.id = r.exam_id

WHERE a.is_correct = 0

    AND r.user_id = #{userId}

    AND e.end_time <= NOW()

    AND r.status IN (2, 3)          -- 已交卷 / 超时交卷

GROUP BY a.question_id

ORDER BY COUNT(*) DESC, MAX(r.submit_time) DESC

e.end_time <= NOW() 这条约束同时解决了功能需求和安全需求——因为能确定这些考试不会再考,所以可以安全地下发标准答案与解析。

userId 由服务端从登录态注入,前端传入的一律忽略,这是越权防护。

七、统计分析

private ExamScoreStatisticsVO buildStatistics(ExamExamEntity exam, boolean withAnalysis) {

    Long examId = exam.getId();

    ExamPaperEntity paper = examPaperMapper.selectById(exam.getPaperId());

    BigDecimal totalScore = paper == null ? null : paper.getTotalScore();

    BigDecimal passScore = exam.getPassScore() != null

            ? exam.getPassScore() : (paper == null ? null : paper.getPassScore());

    List<ExamRecordEntity> records = lambdaQuery()

            .eq(ExamRecordEntity::getExamId, examId)

            .in(ExamRecordEntity::getStatus,

                    RecordStatusEnum.SUBMITTED.getCode(), RecordStatusEnum.TIMEOUT_SUBMITTED.getCode())

            .list();

    // 含主观题且尚未阅卷的答卷:总分只是「客观题得分」,纳入平均分/分数段会拉低统计

    List<ExamRecordEntity> gradedRecords = records.stream()

            .filter(r -> !Integer.valueOf(1).equals(r.getManualPending()))

            .toList();

    int pendingCount = records.size() - gradedRecords.size();

    int candidateCount = Math.toIntExact(examCandidateService.countByExamId(examId));

    int attendedCount = records.size();

    int passCount = (int) gradedRecords.stream()

            .filter(r -> Integer.valueOf(1).equals(r.getPassFlag()))

            .count();

    vo.setCandidateCount(candidateCount);

    vo.setAttendedCount(attendedCount);

    vo.setAbsentCount(Math.max(candidateCount - attendedCount, 0));

    vo.setPendingCount(pendingCount);

    // 通过率以「实考人数」为分母:把缺考算进去会被缺考人数稀释,不是教学上关心的口径

    vo.setPassRate(percent(passCount, attendedCount));

    // 平均分/最高分/最低分/分数段 全部只用「已判分答卷」

    List<BigDecimal> scores = gradedRecords.stream()

            .map(r -> r.getTotalScore() == null ? BigDecimal.ZERO : r.getTotalScore())

            .toList();

    // ...

    vo.setRanges(buildRanges(scores, totalScore, attendedCount));

    if (!withAnalysis) {

        return vo;

    }

    // 高频错题 / 知识点掌握度:直接按 examId 在库内聚合(不随答卷量膨胀到内存)

    List<ExamWrongQuestionStatVO> wrongStats =

            baseMapper.selectWrongQuestionStats(examId, TOP_WRONG_QUESTION_LIMIT);

    fillWrongQuestionStat(wrongStats);

    vo.setTopWrongQuestions(wrongStats);

    List<ExamCategoryStatVO> categoryStats = baseMapper.selectCategoryStats(examId);

    fillCategoryStat(categoryStats);

    vo.setCategoryStats(categoryStats);

    // 组织对比与平均分/分数段同口径:只用已判分答卷

    vo.setOrgStats(buildOrgStats(gradedRecords));

    return vo;

}

三个口径细节:

① 待阅卷答卷不能拉低统计。如果把它们纳入平均分、最高分、分数段和及格人数,会把这些指标全部拉低——明明考得不错的人,因为简答题还没批,看起来像不及格。所以只用单独的 pendingCount 暴露”还有多少份待阅”。

② 通过率的分母是实考人数,不是参考人数。把缺考算进去会让通过率被稀释,那不是教学上关心的指标。

③ 聚合在库内完成(GROUP BY),不把明细捞进内存。这里踩过一个兼容性坑:GROUP BY 时必须显式列出所有非聚合列,MySQL 默认开启 ONLY_FULL_GROUP_BY,漏列会直接报错。

7.1 监考大屏按名单统计,不按答卷

GET /exam/exam/monitor/{examId}

  → 待考 / 考试中 / 已交卷,三者之和 = 参考人数

这里是按参考名单状态聚合,而不是按答卷聚合。因为一个考生可能有多次作答(多份答卷)。按答卷统计会得出「考试中 + 已交卷 > 参考人数」这种让人困惑的数字——监考老师看到会以为系统出 bug 了。

统计分析

图:统计分析,通过率、平均分、分数段与组织对比。

八、小结

这一篇的核心是三次「不下发 / 不纳入」的判断:

  1. 答案不在没考完时下发——三个条件判断,其中一条专门补定时任务的滞后窗口
  2. 待阅卷答卷不纳入成绩统计——否则会把平均分、及格率全部拉低
  3. 回顾页不重算、监控不按答卷统计——同源数据和正确口径比”实时计算”更重要

以及一条贯穿全篇的原则:判分口径必须单点产出。题库保存、自动判分、人工重算三处归一化逻辑一旦分叉,就会出现数字打架,而这类问题极难排查。

下一篇讲 AI 能力怎么嵌进来,以及成本怎么控住。

赞(0)
未经允许不得转载:AI项目博客 » 从零搭建 AI 在线考试系统(四):判分阅卷与统计分析

评论 抢沙发

AI项目博客

专注 AI 工程化落地 —— 覆盖智能体、大模型、Java AI 与 Python 的学习笔记,以及 AI 平台的项目实战记录。

关于本站文章归档

登录

找回密码

注册