[Script/Idea] Cards Smart Analysis Addon: Auto-tag "Bad" Cards based on Difficulty, Lapse Rate & Time

Hi everyone,

I have been using FSRS for a while now, and I realized that the default Anki “Leech” handling (suspend after X lapses) is a bit too blunt. Sometimes a card is difficult, but not worth suspending. Conversely, some cards aren’t technically “leeches” (low lapse count), but they are “time sinks” (I get them right, but it takes me 30 seconds every time).

I wanted a way to automatically analyze and tag cards that need improvement based on the rich data FSRS provides, without simply suspending them.

I wrote a Python script (runnable as a local add-on) that analyzes cards based on multiple dimensions. It features a GUI to configure thresholds and select specific decks.

Key Features:

  1. FSRS Difficulty: Tags cards that the algorithm rates as extremely difficult (e.g., D > 0.95).
  2. Lapse Rate: Instead of just absolute lapse counts, it calculates the percentage of lapses. (e.g., 5 lapses in 100 reviews is fine; 5 lapses in 6 reviews is bad).
  3. Time Sinks: Detects cards with a high average answer time (e.g., > 20s), suggesting they need to be broken down.
  4. The “Safety Valve”: This is my favorite part. If a card has high difficulty or lapses in the past, but its current FSRS Stability is high (e.g., > 60 days), the script ignores it. This means you finally learned it, so it shouldn’t be flagged.
  5. Deck Selection: You can choose to analyze “All Decks” or target a specific sub-deck.

Disclaimer:

This is currently just a “Proof of Concept” script. It works on my machine (Anki 23.10+ with FSRS enabled), but it has not been extensively tested. There might be bugs. Please back up your collection before running it, and use it at your own risk.

How to install:

  1. Open Anki → Tools → Add-ons → View Files.
  2. Create a new folder named FSRS_Smart_Analysis.
  3. Create a file named __init__.py inside that folder.
  4. Paste the code below into that file.
  5. Restart Anki. You will see a new menu: Tools → FSRS Smart Analysis.

I’m sharing this here in case anyone finds it useful or wants to improve upon it.

The Code:

# -*- coding: utf-8 -*-
"""
FSRS Smart Card Analysis (V3.0)
Function: Analyzes card quality based on FSRS metrics, Lapse Rate, and Time.
Support: Deck selection and GUI configuration.
"""

from aqt import mw
from aqt.utils import showInfo, tooltip, askUser
from aqt.qt import *
import json

# Config key for storing settings
CONFIG_KEY = "fsrs_smart_analysis_config"

# Default Configuration
DEFAULT_CONF = {
    "target_deck": "All Decks", 
    "tag_name": "Leech::SmartAnalysis",
    
    # Dimension 1: FSRS Difficulty (0.0 - 1.0)
    "enable_difficulty": True,
    "threshold_difficulty": 0.95, 
    
    # Dimension 2: Lapse Rate (Lapses / Reps)
    "enable_lapse_rate": True,
    "threshold_lapse_rate": 0.30, # 30% error rate
    "min_reps_for_rate": 5,       # Minimum reps required to calculate rate
    
    # Dimension 3: Absolute Lapses (Hard limit)
    "enable_max_lapses": True,
    "threshold_max_lapses": 8,
    
    # Dimension 4: Average Time (seconds)
    "enable_avg_time": True,
    "threshold_avg_time": 20,
    
    # Dimension 5: Total Reps (Fatigue detector)
    "enable_total_reps": True,
    "threshold_total_reps": 30,
    
    # Safety Valve (Exemptions)
    "enable_safety_valve": True,
    "safety_valve_days": 60 # If Stability > 60 days, do not tag
}

def get_config():
    # Load config, fill missing keys with defaults
    conf = mw.col.conf.get(CONFIG_KEY, DEFAULT_CONF)
    for k, v in DEFAULT_CONF.items():
        if k not in conf:
            conf[k] = v
    return conf

def save_config(conf):
    mw.col.conf[CONFIG_KEY] = conf
    mw.col.setMod() # Mark collection as modified

# ================== GUI Settings Class ==================

class ConfigDialog(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle("FSRS Smart Analysis - Settings")
        self.setMinimumWidth(450)
        self.conf = get_config()
        self.initUI()

    def initUI(self):
        layout = QVBoxLayout()
        
        # --- Scope Selection ---
        grp_scope = QGroupBox("Analysis Scope")
        form_scope = QFormLayout()

        # Deck Dropdown
        self.combo_deck = QComboBox()
        self.combo_deck.addItem("All Decks")
        
        # Get all deck names
        all_decks = mw.col.decks.all_names_and_ids()
        deck_names = sorted([d.name for d in all_decks])
        self.combo_deck.addItems(deck_names)
        
        # Restore last selection
        current_deck = self.conf.get("target_deck", "All Decks")
        index = self.combo_deck.findText(current_deck)
        if index >= 0:
            self.combo_deck.setCurrentIndex(index)
        else:
            self.combo_deck.setCurrentIndex(0)

        form_scope.addRow("<b>Target Deck:</b>", self.combo_deck)

        # Tag Name Input
        self.tag_input = QLineEdit(self.conf["tag_name"])
        self.tag_input.setPlaceholderText("e.g. Leech::SmartCheck")
        form_scope.addRow("<b>Tag to add:</b>", self.tag_input)
        
        grp_scope.setLayout(form_scope)
        layout.addWidget(grp_scope)
        
        # --- Criteria ---
        grp_criteria = QGroupBox("Criteria (Tag if ANY condition is met)")
        grid = QGridLayout()
        row = 0

        # FSRS Difficulty
        self.chk_d = QCheckBox("High FSRS Difficulty")
        self.chk_d.setChecked(self.conf["enable_difficulty"])
        self.spin_d = QDoubleSpinBox()
        self.spin_d.setRange(0.1, 1.0)
        self.spin_d.setSingleStep(0.05)
        self.spin_d.setValue(self.conf["threshold_difficulty"])
        self.spin_d.setToolTip("Range 0.0-1.0. (0.95 = FSRS Difficulty 9.5/10)")
        
        grid.addWidget(self.chk_d, row, 0)
        grid.addWidget(QLabel("Threshold >"), row, 1)
        grid.addWidget(self.spin_d, row, 2)
        row += 1

        # Lapse Rate
        self.chk_rate = QCheckBox("High Lapse Rate (%)")
        self.chk_rate.setChecked(self.conf["enable_lapse_rate"])
        self.spin_rate = QDoubleSpinBox()
        self.spin_rate.setRange(0.05, 1.0)
        self.spin_rate.setSingleStep(0.05)
        self.spin_rate.setValue(self.conf["threshold_lapse_rate"])
        self.spin_rate.setToolTip("e.g., 0.3 means > 30% wrong answers.")
        
        grid.addWidget(self.chk_rate, row, 0)
        grid.addWidget(QLabel("Rate >"), row, 1)
        grid.addWidget(self.spin_rate, row, 2)
        row += 1

        # Absolute Lapses
        self.chk_lapses = QCheckBox("Absolute Lapses (Count)")
        self.chk_lapses.setChecked(self.conf["enable_max_lapses"])
        self.spin_lapses = QSpinBox()
        self.spin_lapses.setRange(1, 100)
        self.spin_lapses.setValue(self.conf["threshold_max_lapses"])
        
        grid.addWidget(self.chk_lapses, row, 0)
        grid.addWidget(QLabel("Count >"), row, 1)
        grid.addWidget(self.spin_lapses, row, 2)
        row += 1

        # Avg Time
        self.chk_time = QCheckBox("Avg Answer Time")
        self.chk_time.setChecked(self.conf["enable_avg_time"])
        self.spin_time = QSpinBox()
        self.spin_time.setRange(5, 300)
        self.spin_time.setValue(self.conf["threshold_avg_time"])
        self.spin_time.setSuffix(" s")
        
        grid.addWidget(self.chk_time, row, 0)
        grid.addWidget(QLabel("Time >"), row, 1)
        grid.addWidget(self.spin_time, row, 2)
        row += 1
        
        # Total Reps
        self.chk_reps = QCheckBox("Total Review Count")
        self.chk_reps.setChecked(self.conf["enable_total_reps"])
        self.spin_reps = QSpinBox()
        self.spin_reps.setRange(10, 999)
        self.spin_reps.setValue(self.conf["threshold_total_reps"])

        grid.addWidget(self.chk_reps, row, 0)
        grid.addWidget(QLabel("Count >"), row, 1)
        grid.addWidget(self.spin_reps, row, 2)
        row += 1

        grp_criteria.setLayout(grid)
        layout.addWidget(grp_criteria)
        
        # --- Safety Valve ---
        grp_safety = QGroupBox("Safety Valve (Exemptions)")
        h_layout = QHBoxLayout()
        self.chk_safety = QCheckBox("Ignore High Stability Cards")
        self.chk_safety.setChecked(self.conf["enable_safety_valve"])
        self.chk_safety.setToolTip("If a card has a long stability interval, you have learned it. Don't tag it.")
        
        self.spin_safety = QSpinBox()
        self.spin_safety.setRange(10, 3650)
        self.spin_safety.setValue(self.conf["safety_valve_days"])
        self.spin_safety.setSuffix(" Days")
        
        h_layout.addWidget(self.chk_safety)
        h_layout.addWidget(QLabel("Stability >"))
        h_layout.addWidget(self.spin_safety)
        grp_safety.setLayout(h_layout)
        layout.addWidget(grp_safety)

        # Buttons
        btn_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        btn_box.accepted.connect(self.accept)
        btn_box.rejected.connect(self.reject)
        layout.addWidget(btn_box)

        self.setLayout(layout)

    def get_new_conf(self):
        selected_deck = self.combo_deck.currentText()
        if self.combo_deck.currentIndex() == 0:
            selected_deck = "All Decks"

        return {
            "target_deck": selected_deck,
            "tag_name": self.tag_input.text(),
            "enable_difficulty": self.chk_d.isChecked(),
            "threshold_difficulty": self.spin_d.value(),
            "enable_lapse_rate": self.chk_rate.isChecked(),
            "threshold_lapse_rate": self.spin_rate.value(),
            "min_reps_for_rate": 5,
            "enable_max_lapses": self.chk_lapses.isChecked(),
            "threshold_max_lapses": self.spin_lapses.value(),
            "enable_avg_time": self.chk_time.isChecked(),
            "threshold_avg_time": self.spin_time.value(),
            "enable_total_reps": self.chk_reps.isChecked(),
            "threshold_total_reps": self.spin_reps.value(),
            "enable_safety_valve": self.chk_safety.isChecked(),
            "safety_valve_days": self.spin_safety.value()
        }

# ================== Core Analysis Logic ==================

def run_analysis():
    # 1. Load config
    conf = get_config()
    target_deck = conf.get("target_deck", "All Decks")
    tag_name = conf["tag_name"]
    
    # 2. Build Query
    search_query = "-is:suspended"
    
    deck_msg = "All Decks"
    if target_deck != "All Decks":
        # Quote deck name to handle spaces
        search_query += f' deck:"{target_deck}"'
        deck_msg = f"Deck [{target_deck}]"
    
    # 3. Confirmation
    if not askUser(f"Start Analysis?\n\nScope: {deck_msg}\nTarget Tag: {tag_name}\n\nProceed?"):
        return

    mw.progress.start(label="Reading data...", immediate=True)
    
    try:
        # Execute Search
        card_ids = mw.col.find_cards(search_query)
        
        marked_count = 0
        new_tag_count = 0
        
        # SQL for average time (prepared statement)
        sql_avg_time = "select avg(time) from revlog where cid = ?"
        
        total_cards = len(card_ids)
        if total_cards == 0:
            showInfo("No unsuspended cards found in the selected scope.")
            return

        for idx, cid in enumerate(card_ids):
            if idx % 200 == 0:
                mw.progress.update(label=f"Analyzing {deck_msg}... {idx}/{total_cards}")
            
            card = mw.col.get_card(cid)
            note = card.note()
            
            # Skip if already tagged (optional, currently enabled)
            if note.has_tag(tag_name):
                continue

            reasons = []
            
            # --- Safety Valve (Stability) ---
            is_safe = False
            if conf["enable_safety_valve"] and card.memory_state:
                if card.memory_state.stability > conf["safety_valve_days"]:
                    is_safe = True
            
            if is_safe:
                continue

            # --- 1: FSRS Difficulty ---
            if conf["enable_difficulty"] and card.memory_state:
                if card.memory_state.difficulty > (conf["threshold_difficulty"] * 10):
                    reasons.append("High Difficulty")

            # --- 2: Lapse Rate ---
            if conf["enable_lapse_rate"] and card.reps >= conf["min_reps_for_rate"]:
                if card.reps > 0:
                    rate = card.lapses / card.reps
                    if rate > conf["threshold_lapse_rate"]:
                        reasons.append("High Lapse Rate")

            # --- 3: Absolute Lapses ---
            if conf["enable_max_lapses"]:
                if card.lapses > conf["threshold_max_lapses"]:
                    reasons.append("High Lapses")
            
            # --- 4: Total Reps ---
            if conf["enable_total_reps"]:
                if card.reps > conf["threshold_total_reps"]:
                    reasons.append(f"Fatigue ({card.reps} reps)")

            # --- 5: Average Time (Slowest check, done last) ---
            if conf["enable_avg_time"] and not reasons:
                # Need > 3 reps to be statistically relevant
                if card.reps > 3:
                    avg_time = mw.col.db.scalar(sql_avg_time, cid)
                    if avg_time and avg_time > (conf["threshold_avg_time"] * 1000):
                        reasons.append("Time Sink")

            # --- Apply Tag ---
            if reasons:
                note.add_tag(tag_name)
                mw.col.update_note(note)
                new_tag_count += 1
                marked_count += 1
        
    finally:
        mw.progress.finish()
        mw.reset()

    if new_tag_count > 0:
        showInfo(f"Analysis Complete!\n\nScope: {deck_msg}\nNewly Tagged: {new_tag_count} cards\n\nPlease check the tag: {tag_name}")
    else:
        tooltip(f"Analysis Complete. No new bad cards found in {deck_msg}.")

# ================== Menu Integration ==================

def on_settings():
    d = ConfigDialog(mw)
    if d.exec():
        save_config(d.get_new_conf())
        tooltip("Configuration Saved")

def on_run():
    run_analysis()

# Create Menu
menu = QMenu("FSRS Smart Analysis", mw)
mw.form.menuTools.addMenu(menu)

action_run = QAction("Start Analysis", mw)
action_run.triggered.connect(on_run)
menu.addAction(action_run)

action_settings = QAction("Settings...", mw)
action_settings.triggered.connect(on_settings)
menu.addAction(action_settings)