AI 工具

DeepLearning.AI:看完短课容易忘:把自己的笔记做成间隔重复卡片是个好办法

看完短课容易忘。把自己的笔记做成间隔重复卡片是个好办法。 这篇做 toanki.py:把 Markdown 里 Q: / A: / Tags: 格式的笔记转成 Anki 可以直接导入的文本文件(带文件头,自动选好分隔符、牌组和标签列),先检查重复和空答案;再用 Anki 官方的 Python 包在一个临时收藏里真实导入一次,确认 Anki 能正确识别。

BINBHO · 图文教程

看完短课容易忘。把自己的笔记做成间隔重复卡片是个好办法。 这篇做 to_anki.py:把 Markdown 里 Q: / A: / Tags: 格式的笔记转成 Anki 可以直接导入的文本文件(带文件头,自动选好分隔符、牌组和标签列),先检查重复和空答案;再用 Anki 官方的 Python 包在一个临时收藏里真实导入一次,确认 Anki 能正确识别。

先看清成品

  • 用 Anki 官方 Python 包(anki 26.09.3)真实导入:5 张卡片、标签、牌组 学习笔记::RAG 和多行答案都正确。
  • 真实测试重复导入:同一文件再导入 0 新增 5 重复;改一个答案再导入,在原笔记上更新,但计数在 first_field_match,不在 updated。
  • 发现并修复:笔记格式错时脚本直接抛 Traceback。5 项 pytest 通过。

合规提醒:DeepLearning.AI 的课程视频、Notebook 和讲义受版权保护,按其使用条款只供个人学习。本文不复制、不抓取任何课程内容,只把课程讲到的方法用自己写的数据和代码重新做一遍。课程是否免费、证书规则以 deeplearning.ai 页面为准。卡片内容用自己的话写,不要大段复制课程讲义。

运行说明:全部在 box 上真实运行(2026-10-21 21:52 UTC+8 左右)。导入验证用的是 Anki 官方发布在 PyPI 的 anki 包和一个临时收藏,我没有在 Anki 桌面版里手动点导入。

做完之后你会得到什么?

成果 说明
my_notes.md 示例笔记(我写的)
to_anki.py 转换 + 检查
cards.txt 生成的导入文件
verify_import.py 用官方 anki 包试导入
test_to_anki.py 5 项测试

需要准备哪些环境?

项目 说明
Python 3.9 以上,pip install pytest
Anki 桌面版 2.1.54 以上(文件头功能从这个版本开始支持)
可选 pip install anki 用于自动验证

关键事实

事实 来源
文本文件必须 UTF-8;Anki 按第一条非注释行判断字段数 Anki 手册 Text Files
文件头(2.1.54+):#separator、#html、#tags、#columns、#deck、#tags column 等,每个一行,放在文件最前面 同上
html 打开时用 <br> 换行;尖括号和 & 要转义 同上
Anki 用第一个字段判断重复;默认会用文件内容更新已有笔记 同上

文件结构

87-deeplearning-ai-C-src/
├── my_notes.md
├── to_anki.py
├── cards.txt
├── verify_import.py
└── test_to_anki.py

第 1 步:笔记格式

# RAG 学习笔记(自己的话)

Q: 句子窗口检索的思路是什么?
A: 用单个句子做检索单位,命中后把前后几句一起交给模型,兼顾检索精度和上下文。
Tags: rag 检索

Q: RAG 三元组指哪三项?
A: 上下文相关性(Context Relevance)、有据性(Groundedness)、答案相关性(Answer Relevance)。
Tags: rag 评估

Q: 为什么固定长度切块会出问题?
A: 可能把一个完整信息切成两半,比如把 IP 地址 192.168.31.20 切到两个块里。
Tags: rag

Q: 提示词的两条原则?
A: 1. 写清楚、具体的指令;
2. 给模型时间“思考”。
Tags: prompt

Q: 分隔符有什么用?
A: 把要处理的文本和指令分开,比如用 ```三个反引号``` 或 <tag></tag>,减少把输入当成指令的情况。
Tags: prompt 安全

第 2 步:转换脚本

"""把自己的 Q/A 笔记转成 Anki 可导入的纯文本文件(UTF-8,Tab 分隔,带文件头)。
格式依据 Anki 手册“Text Files”:文件头 #separator / #html / #columns / #deck / #tags column(2.1.54+);
多行内容在 html:true 时用 <br>;尖括号和 & 需转义;Anki 用第一个字段判断重复,所以 Front 必须唯一。"""
import argparse, html, re, sys


def parse(md):
    cards, cur, key = [], None, None
    for line in md.splitlines():
        m = re.match(r"^(Q|A|Tags):\s*(.*)$", line)
        if m:
            key = m.group(1)
            if key == "Q":
                cur = {"Q": m.group(2), "A": "", "Tags": ""}; cards.append(cur)
            elif cur is None:
                raise ValueError(f"{key}: 出现在任何 Q: 之前")
            else:
                cur[key] = m.group(2)
        elif cur and key == "A" and line.strip():
            cur["A"] += "\n" + line            # 答案可以多行
        elif not line.strip():
            key = None
    return cards


def field(s):
    # 先转义 < > &,再把换行变成 <br>;Tab 是分隔符,替换成空格
    return html.escape(s.strip(), quote=False).replace("\t", " ").replace("\n", "<br>")


def check(cards):
    errs, seen = [], {}
    for i, c in enumerate(cards, 1):
        if not c["Q"].strip(): errs.append(f"第{i}张没有问题")
        if not c["A"].strip(): errs.append(f"第{i}张没有答案:{c['Q'][:20]}")
        k = c["Q"].strip()
        if k in seen: errs.append(f"第{i}张和第{seen[k]}张问题相同,导入时会被当成重复(Anki 按第一个字段判重)")
        seen.setdefault(k, i)
    return errs


def render(cards, deck):
    head = ["#separator:tab", "#html:true", f"#deck:{deck}", "#columns:Front\tBack\tTags", "#tags column:3"]
    rows = [f"{field(c['Q'])}\t{field(c['A'])}\t{c['Tags'].strip()}" for c in cards]
    return "\n".join(head + rows) + "\n"


def main(argv=None):
    ap = argparse.ArgumentParser()
    ap.add_argument("notes"); ap.add_argument("-o", "--out", default="cards.txt")
    ap.add_argument("--deck", default="学习笔记::RAG")
    a = ap.parse_args(argv)
    try:
        cards = parse(open(a.notes, encoding="utf-8").read())
    except ValueError as e:
        print("✗ 笔记格式错误:", e, file=sys.stderr); return 1
    errs = check(cards)
    for e in errs: print("✗", e, file=sys.stderr)
    if errs: return 1
    open(a.out, "w", encoding="utf-8", newline="\n").write(render(cards, a.deck))
    print(f"{len(cards)} 张卡片 → {a.out}(在 Anki 里 文件 → 导入,选这个文件)")
    return 0


if __name__ == "__main__":
    sys.exit(main())

第 3 步:生成的文件

#separator:tab
#html:true
#deck:学习笔记::RAG
#columns:Front	Back	Tags
#tags column:3
句子窗口检索的思路是什么?	用单个句子做检索单位,命中后把前后几句一起交给模型,兼顾检索精度和上下文。	rag 检索
RAG 三元组指哪三项?	上下文相关性(Context Relevance)、有据性(Groundedness)、答案相关性(Answer Relevance)。	rag 评估
为什么固定长度切块会出问题?	可能把一个完整信息切成两半,比如把 IP 地址 192.168.31.20 切到两个块里。	rag
提示词的两条原则?	1. 写清楚、具体的指令;<br>2. 给模型时间“思考”。	prompt
分隔符有什么用?	把要处理的文本和指令分开,比如用 ```三个反引号``` 或 &lt;tag&gt;&lt;/tag&gt;,减少把输入当成指令的情况。	prompt 安全

第 4 步:导入验证

"""用 Anki 官方的 Python 包(pip install anki)在一个临时的新收藏里真实导入 cards.txt,确认文件头被识别、卡片数和字段正确。
不碰你自己的 Anki 收藏。"""
import os, sys, tempfile
from anki.collection import Collection, ImportCsvRequest

path = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "cards.txt")
with tempfile.TemporaryDirectory() as d:
    col = Collection(os.path.join(d, "test.anki2"))
    md = col.get_csv_metadata(path=path, delimiter=None)
    print("识别到:分隔符", md.Delimiter.Name(md.delimiter), "| html", md.is_html, "| 列", list(md.column_labels), "| 牌组 id", md.deck_id)
    res = col.import_csv(ImportCsvRequest(path=path, metadata=md))
    print("新增笔记:", len(res.log.new), "| 重复:", len(res.log.duplicate), "| 按第一字段匹配并更新:", len(res.log.first_field_match))
    res2 = col.import_csv(ImportCsvRequest(path=path, metadata=md))
    print("同一文件再导入一次 → 新增", len(res2.log.new), "| 按第一字段更新", len(res2.log.first_field_match), "| 重复", len(res2.log.duplicate))
    for nid in col.find_notes(""):
        n = col.get_note(nid)
        print(" ", col.decks.name(col.get_card(n.card_ids()[0]).did), "|", n.fields[0][:18], "|", n.fields[1][:24], "|", n.tags)
    col.close()

第 5 步:测试

import to_anki as t

def test_parse_multiline_and_tags():
    c = t.parse("Q: 问\nA: 第一行\n第二行\nTags: a b\n")
    assert c == [{"Q": "问", "A": "第一行\n第二行", "Tags": "a b"}]

def test_field_escapes_html_and_tab():
    assert t.field("a<b>&c\td\ne") == "a&lt;b&gt;&amp;c d<br>e"

def test_check_dupes_and_empty():
    errs = t.check(t.parse("Q: x\nA: 1\n\nQ: x\nA: 2\n\nQ: y\n"))
    assert any("重复" in e for e in errs) and any("没有答案" in e for e in errs)

def test_render_headers():
    out = t.render(t.parse("Q: q\nA: a\nTags: k\n"), "D::E").splitlines()
    assert out[:5] == ["#separator:tab", "#html:true", "#deck:D::E", "#columns:Front\tBack\tTags", "#tags column:3"]
    assert out[5] == "q\ta\tk"

def test_answer_before_question_rejected():
    import pytest
    with pytest.raises(ValueError): t.parse("A: 孤儿\n")

第 6 步:运行

$ python to_anki.py my_notes.md
5 张卡片 → cards.txt(在 Anki 里 文件 → 导入,选这个文件)
$ python verify_import.py
识别到:分隔符 TAB | html True | 列 ['Front', 'Back', 'Tags'] | 牌组 id 0
新增笔记: 5 | 重复: 0 | 按第一字段匹配并更新: 0
同一文件再导入一次 → 新增 0 | 按第一字段更新 0 | 重复 5
  学习笔记::RAG | 句子窗口检索的思路是什么? | 用单个句子做检索单位,命中后把前后几句一起交给模 | ['rag', '检索']
  学习笔记::RAG | RAG 三元组指哪三项? | 上下文相关性(Context Relevance | ['rag', '评估']
  学习笔记::RAG | 为什么固定长度切块会出问题? | 可能把一个完整信息切成两半,比如把 IP 地址  | ['rag']
  学习笔记::RAG | 提示词的两条原则? | 1. 写清楚、具体的指令;<br>2. 给模型时 | ['prompt']
  学习笔记::RAG | 分隔符有什么用? | 把要处理的文本和指令分开,比如用 ```三个反引 | ['prompt', '安全']

# 检查功能
$ python to_anki.py bad2.md    # 两个相同问题 + 一个空答案
✗ 第2张和第1张问题相同,导入时会被当成重复(Anki 按第一个字段判重)
✗ 第3张没有答案:空答案
$ python to_anki.py bad1.md    # 答案出现在问题之前
✗ 笔记格式错误: A: 出现在任何 Q: 之前
$ python -m pytest -q
5 passed

怎么检查结果?

  • 在 Anki 桌面版:文件 → 导入,选 cards.txt,预览里应该已经选好 Tab 分隔、允许 HTML、牌组和标签列,不用手动调。
  • 第 4 张卡片的答案显示为两行。
  • 第 5 张卡片里的 <tag></tag> 原样显示,没有被当成 HTML 吞掉。

调试示例

1. 修改笔记后再导入,“更新”数是 0,但内容确实更新了。 我把第一张卡的答案改了几个字,在同一个收藏里再导入:

cards.txt      新增 5 更新 0 重复 0 | dupe_resolution 0
cards_edit.txt 新增 0 更新 0 重复 4 | dupe_resolution 0

一开始以为没更新。列出日志的全部字段:duplicate 4、first_field_match 1、found_notes 5;再读出笔记,答案已经是新内容。也就是说,在这个版本里按第一个字段匹配并更新的笔记记在 first_field_match 里,不在 updated 里。验证脚本因此改为打印 first_field_match。这和手册说的“默认会更新已有笔记”一致,只是统计字段名和我猜的不一样。

2. 格式错误时直接抛 Traceback。 用一个以 A: 开头的文件测试,屏幕上是一整段 Python 调用栈。改为在 main 里捕获 ValueError,打印一行“笔记格式错误”并返回 1。

3. 为什么不能随便改问题。 Anki 按第一个字段(这里是问题)判断是不是同一张卡。如果改了问题的措辞,再导入就会变成一张新卡,旧卡和学习记录还在。要改问题,最好在 Anki 里直接改。

常见问题

现象 原因 处理
中文乱码 文件不是 UTF-8 本脚本固定写 UTF-8
全部挤在一个字段 分隔符没识别 确认文件头 #separator:tab 在最前面
出现重复卡 改了问题的措辞 在 Anki 里改问题

原样显示
导入时没允许 HTML 文件头已有 #html:true;手动导入时勾选

模板规则

  • 问题唯一,作为判重的依据。
  • 用文件头固定导入设置。
  • 先检查再生成。
  • 用自己的话写卡片。

项目资源

用途 地址
DeepLearning.AI 短课列表 https://www.deeplearning.ai/short-courses/
DeepLearning.AI 使用条款 https://www.deeplearning.ai/terms-of-use/
Anki 手册:导入文本文件 https://docs.ankiweb.net/importing/text-files.html
anki(PyPI,官方 Python 包) https://pypi.org/project/anki/
Building and Evaluating Advanced RAG 课程页 https://www.deeplearning.ai/short-courses/building-evaluating-advanced-rag/

每学完一节课,花五分钟写几张卡,比重看一遍视频有效。