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

从零搭建 AI 在线考试系统(二):组卷抽题与参考名单指派

上一篇搭好了工程和数据库。这一篇实现组卷和考生指派——从「题库里按规则抽题生成试卷」到「把考生圈进考场」,每个方法都能直接抄。

一、试卷的两条生成路径

试卷由 exam_paper 的 gen_mode 决定生成方式:

gen_mode 方式 数据来源
1 固定组卷 前端提交题目清单,逐题落 exam_paper_question
2 规则抽题 先落 exam_paper_rule,再逐条规则抽题

两者的产物都落在 exam_paper_question,后续流程完全一致。分流入口:

private void buildAndPersistQuestions(ExamPaperEntity paper, ExamPaperSaveBO bo) {

    PaperGenModeEnum genMode = PaperGenModeEnum.fromCode(paper.getGenMode());

    if (genMode == null) {

        throw new PlatformException("组卷方式不合法:" + paper.getGenMode());

    }

    // 重新生成的前提:先清掉旧的题目与规则明细

    removePaperChildren(List.of(paper.getId()));

    List<ExamPaperQuestionEntity> questions;

    if (genMode == PaperGenModeEnum.FIXED) {

        questions = buildByFixedQuestions(paper.getId(), bo.getFixedQuestions());

    } else {

        questions = buildByRules(paper, bo.getRules());

    }

    examPaperQuestionService.saveBatch(questions);

    refreshSummary(paper, questions);   // 回写题目数、总分,并校验及格分不超总分

}

固定组卷的逻辑很直白——校验题目存在、去重、按提交顺序编号落库:

private List<ExamPaperQuestionEntity> buildByFixedQuestions(Long paperId,

                                                            List<ExamPaperQuestionItemBO> items) {

    if (CollectionUtils.isEmpty(items)) {

        throw new PlatformException("请至少选择一道题目");

    }

    List<Long> questionIds = new ArrayList<>(items.size());

    Set<Long> seen = new HashSet<>();

    for (ExamPaperQuestionItemBO item : items) {

        if (!seen.add(item.getQuestionId())) {

            throw new PlatformException("题目重复选择,题目Id=" + item.getQuestionId());

        }

        questionIds.add(item.getQuestionId());

    }

    loadQuestions(questionIds);   // 校验题目存在且处于启用状态

    List<ExamPaperQuestionEntity> result = new ArrayList<>(items.size());

    int sortNo = 1;

    for (ExamPaperQuestionItemBO item : items) {

        ExamPaperQuestionEntity pq = new ExamPaperQuestionEntity();

        pq.setPaperId(paperId);

        pq.setQuestionId(item.getQuestionId());

        pq.setSortNo(sortNo++);

        pq.setScore(item.getScore());

        result.add(pq);

    }

    return result;

}

示意图

二、规则抽题:核心算法

一条规则描述一个抽题条款:某个知识点、某个题型、某个难度、抽多少道、每题多少分。规则先落库,再执行抽题:

private List<ExamPaperQuestionEntity> buildByRules(ExamPaperEntity paper, List<ExamPaperRuleBO> rules) {

    if (CollectionUtils.isEmpty(rules)) {

        throw new PlatformException("请至少配置一条抽题规则");

    }

    List<ExamPaperRuleEntity> ruleEntities = new ArrayList<>(rules.size());

    int sortNo = 1;

    for (ExamPaperRuleBO rule : rules) {

        ExamPaperRuleEntity ruleEntity = new ExamPaperRuleEntity();

        ruleEntity.setPaperId(paper.getId());

        ruleEntity.setCategoryId(rule.getCategoryId());

        ruleEntity.setQuestionType(rule.getQuestionType());

        ruleEntity.setDifficulty(rule.getDifficulty());

        ruleEntity.setQuestionCount(rule.getQuestionCount());

        ruleEntity.setScorePer(rule.getScorePer());

        ruleEntity.setSortNo(sortNo++);

        ruleEntities.add(ruleEntity);

    }

    examPaperRuleService.saveBatch(ruleEntities);

    return drawByRules(ruleEntities);   // 落完规则立刻抽题定型

}

2.1 抽题主体

private List<ExamPaperQuestionEntity> drawByRules(List<ExamPaperRuleEntity> rules) {

    List<ExamPaperQuestionEntity> result = new ArrayList<>();

    // 跨规则去重:同一道题不能被两条规则同时抽中

    Set<Long> picked = new LinkedHashSet<>();

    int sortNo = 1;

    // 知识点树只加载一次:N 条规则即 N 次全表查询,这里提前避免

    CategoryTree tree = loadCategoryTree();

    for (ExamPaperRuleEntity rule : rules) {

        ExamRandomQuestionQueryBO query = new ExamRandomQuestionQueryBO();

        query.setCategoryId(rule.getCategoryId());

        // 规则选中的是知识点树的某个节点,抽题应覆盖其全部子知识点

        query.setCategoryIds(tree.descendantIds(rule.getCategoryId()));

        query.setQuestionType(rule.getQuestionType());

        query.setDifficulty(rule.getDifficulty());

        query.setLimit(rule.getQuestionCount());

        int need = rule.getQuestionCount();

        // 先算「排除已选题后」的可用题量,再取随机窗口起点

        long available = examQuestionMapper.countAvailableForDraw(query, picked);

        if (available < need) {

            throw new PlatformException("抽题失败:" + describeRule(rule, tree)

                    + " 可用题目不足,需要 " + need + " 道,实际仅 " + available + " 道");

        }

        int offset = available == need

                ? 0

                : ThreadLocalRandom.current().nextInt((int) (available - need + 1));

        List<Long> ids = examQuestionMapper.selectRandomQuestionIds(query, picked, offset);

        if (ids.size() < need) {

            throw new PlatformException("抽题失败:" + describeRule(rule, tree)

                    + " 可用题目不足,需要 " + need + " 道,实际仅 " + ids.size() + " 道");

        }

        for (Long questionId : ids) {

            ExamPaperQuestionEntity pq = new ExamPaperQuestionEntity();

            pq.setPaperId(rule.getPaperId());

            pq.setQuestionId(questionId);

            pq.setSortNo(sortNo++);

            pq.setScore(rule.getScorePer());

            result.add(pq);

            picked.add(questionId);

        }

    }

    return result;

}

三个要点:

① 抽不到就抛错,不”少抽”。如果规则要 10 道、库里只有 7 道就静默抽 7 道,那这份卷子的总分和预期不符——考生答完发现满分不是 100,是最难排查的那类问题。宁可当场失败。

② 用随机窗口替代 ORDER BY RAND()。这是性能上的关键改动。ORDER BY RAND() 要为每一行生成随机值再整体排序,题库上万条时明显变慢。改用「先 COUNT 出可用量 → 取随机 offset → 按主键顺序 LIMIT」,走的是索引区间扫描。

③ picked 同时参与 COUNT 和 SELECT。跨规则去重意味着第二条规则要排除第一条已抽中的题。这里必须保证「算可用量」和「真抽取」用的是同一套排除条件,否则算出来的数字不是真能抽到的数量,随机 offset 会落到非法区间。

2.2 Mapper:共用筛选条件

上一节提到的两点,在 XML 里靠共用 SQL 片段保证:

<!-- 可用题筛选条件:抽题与「可用题量」共用同一段,避免口径分叉 -->

<sql id="availableQuestionFilters">

    t.status = 1

    <if test="bo != null">

        <if test="bo.categoryIds != null and bo.categoryIds.size() > 0">

            AND t.category_id IN

            <foreach collection="bo.categoryIds" item="categoryId" open="(" separator="," close=")">

                #{categoryId}

            </foreach>

        </if>

        <if test="(bo.categoryIds == null or bo.categoryIds.size() == 0) and bo.categoryId != null">

            AND t.category_id = #{bo.categoryId}

        </if>

        <if test="bo.questionType != null">

            AND t.question_type = #{bo.questionType}

        </if>

        <if test="bo.difficulty != null">

            AND t.difficulty = #{bo.difficulty}

        </if>

    </if>

</sql>

<!-- 剔除已被前面规则选中的题目(跨规则去重) -->

<sql id="availableQuestionExclude">

    <if test="excludeIds != null and excludeIds.size() > 0">

        AND t.id NOT IN

        <foreach collection="excludeIds" item="excludedId" open="(" separator="," close=")">

            #{excludedId}

        </foreach>

    </if>

</sql>

<!-- 抽题:按主键顺序取一个窗口(offset 由 Service 随机生成) -->

<select id="selectRandomQuestionIds" resultType="java.lang.Long">

    SELECT t.id

    FROM exam_question t

    WHERE

    <include refid="availableQuestionFilters"/>

    <include refid="availableQuestionExclude"/>

    ORDER BY t.id

    LIMIT #{offset}, #{bo.limit}

</select>

<!-- 组卷专用可用题量:与 selectRandomQuestionIds 完全同条件 -->

<select id="countAvailableForDraw" resultType="java.lang.Long">

    SELECT COUNT(*)

    FROM exam_question t

    WHERE

    <include refid="availableQuestionFilters"/>

    <include refid="availableQuestionExclude"/>

</select>

抽题顺序有序,会让考生看到有序的卷面吗?不会。因为考生进入考试时还会按 shuffle_question 开关整体打乱(见下一篇的组卷快照)。组卷阶段的有序只是为了 LIMIT 窗口能走索引。

2.3 知识点树:BFS 展开子树

规则里选的是知识点树的某个节点。如果只按该节点精确匹配,父节点上通常没有直接挂题,会得到 0 道而误报「题目不足」。所以必须展开整棵子树:

private CategoryTree loadCategoryTree() {

    // 租户拦截器会自动限定到当前租户,不会跨租户取到知识点

    List<ExamCategoryEntity> all = examCategoryMapper.selectList(null);

    Map<Long, List<ExamCategoryEntity>> childrenByParent = all.stream()

            .filter(item -> item.getParentId() != null && item.getId() != null)

            .collect(Collectors.groupingBy(ExamCategoryEntity::getParentId));

    Map<Long, String> nameById = all.stream()

            .filter(item -> item.getId() != null)

            .collect(Collectors.toMap(ExamCategoryEntity::getId, ExamCategoryEntity::getCategoryName,

                    (a, b) -> a));

    return new CategoryTree(childrenByParent, nameById);

}

private record CategoryTree(Map<Long, List<ExamCategoryEntity>> childrenByParent,

                            Map<Long, String> nameById) {

    /** 返回「指定知识点 + 其全部子孙知识点」的 id 集合 */

    private List<Long> descendantIds(Long categoryId) {

        if (categoryId == null) {

            return null;   // 保持调用方的「不限」语义

        }

        Set<Long> ids = new LinkedHashSet<>();

        ids.add(categoryId);

        // BFS 展开整棵子树;重复入队会被 ids.add 挡住,成环也不会死循环

        Deque<ExamCategoryEntity> queue = new ArrayDeque<>(

                childrenByParent.getOrDefault(categoryId, List.of()));

        while (!queue.isEmpty()) {

            ExamCategoryEntity current = queue.poll();

            if (current.getId() != null && ids.add(current.getId())) {

                queue.addAll(childrenByParent.getOrDefault(current.getId(), List.of()));

            }

        }

        return new ArrayList<>(ids);

    }

    private String nameOf(Long categoryId) {

        return categoryId == null ? null : nameById.get(categoryId);

    }

}

题目管理:知识点树与筛选

图:题目管理,左侧知识点树,右侧按题型、难度、来源筛选。

2.4 重新抽题

public void regenerate(Long id) {

    ExamPaperEntity entity = getOrThrow(id, "试卷不存在,ID=" + id);

    if (PaperGenModeEnum.fromCode(entity.getGenMode()) != PaperGenModeEnum.RULE) {

        throw new PlatformException("只有规则抽题的试卷才支持重新抽题");

    }

    // 已被已发布的考试引用时不允许换题,否则考生答的和统计的不是一套

    checkReferencedByPublishedExam(List.of(id));

    List<ExamPaperRuleEntity> rules = listRules(id);

    if (rules.isEmpty()) {

        throw new PlatformException("试卷未配置抽题规则,无法重新抽题");

    }

    removePaperQuestions(List.of(id));

    List<ExamPaperQuestionEntity> questions = drawByRules(rules);

    examPaperQuestionService.saveBatch(questions);

    refreshSummary(entity, questions);

}

试卷列表

图:试卷列表,规则抽题生成的试卷可重新抽题、生成平行卷。

三、平行卷

平行卷(B 卷)的要求是结构对齐——知识点、题型、难度、分值都不变,只换具体题目。做法是逐题替换:

@Override

@Transactional(rollbackFor = Exception.class)

public ExamPaperParallelVO createParallel(Long sourcePaperId, ExamPaperParallelBO bo) {

    ExamPaperEntity source = getOrThrow(sourcePaperId, "试卷不存在,ID=" + sourcePaperId);

    List<ExamPaperQuestionVO> sourceQuestions =

            examPaperQuestionMapper.selectPaperQuestions(sourcePaperId);

    if (CollectionUtils.isEmpty(sourceQuestions)) {

        throw new PlatformException("原试卷没有题目,无法生成平行卷");

    }

    checkNameUnique(bo.getPaperName(), null);

    // 原题的「知识点/题型/难度」是对齐基准

    List<Long> sourceQuestionIds = sourceQuestions.stream()

            .map(ExamPaperQuestionVO::getQuestionId)

            .filter(Objects::nonNull)

            .distinct()

            .toList();

    Map<Long, ExamQuestionEntity> originIndex = examQuestionMapper.selectBatchIds(sourceQuestionIds)

            .stream()

            .filter(entity -> entity.getId() != null)

            .collect(Collectors.toMap(ExamQuestionEntity::getId, entity -> entity, (a, b) -> a));

    ExamPaperEntity paper = new ExamPaperEntity();

    paper.setPaperName(bo.getPaperName().trim());

    // 题目已逐题替换并定型,落为固定卷

    paper.setGenMode(PaperGenModeEnum.FIXED.getCode());

    paper.setPassScore(source.getPassScore());

    paper.setDurationMin(source.getDurationMin());

    paper.setRemark("由《" + source.getPaperName() + "》生成的平行卷");

    paper.setQuestionCount(0);

    paper.setTotalScore(BigDecimal.ZERO);

    paper.setStatus(EnableStatusEnum.ENABLED.getCode());

    save(paper);

    CategoryTree tree = loadCategoryTree();

    // 排除集合承担两件事:不换回原题、不重复使用同一道替换题

    Set<Long> excludeIds = new LinkedHashSet<>(sourceQuestionIds);

    List<ExamPaperQuestionEntity> questions = new ArrayList<>(sourceQuestions.size());

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

    int sortNo = 1;

    for (ExamPaperQuestionVO sourceQuestion : sourceQuestions) {

        ExamQuestionEntity origin = originIndex.get(sourceQuestion.getQuestionId());

        if (origin == null) {

            shortages.add("第 " + sourceQuestion.getSortNo() + " 题的原题已被删除,无法对齐");

            continue;

        }

        Long alternativeId = pickAlternative(origin, tree, excludeIds);

        if (alternativeId == null) {

            shortages.add("第 " + sourceQuestion.getSortNo() + " 题("

                    + describeOrigin(origin, tree) + ")没有可替换的同结构题目");

            continue;

        }

        excludeIds.add(alternativeId);

        ExamPaperQuestionEntity pq = new ExamPaperQuestionEntity();

        pq.setPaperId(paper.getId());

        pq.setQuestionId(alternativeId);

        pq.setSortNo(sortNo++);

        pq.setScore(sourceQuestion.getScore());   // 分值沿用原卷

        questions.add(pq);

    }

    if (!shortages.isEmpty()) {

        // 整卷回滚:部分替换的「平行卷」结构已不对齐,比生成失败更危险

        throw new PlatformException("平行卷生成失败:" + String.join(";", shortages)

                + "。请先补充题库后重试");

    }

    examPaperQuestionService.saveBatch(questions);

    refreshSummary(paper, questions);

    // ... 组装返回 VO(原卷名、新卷 Id、替换题数)

}

/** 为一道原题抽一道「同结构」的替换题 */

private Long pickAlternative(ExamQuestionEntity origin, CategoryTree tree, Set<Long> excludeIds) {

    ExamRandomQuestionQueryBO query = new ExamRandomQuestionQueryBO();

    query.setCategoryIds(tree.descendantIds(origin.getCategoryId()));

    query.setQuestionType(origin.getQuestionType());

    query.setDifficulty(origin.getDifficulty());

    query.setLimit(1);

    long available = examQuestionMapper.countAvailableForDraw(query, excludeIds);

    if (available <= 0) {

        return null;

    }

    // 随机起点,避免每次生成的平行卷都挑中同一批题

    int offset = available == 1 ? 0 : ThreadLocalRandom.current().nextInt((int) available);

    List<Long> ids = examQuestionMapper.selectRandomQuestionIds(query, excludeIds, offset);

    return ids.isEmpty() ? null : ids.get(0);

}

「任一题找不到替换就整卷回滚」是这里最重要的决定。

如果改成「能找到几道换几道」,产出的卷子结构已经不对齐,却还会被当作 B 卷使用——两个考场的难度分布不同,成绩就没法横向比较。宁可让操作者知道”这批题不够出平行卷”,也不能悄悄交付一份有缺陷的卷子。

另外注意:平行卷是纯算法、零 token 成本,不调用大模型。这件事本质是”按条件再查一次”,模型帮不上忙,反而会引入不确定性。

四、参考名单指派

4.1 两种圈人方式

先看按组织批量指派:

@Override

@Transactional(rollbackFor = Exception.class)

public int addCandidatesByOrg(ExamCandidateOrgSaveBO bo) {

    requireDraftExam(bo.getExamId());   // 已发布的考试不能再改名单

    List<Long> orgIds = bo.getOrgIds().stream()

            .filter(Objects::nonNull)

            .distinct()

            .collect(Collectors.toList());

    if (orgIds.isEmpty()) {

        throw new PlatformException("请至少选择一个组织");

    }

    // 组织(含子孙)→ 启用账号;显式带租户,避免跨租户拉人

    List<Long> userIds = baseMapper.selectUserIdsByOrgIds(orgIds, UserUtils.getTenantId());

    if (userIds.isEmpty()) {

        throw new PlatformException("所选组织下没有可指派的启用账号");

    }

    ExamCandidateSaveBO saveBO = new ExamCandidateSaveBO();

    saveBO.setExamId(bo.getExamId());

    saveBO.setUserIds(userIds);

    return addCandidates(saveBO);   // 复用统一的落库逻辑

}

组织树的「含子孙」靠物化路径前缀匹配,一条 SQL 搞定,不用递归查询:

<!--

  按组织(含子孙)取启用账号:sys_user / sys_user_org / sys_org 均未纳入

  租户隔离表,故 tenantId 由 Java 侧显式注入。

  子孙判定:ao.org_path 以「该组织 org_path + ,」为前缀。

-->

<select id="selectUserIdsByOrgIds" resultType="java.lang.Long">

    SELECT DISTINCT u.id

    FROM sys_user u

             JOIN sys_user_org uo ON uo.user_id = u.id

    <where>

        u.status = 1

        <if test="tenantId != null">

            AND u.tenant_id = #{tenantId}

        </if>

        AND (

        <foreach collection="orgIds" item="orgId" separator=" OR ">

            (uo.org_id = #{orgId}

            OR uo.org_path LIKE CONCAT((SELECT o.org_path FROM sys_org o WHERE o.id = #{orgId}), ',%'))

        </foreach>

        )

    </where>

    ORDER BY u.id ASC

</select>

4.2 统一的落库逻辑

两种圈人方式最终都汇到同一个方法:

@Override

@Transactional(rollbackFor = Exception.class)

public int addCandidates(ExamCandidateSaveBO bo) {

    requireDraftExam(bo.getExamId());

    // 1. 去重入参

    List<Long> userIds = bo.getUserIds().stream()

            .filter(Objects::nonNull)

            .distinct()

            .collect(Collectors.toList());

    if (userIds.isEmpty()) {

        throw new PlatformException("请至少选择一名考生");

    }

    // 2. 校验用户可用(存在 + 同租户 + 启用)

    //    批量指派时 userIds 可能远超单条 IN 的长度上限,按 500 分片查询后合并

    Long tenantId = UserUtils.getTenantId();

    List<ExamCandidateUserVO> users = BatchQueryUtils.queryInBatches(

            userIds, batch -> baseMapper.selectUsersByIds(batch, tenantId));

    if (users.size() != userIds.size()) {

        Set<Long> found = users.stream().map(ExamCandidateUserVO::getUserId).collect(Collectors.toSet());

        List<Long> missing = userIds.stream().filter(id -> !found.contains(id)).toList();

        throw new PlatformException("以下用户不存在或不属于当前租户:" + missing);

    }

    List<String> disabled = users.stream()

            .filter(u -> !Objects.equals(1, u.getStatus()))

            .map(u -> StringUtils.hasText(u.getRealName()) ? u.getRealName() : u.getUsername())

            .toList();

    if (!disabled.isEmpty()) {

        throw new PlatformException("以下用户账号非启用状态,不能指派为考生:" + disabled);

    }

    // 3. 剔除已在名单中的人(重复指派不报错,按跳过处理)

    Set<Long> existing = listCandidateUserIds(bo.getExamId());

    List<ExamCandidateUserVO> toAdd = users.stream()

            .filter(u -> !existing.contains(u.getUserId()))

            .toList();

    if (toAdd.isEmpty()) {

        return 0;

    }

    // 4. 落库(姓名取快照,与用户后续改名解耦)

    List<ExamCandidateEntity> entities = new ArrayList<>(toAdd.size());

    for (ExamCandidateUserVO user : toAdd) {

        ExamCandidateEntity entity = new ExamCandidateEntity();

        entity.setExamId(bo.getExamId());

        entity.setUserId(user.getUserId());

        entity.setUserName(StringUtils.hasText(user.getRealName())

                ? user.getRealName() : user.getUsername());

        entity.setStatus(CandidateStatusEnum.PENDING.getCode());

        entities.add(entity);

    }

    saveBatch(entities);

    return entities.size();

}

这里有个容易踩的坑:组织树可能上千人,逐行插入会产生大量数据库往返。所以流程是「先解析出完整用户集合 → 去重 → 校验租户与状态 → 成批写入」,而不是在循环里判断一行插一行。

另外 BatchQueryUtils.queryInBatches 是必要的——按组织拉人时 userIds 可能几千个,单条 IN 会超出 MySQL 的包大小限制或 SQL 长度限制。

4.3 Excel 名单导入

按「工号(优先)/ 登录名」匹配启用账号:

<!-- 查不到返回 null -->

<select id="selectUserIdByAccount" resultType="java.lang.Long">

    SELECT u.id

    FROM sys_user u

    <where>

        u.status = 1

        <if test="tenantId != null">

            AND u.tenant_id = #{tenantId}

        </if>

        <choose>

            <when test="employeeNo != null and employeeNo != ''">

                AND u.employee_no = #{employeeNo}

            </when>

            <otherwise>

                AND u.username = #{username}

            </otherwise>

        </choose>

    </where>

</select>

Excel 导入的关键是错误处理策略:逐行校验并收集错误行号,最后一次性成批指派。不要遇到第一行错误就中断——否则使用者要反复导入、不断修正。把所有问题行一次性列出来,改一轮就能过。

五、随机练习

练习模式(/practice)刻意和正式考试解耦:不建答卷、不入库、不参与成绩统计。但有两点必须复用:

① 抽题复用同一套逻辑,并且按同样口径把知识点展开为「自身 + 子孙」。如果练习的知识点展开逻辑和考试不一样,就会出现「练习覆盖面比考试窄」这种说不清的问题。

② 判题复用同一个 ExamAutoGrader——把题目包装成单题快照后交给判分器。

为什么一定要复用?如果练习另写一套字符串比较逻辑,早晚会出现「练习判对、考试判错」。考生会立刻怀疑整个系统的判分能力,这种信任损失很难挽回。

答案下发策略也做了区分:抽题接口不下发答案,只有逐题提交后才返回该题的答案与解析。不能一次性把整卷答案拉走,否则练习就成了”调接口拿答案”。

六、小结

这一篇实现的四个要点:

  1. 抽题用随机窗口而非 ORDER BY RAND()——先 COUNT 再取随机 offset,走索引避免全表排序
  2. 「可用量」与「真抽取」共用同一段 SQL——口径分叉会让随机 offset 落到非法区间
  3. 平行卷任一题找不到替换就整卷回滚——部分替换的卷子结构不对齐,比生成失败更危险
  4. 圈人先解析再批量写——组织可能上千人,逐行插入的往返开销不可接受

另外两个容易漏的隔离点:sys_org 和 sys_user 这类平台共享表没有纳入租户隔离,凡是直接查它们的地方都必须显式加 tenant_id 条件。

下一篇讲考试进行时:断线续考、交卷防重、防作弊,以及两个真实的实现陷阱。

赞(0)
未经允许不得转载:AI项目博客 » 从零搭建 AI 在线考试系统(二):组卷抽题与参考名单指派

评论 抢沙发

AI项目博客

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

关于本站文章归档

登录

找回密码

注册