智学文枢:AI 产出的持久化之道

variant_repo / analysis_repo:AI 产出的持久化之道

一句话摘要:变式题与分析结果是 AI 引擎的两类核心产出,两个仓库用批量写入、case 排序、create_or_update 幂等更新,把「高吞吐、可重试」的 AI 结果可靠地落库。

一、引言

第一性原理:AI 引擎的调用成本高、时延大、结果不可复现。一次理科分析调用可能耗时十几秒,一次变式生成可能产出 3 道新题。这类「昂贵产出」的持久化策略必须与普通 CRUD 不同:

  1. 批量:AI 一次返回多道变式,必须一次事务批量入库,绝不能逐条提交。
  2. 幂等:分析引擎可能因网络抖动重试,重复写入不能产生重复记录。
  3. 可恢复:题目列表页要知道「哪些题已分析过、每道题有几道变式」,且这个统计必须一次查询完成。

variant_repo.py 与 analysis_repo.py 正是围绕这三个需求设计的。

二、核心原理

2.1 变式列表的「业务序」:case 表达式

变式训练页(第 60 篇)的展示顺序是固定的:降难度(-1)→ 同难度(0)→ 升难度(+1)。这不是字典序,也不是入库序,而是业务定义的语义序。

用 ORDER BY difficulty_delta 会得到 +1 < -1 < 0 的错误顺序(字符串比较),因此仓库用 case 把三个难度档映射成 0/1/2:

python 复制代码
order_expr = case(
    (Variant.difficulty_delta == "-1", 0),
    (Variant.difficulty_delta == "0", 1),
    (Variant.difficulty_delta == "+1", 2),
    else_=3,
)

源码位置:data/repositories/variant_repo.py L14-L19

case 是 SQL 的 CASE WHEN 的 ORM 表达,在数据库端完成排序,而不是把数据拉回 Python 再 sort------数据量不大时两者皆可,但「排序逻辑内聚在仓库」让 UI 层永远拿到正确顺序。

2.2 批量的三件套

变式场景有三个批量操作:批量计数(列表页徽标)、批量统计(分析状态)、批量创建(变式生成)。

方法 所在仓库 SQL 本质 解决的问题
batch_get_variant_counts variant_repo GROUP BY + COUNT 每道题有几道变式(徽标)
batch_get_analyzed_question_ids analysis_repo WHERE id IN (...) 哪些题已有分析(打勾)
batch_create variant_repo 循环 add + 一次 commit 多道变式一次入库

三个方法的共同点是一个 IN/GROUP BY 查询取代 N 次单查,把列表页的复杂度从 O(N) 查询降为 O(1) 查询 + 内存映射。

三、代码实现

3.1 变式计数:GROUP BY 一次成型

python 复制代码
def batch_get_variant_counts(self, question_ids: list) -> dict:
    """批量查询每道原题的变式题数量"""
    from sqlalchemy import func, select as sa_select
    rows = self.session.execute(
        sa_select(
            Variant.original_question_id,
            func.count(Variant.id)
        ).where(
            Variant.original_question_id.in_(question_ids)
        ).group_by(Variant.original_question_id)
    ).all()
    return {row[0]: row[1] for row in rows}

源码位置:data/repositories/variant_repo.py L26-L37

返回 {原题ID: 变式数} 字典,题目列表页拿到后直接 counts.get(q.id, 0) 渲染徽标,未生成变式的题自然得到 0。

3.2 批量创建:一次事务,整体提交

python 复制代码
def batch_create(self, variants: List[Variant]) -> List[Variant]:
    """批量创建变式题"""
    for v in variants:
        self.session.add(v)
    self.session.commit()
    for v in variants:
        self.session.refresh(v)
    return variants

源码位置:data/repositories/variant_repo.py L39-L46

add 逐条加入 session(仅内存排队),commit() 一次性发往数据库------要么全部成功,要么全部回滚,天然满足 AI 多产出的事务原子性。refresh 让对象带上数据库默认值(如 created_at)。

3.3 分析结果:create_or_update 幂等更新

分析结果与题目是一对一(question_id UNIQUE),AI 重试时不能新建第二条:

python 复制代码
def create_or_update(self, analysis: AIAnalysis) -> AIAnalysis:
    """创建或更新分析结果(基于question_id唯一约束)"""
    existing = self.get_by_question(analysis.question_id)
    if existing:
        # 更新已有记录
        for col in ["analysis_type", "raw_response", "step_by_step",
                    "core_concept", "knowledge_gap", "contextual_memory",
                    "memory_mnemonic", "remediation_suggestion",
                    "difficulty_assessment", "common_mistake",
                    "cross_subject_link", "has_teaching_talk", "teaching_talk",
                    "teaching_talk_model", "teaching_talk_generated_at"]:
            setattr(existing, col, getattr(analysis, col, None))
        self.session.commit()
        self.session.refresh(existing)
        return existing
    else:
        return self.create(analysis)

源码位置:data/repositories/analysis_repo.py L28-L45

两个细节值得品味:

  • 字段白名单 :15 个字段显式列出,而非 existing.__dict__ = analysis.__dict__ 全量覆盖。后者会连带复制 id、question_id、generated_at 等不该被 AI 结果覆盖的字段。
  • getattr(analysis, col, None) 容错:新返回结果缺某个字段时写入 None,而不是 KeyError 崩溃。分析引擎版本迭代时老字段优雅降级。

3.4 分析状态的批量查询

python 复制代码
def batch_get_analyzed_question_ids(self, question_ids: list) -> set:
    """批量查询哪些习题已有分析结果"""
    from sqlalchemy import select as sa_select
    rows = self.session.execute(
        sa_select(AIAnalysis.question_id).where(
            AIAnalysis.question_id.in_(question_ids)
        )
    ).all()
    return {row[0] for row in rows}

源码位置:data/repositories/analysis_repo.py L18-L26

返回 set 而非 dict,语义是「命中集合」,题目列表页用 q.id in analyzed_set 判断是否显示「重新分析」按钮。集合天然去重,与「是否有分析」的布尔语义完全匹配。

3.5 AI 引擎落库的完整时序

SQLite Session AnalysisRepository / VariantRepository 分析/变式引擎 SQLite Session AnalysisRepository / VariantRepository 分析/变式引擎 #mermaid-svg-7UuK3QsWZpIicn9Z{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7UuK3QsWZpIicn9Z .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7UuK3QsWZpIicn9Z .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7UuK3QsWZpIicn9Z .error-icon{fill:#552222;}#mermaid-svg-7UuK3QsWZpIicn9Z .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7UuK3QsWZpIicn9Z .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7UuK3QsWZpIicn9Z .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7UuK3QsWZpIicn9Z .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7UuK3QsWZpIicn9Z .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7UuK3QsWZpIicn9Z .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7UuK3QsWZpIicn9Z .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7UuK3QsWZpIicn9Z .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7UuK3QsWZpIicn9Z .marker.cross{stroke:#333333;}#mermaid-svg-7UuK3QsWZpIicn9Z svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7UuK3QsWZpIicn9Z p{margin:0;}#mermaid-svg-7UuK3QsWZpIicn9Z .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7UuK3QsWZpIicn9Z text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-7UuK3QsWZpIicn9Z .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-7UuK3QsWZpIicn9Z .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-7UuK3QsWZpIicn9Z .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-7UuK3QsWZpIicn9Z .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-7UuK3QsWZpIicn9Z #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-7UuK3QsWZpIicn9Z .sequenceNumber{fill:white;}#mermaid-svg-7UuK3QsWZpIicn9Z #sequencenumber{fill:#333;}#mermaid-svg-7UuK3QsWZpIicn9Z #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-7UuK3QsWZpIicn9Z .messageText{fill:#333;stroke:none;}#mermaid-svg-7UuK3QsWZpIicn9Z .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7UuK3QsWZpIicn9Z .labelText,#mermaid-svg-7UuK3QsWZpIicn9Z .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-7UuK3QsWZpIicn9Z .loopText,#mermaid-svg-7UuK3QsWZpIicn9Z .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-7UuK3QsWZpIicn9Z .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-7UuK3QsWZpIicn9Z .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-7UuK3QsWZpIicn9Z .noteText,#mermaid-svg-7UuK3QsWZpIicn9Z .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-7UuK3QsWZpIicn9Z .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7UuK3QsWZpIicn9Z .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7UuK3QsWZpIicn9Z .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7UuK3QsWZpIicn9Z .actorPopupMenu{position:absolute;}#mermaid-svg-7UuK3QsWZpIicn9Z .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-7UuK3QsWZpIicn9Z .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7UuK3QsWZpIicn9Z .actor-man circle,#mermaid-svg-7UuK3QsWZpIicn9Z line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-7UuK3QsWZpIicn9Z :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt已存在不存在 create_or_update(analysis)get_by_question 查重白名单字段 setattr + commitadd + commit + refreshBEGIN / UPDATE or INSERT / COMMITbatch_create(v1, v2, v3)add x3 + 一次 commitBEGIN / INSERT x3 / COMMIT

四、设计权衡 / 踩坑记录

1. 为什么「分析」用 create_or_update 而「变式」用批量新建

分析结果与题目是一对一,语义是「覆盖最新结果」;变式是「一个原题多条变式」的一对多,语义是「追加」。若把变式也做成 upsert,反而需要额外业务键(原题ID + 难度 + 文本)判断重复,复杂度翻倍。建模时的一对一/一对多选择,直接决定了仓库 API 形态。

2. case 排序的魔法数字

0/1/2/3 是业务排序权重,若未来新增难度档(如 ±2),需要同步改 case。可以用常量表代替,但项目变式难度固定三档,魔法数字换来的简洁是划算的。

3. 白名单列清单的维护成本

create_or_update 的 15 个字段与模型新增字段必须手工同步。分析引擎每次加字段(如教学讲解 talk 系列)都要更新此清单------这是「显式优于隐式」的代价,好在字段演进不频繁(本清单跨两个大版本只扩了一次)。

4. 批量统计的边界:超大 IN 列表

IN (...) 列表过长(上千条)时 SQLite 会有参数上限问题。桌面应用单学生题目量级(数百)远未触及,但批量统计方法统一放在仓库层,未来若需要分页切片,改动点只有一个。

五、小结

  • variant_repo 用 case 表达式实现「降→平→升」的业务排序,batch_create 一次事务批量入库 AI 产出的多道变式。
  • batch_get_variant_counts 以 GROUP BY 一次查询产出全部徽标数据,消灭列表页 N+1。
  • analysis_repo 的 create_or_update 用「字段白名单 + 幂等键」支撑 AI 重试与结果覆盖,batch_get_analyzed_question_ids 以集合语义服务「是否已分析」判断。
  • 批量与幂等是 AI 类仓库的两大设计主轴,二者共同保证昂贵的 AI 产出可靠、可重复地落库。
相关推荐
CaptainT1 小时前
第 1 卷:执行模型与基础语法
python
总裁余(余登武)1 小时前
K-means算法改进(Python版)
python·算法·kmeans
CaptainT1 小时前
第 2 卷:函数、模块与推导式
python
Shockang1 小时前
项目驱动的 Python 入门
python
余槐i2 小时前
transformers 升级后加载旧权重报错:三处不兼容如何逐一验证
python·大模型·transformers·模型量化·模型推理
CCblandy2 小时前
向量检索 ANN 参数实测:换一种数据分布,recall 掉了 51 个百分点
人工智能·python·算法·自然语言处理·性能优化
云和数据.ChenGuang3 小时前
print(f“a+b=“,result) print(f“a+b={result}“)区别
java·前端·人工智能·python·深度学习
2601_962885723 小时前
如何用 Python 算最大回撤和回撤恢复期?
java·数据库·python
Want5953 小时前
Python大雪纷飞①
开发语言·python·数学建模