forked from ChelseaKR/queer-the-stacks
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathshelf.py
More file actions
90 lines (70 loc) · 3.29 KB
/
Copy pathshelf.py
File metadata and controls
90 lines (70 loc) · 3.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
"""Series intelligence and a prioritized to-read shelf.
Both are pure functions over unified reading state. "Up next in a series" finds
books you *own but haven't read* in a series you've already started; the to-read
shelf orders your unread owned books by how well they fit your taste (sourced
themes), with series-continuations floated to the top.
"""
from __future__ import annotations
from dataclasses import dataclass
from ingest.models import ReadingState, ReadingStatus
from recommender.model import TasteProfile, build_taste_profile
@dataclass(frozen=True)
class SeriesNext:
"""An unread owned book that continues a series you've started."""
series: str
title: str
authors: tuple[str, ...]
series_index: float | None
def _series_index(state: ReadingState) -> float:
if state.book and state.book.series_index is not None:
return state.book.series_index
return 0.0
def series_continuations(states: list[ReadingState]) -> list[SeriesNext]:
"""Unread owned books in series where at least one book is already finished.
Ordered by series name, then series index, then title — deterministic.
"""
started: set[str] = {
s.book.series
for s in states
if s.book and s.book.series and s.status is ReadingStatus.FINISHED
}
out: list[SeriesNext] = []
for s in states:
if s.book and s.book.series in started and s.status is ReadingStatus.UNREAD:
out.append(
SeriesNext(
series=s.book.series or "",
title=s.title,
authors=s.authors,
series_index=s.book.series_index,
)
)
return sorted(out, key=lambda x: (x.series, _idx(x.series_index), x.title))
def _idx(value: float | None) -> float:
return value if value is not None else 0.0
def to_read(states: list[ReadingState], taste: TasteProfile | None = None) -> list[ReadingState]:
"""Unread owned books, best taste-fit first; series continuations float up.
Fit is the count of a book's sourced themes that are in the taste profile, so
the shelf leans toward what you already love without any inference.
**The ordering is only taste-ranked when there is a taste to rank by.** The
profile is built from finished books; with none — a Calibre-only library has
no reading status at all — ``theme_weights`` is empty, ``fit`` is 0 for every
book, and the sort collapses to ``sorted(unread, key=title)``. The function
still returns the right answer; what it must not do is let a caller describe
that answer as personalization. Callers check ``taste.theme_weights`` (see
:attr:`app.view.DashboardView.to_read_taste_ranked`) and say which order the
reader is actually looking at.
"""
profile = taste or build_taste_profile(states)
continuation_titles = {c.title for c in series_continuations(states)}
unread = [s for s in states if s.status is ReadingStatus.UNREAD and s.book is not None]
def fit(state: ReadingState) -> int:
return sum(1 for t in state.theme_tags if t.normalized in profile.theme_weights)
return sorted(
unread,
key=lambda s: (
0 if s.title in continuation_titles else 1, # continuations first
-fit(s),
s.title,
),
)