Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ The default setup targets **Kiku** and **Lapis**-style Japanese sentence cards.
## What it does

- **Frequency-first ranking** — prioritizes cards with better ranks from the configured frequency source.
- **Optional reading exposure boost** — uses `_reading_exposure_words.json.gz` from `collection.media` when another add-on exports it.
- **Readability-aware tie breaking** — prefers cards that use kanji you have already matured.
- **Kiku + Lapis defaults** — ships with sensible defaults for common Japanese sentence-card setups.
- **Safe scope** — only eligible new cards are repositioned; reviews, learning cards, and suspended cards are left alone.
Expand Down Expand Up @@ -154,9 +155,10 @@ For each eligible new card, the add-on:

1. reads the expression field,
2. looks up a frequency rank,
3. infers known kanji from matured cards,
4. blends frequency with a soft readability multiplier,
5. repositions matching new cards through Anki's internal scheduler API.
3. loads optional Reading Exposure Exporter media from `collection.media`,
4. infers known kanji from matured cards,
5. blends frequency and reading exposure with a soft readability multiplier,
6. repositions matching new cards through Anki's internal scheduler API.

Default scoring shape:

Expand All @@ -167,6 +169,12 @@ Default scoring shape:
| Unknown kanji | configurable penalty per unknown kanji |
| Partially-known kanji word | tiny coverage bonus |

If `_reading_exposure_words.json.gz` exists in Anki's `collection.media`, the
sorter adds a small frequency-like boost from that media file. The boost favors
words seen in the last 7 days, still counts the last 14 and 31 day windows, and
keeps a small lifetime-count component. Missing files are ignored; malformed
files are reported in the sort summary warnings.

Final ordering uses score first, then raw rank, expression length, current due position, template order, and card id for stable tie-breaking.

## Configuration
Expand Down Expand Up @@ -209,6 +217,7 @@ Important settings:
| `syncSafetyMode` | `mobile_guarded` by default; `desktop_only_allow_auto` opt-in for automation. |
| `jitenFrequencyListId` | Built-in Jiten list: `global`, `visual_novel`, `novel`, `anime`, etc. |
| `yomitanFrequencyIndexUrl` | Optional Yomitan frequency dictionary URL. |
| `readingExposureWeight` | Optional boost from Reading Exposure Exporter media. `0.0` disables it. |

## Frequency sources

Expand Down
3 changes: 2 additions & 1 deletion addon/anki_sorter/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@
"kanaOnlyMultiplier": 0.92,
"unknownKanjiPenaltyStep": 0.18,
"unknownKanjiPenaltyCap": 0.54,
"partialKnownCoverageBonus": 0.04
"partialKnownCoverageBonus": 0.04,
"readingExposureWeight": 0.18
}
4 changes: 4 additions & 0 deletions addon/anki_sorter/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ Ranking settings:
Small bonus for partially-known cards in `frequency_first_soft_v1`.
This only applies to cards that still have at least one unknown kanji.

- `readingExposureWeight`
Optional boost from Reading Exposure Exporter media in Anki's
`collection.media` directory. `0.0` disables the boost. The default is `0.18`.

- `tierOrder`
Controls the tier order used by `easy_first_tiered_v1`.
It does not affect `frequency_first_soft_v1`.
Expand Down
13 changes: 13 additions & 0 deletions addon/anki_sorter/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
DEFAULT_UNKNOWN_KANJI_PENALTY_STEP = 0.18
DEFAULT_UNKNOWN_KANJI_PENALTY_CAP = 0.54
DEFAULT_PARTIAL_KNOWN_COVERAGE_BONUS = 0.04
DEFAULT_READING_EXPOSURE_WEIGHT = 0.18
VALID_STRATEGIES = {
STRATEGY_FREQUENCY_FIRST_SOFT_V1,
STRATEGY_EASY_FIRST_TIERED_V1,
Expand Down Expand Up @@ -115,6 +116,7 @@ class AddonConfig:
unknown_kanji_penalty_step: float = DEFAULT_UNKNOWN_KANJI_PENALTY_STEP
unknown_kanji_penalty_cap: float = DEFAULT_UNKNOWN_KANJI_PENALTY_CAP
partial_known_coverage_bonus: float = DEFAULT_PARTIAL_KNOWN_COVERAGE_BONUS
reading_exposure_weight: float = DEFAULT_READING_EXPOSURE_WEIGHT

def to_dict(self) -> dict[str, Any]:
return {
Expand Down Expand Up @@ -143,6 +145,7 @@ def to_dict(self) -> dict[str, Any]:
"unknownKanjiPenaltyStep": self.unknown_kanji_penalty_step,
"unknownKanjiPenaltyCap": self.unknown_kanji_penalty_cap,
"partialKnownCoverageBonus": self.partial_known_coverage_bonus,
"readingExposureWeight": self.reading_exposure_weight,
}

@property
Expand Down Expand Up @@ -270,6 +273,14 @@ def parse_config(raw: Mapping[str, Any] | None) -> AddonConfig:
maximum=1.0,
errors=errors,
)
reading_exposure_weight = _coerce_float_in_range(
raw.get("readingExposureWeight"),
DEFAULT_READING_EXPOSURE_WEIGHT,
"readingExposureWeight",
minimum=0.0,
maximum=1.0,
errors=errors,
)

if http_port > 65535:
errors.append("httpPort must be between 1 and 65535.")
Expand Down Expand Up @@ -327,6 +338,7 @@ def parse_config(raw: Mapping[str, Any] | None) -> AddonConfig:
unknown_kanji_penalty_step=unknown_kanji_penalty_step,
unknown_kanji_penalty_cap=unknown_kanji_penalty_cap,
partial_known_coverage_bonus=partial_known_coverage_bonus,
reading_exposure_weight=reading_exposure_weight,
)


Expand Down Expand Up @@ -437,6 +449,7 @@ def _should_migrate_default_tiered_strategy(raw: Mapping[str, Any]) -> bool:
"unknownKanjiPenaltyStep",
"unknownKanjiPenaltyCap",
"partialKnownCoverageBonus",
"readingExposureWeight",
)
)

Expand Down
59 changes: 50 additions & 9 deletions addon/anki_sorter/ranking.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
DEFAULT_KANA_ONLY_MULTIPLIER,
DEFAULT_PARTIAL_KNOWN_COVERAGE_BONUS,
DEFAULT_PREFER_SHORTER_EXPRESSIONS,
DEFAULT_READING_EXPOSURE_WEIGHT,
DEFAULT_TIER_ORDER,
DEFAULT_UNKNOWN_KANJI_PENALTY_CAP,
DEFAULT_UNKNOWN_KANJI_PENALTY_STEP,
Expand All @@ -34,6 +35,12 @@ class CardInput:
total_kanji_count: int
raw_rank: float | None
rank_source: str | None
reading_exposure_score: float = 0.0
reading_exposure_total_count: int = 0
reading_exposure_last_7_days_count: int = 0
reading_exposure_last_14_days_count: int = 0
reading_exposure_last_31_days_count: int = 0
reading_exposure_last_seen_at_millis: int = 0

@property
def unknown_kanji_count(self) -> int:
Expand All @@ -57,6 +64,7 @@ class ScoredCard:
priority_label: str
readability_multiplier: float
coverage_bonus: float
reading_exposure_score: float


def parse_freqsort(value: str) -> float | None:
Expand All @@ -79,6 +87,7 @@ def score_cards(
unknown_kanji_penalty_step: float = DEFAULT_UNKNOWN_KANJI_PENALTY_STEP,
unknown_kanji_penalty_cap: float = DEFAULT_UNKNOWN_KANJI_PENALTY_CAP,
partial_known_coverage_bonus: float = DEFAULT_PARTIAL_KNOWN_COVERAGE_BONUS,
reading_exposure_weight: float = DEFAULT_READING_EXPOSURE_WEIGHT,
) -> list[ScoredCard]:
if strategy == STRATEGY_FREQUENCY_FIRST_SOFT_V1:
return _score_cards_frequency_first_soft(
Expand All @@ -90,19 +99,22 @@ def score_cards(
unknown_kanji_penalty_step=unknown_kanji_penalty_step,
unknown_kanji_penalty_cap=unknown_kanji_penalty_cap,
partial_known_coverage_bonus=partial_known_coverage_bonus,
reading_exposure_weight=reading_exposure_weight,
)
if strategy == STRATEGY_EASY_FIRST_TIERED_V1:
return _score_cards_easy_first_tiered(
cards,
tier_order=tier_order,
prefer_shorter_expressions=prefer_shorter_expressions,
freqsort_weight=freqsort_weight,
reading_exposure_weight=reading_exposure_weight,
)
if strategy == STRATEGY_BALANCED_EASE_V1:
return _score_cards_balanced(
cards,
tier_order=tier_order,
freqsort_weight=freqsort_weight,
reading_exposure_weight=reading_exposure_weight,
)
raise ValueError(f"Unsupported ranking strategy: {strategy}")

Expand All @@ -112,6 +124,7 @@ def _score_cards_balanced(
*,
tier_order: tuple[str, ...],
freqsort_weight: float,
reading_exposure_weight: float,
) -> list[ScoredCard]:
ranks = [card.raw_rank for card in cards if card.raw_rank is not None]
max_rank = max(ranks) if ranks else None
Expand All @@ -128,7 +141,11 @@ def _score_cards_balanced(
if card.rank_source == "freqsort"
else 0.0
)
frequency_score = rank_score * rank_multiplier
frequency_score = _combined_frequency_score(
rank_score * rank_multiplier,
card.reading_exposure_score,
reading_exposure_weight,
)
kana_bonus = 0.05 if card.total_kanji_count == 0 else 0.0
ease_score = (
0.50 * coverage_score
Expand All @@ -147,6 +164,7 @@ def _score_cards_balanced(
priority_label=_priority_label(card),
readability_multiplier=1.0,
coverage_bonus=0.0,
reading_exposure_score=card.reading_exposure_score,
)
)

Expand All @@ -171,14 +189,19 @@ def _score_cards_easy_first_tiered(
tier_order: tuple[str, ...],
prefer_shorter_expressions: bool,
freqsort_weight: float,
reading_exposure_weight: float,
) -> list[ScoredCard]:
scored: list[ScoredCard] = []
for card in cards:
priority_tier = _priority_tier_index(card, tier_order)
frequency_score = _absolute_frequency_score(
card.raw_rank,
card.rank_source,
freqsort_weight,
frequency_score = _combined_frequency_score(
_absolute_frequency_score(
card.raw_rank,
card.rank_source,
freqsort_weight,
),
card.reading_exposure_score,
reading_exposure_weight,
)
coverage_score = card.coverage_score
unknown_penalty = min(card.unknown_kanji_count, 3) / 3.0
Expand All @@ -194,13 +217,15 @@ def _score_cards_easy_first_tiered(
priority_label=_priority_label(card),
readability_multiplier=1.0,
coverage_bonus=0.0,
reading_exposure_score=card.reading_exposure_score,
)
)

return sorted(
scored,
key=lambda scored_card: (
scored_card.priority_tier,
-scored_card.frequency_score,
scored_card.card.raw_rank
if scored_card.card.raw_rank is not None
else math.inf,
Expand All @@ -224,15 +249,20 @@ def _score_cards_frequency_first_soft(
unknown_kanji_penalty_step: float,
unknown_kanji_penalty_cap: float,
partial_known_coverage_bonus: float,
reading_exposure_weight: float,
) -> list[ScoredCard]:
scored: list[ScoredCard] = []
for card in cards:
priority_tier = _priority_tier_index(card, tier_order)
priority_label = _priority_label(card)
frequency_score = _absolute_frequency_score(
card.raw_rank,
card.rank_source,
freqsort_weight,
frequency_score = _combined_frequency_score(
_absolute_frequency_score(
card.raw_rank,
card.rank_source,
freqsort_weight,
),
card.reading_exposure_score,
reading_exposure_weight,
)
coverage_score = card.coverage_score
unknown_penalty = _unknown_kanji_penalty(
Expand Down Expand Up @@ -262,6 +292,7 @@ def _score_cards_frequency_first_soft(
priority_label=priority_label,
readability_multiplier=readability_multiplier,
coverage_bonus=coverage_bonus,
reading_exposure_score=card.reading_exposure_score,
)
)

Expand Down Expand Up @@ -309,6 +340,16 @@ def _absolute_frequency_score(
return 0.0


def _combined_frequency_score(
base_frequency_score: float,
reading_exposure_score: float,
reading_exposure_weight: float,
) -> float:
exposure_score = max(0.0, min(1.0, reading_exposure_score))
exposure_weight = max(0.0, min(1.0, reading_exposure_weight))
return min(1.0, base_frequency_score + exposure_score * exposure_weight)


def _unknown_kanji_penalty(
unknown_kanji_count: int,
step: float,
Expand Down
Loading
Loading