variant_repo / analysis_repo:AI 产出的持久化之道
一句话摘要:变式题与分析结果是 AI 引擎的两类核心产出,两个仓库用批量写入、
case排序、create_or_update幂等更新,把「高吞吐、可重试」的 AI 结果可靠地落库。
一、引言
第一性原理:AI 引擎的调用成本高、时延大、结果不可复现。一次理科分析调用可能耗时十几秒,一次变式生成可能产出 3 道新题。这类「昂贵产出」的持久化策略必须与普通 CRUD 不同:
- 批量:AI 一次返回多道变式,必须一次事务批量入库,绝不能逐条提交。
- 幂等:分析引擎可能因网络抖动重试,重复写入不能产生重复记录。
- 可恢复:题目列表页要知道「哪些题已分析过、每道题有几道变式」,且这个统计必须一次查询完成。
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 产出可靠、可重复地落库。