backloggd-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
backloggd/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """Backloggd Python SDK and Taste Matcher."""
2
+
3
+ from backloggd.client import BackloggdClient
4
+ from backloggd.sync_client import Backloggd
5
+ from backloggd.cache import CacheManager
6
+ from backloggd.utils import load_cookies, build_games_url
7
+ from backloggd.exceptions import (
8
+ BackloggdError,
9
+ ChallengeResponseError,
10
+ RateLimitError,
11
+ ResourceNotFoundError,
12
+ UserNotFoundError,
13
+ GameNotFoundError,
14
+ ListNotFoundError,
15
+ ReviewNotFoundError,
16
+ )
17
+ from backloggd.models import (
18
+ GameDetails,
19
+ GameEntry,
20
+ GameSearchResult,
21
+ GameStatsSummary,
22
+ GameStatus,
23
+ MatchComparison,
24
+ RatingDisagreement,
25
+ Review,
26
+ UserLibrary,
27
+ UserList,
28
+ UserListSummary,
29
+ UserProfile,
30
+ UserStats,
31
+ UserSummary,
32
+ JournalEntry,
33
+ )
34
+ from backloggd.analytics.matcher import calculate_similarity_raw, compare_libraries
35
+
36
+ __version__ = "0.1.0"
37
+
38
+ __all__ = [
39
+ # Clients, Cache & Utilities
40
+ "Backloggd", # Synchronous client (no async/await needed)
41
+ "BackloggdClient", # Async client
42
+ "CacheManager",
43
+ "load_cookies",
44
+ "build_games_url",
45
+ # Models
46
+ "GameEntry",
47
+ "GameDetails",
48
+ "GameSearchResult",
49
+ "GameStatsSummary",
50
+ "GameStatus",
51
+ "Review",
52
+ "UserList",
53
+ "UserListSummary",
54
+ "UserLibrary",
55
+ "UserProfile",
56
+ "UserStats",
57
+ "UserSummary",
58
+ "JournalEntry",
59
+ "MatchComparison",
60
+ "RatingDisagreement",
61
+ # Analytics
62
+ "calculate_similarity_raw",
63
+ "compare_libraries",
64
+ # Exceptions
65
+ "BackloggdError",
66
+ "ChallengeResponseError",
67
+ "RateLimitError",
68
+ "ResourceNotFoundError",
69
+ "UserNotFoundError",
70
+ "GameNotFoundError",
71
+ "ListNotFoundError",
72
+ "ReviewNotFoundError",
73
+ ]
74
+
@@ -0,0 +1,3 @@
1
+ from backloggd.analytics.matcher import calculate_similarity_raw, compare_libraries
2
+
3
+ __all__ = ["calculate_similarity_raw", "compare_libraries"]
@@ -0,0 +1,152 @@
1
+ """Taste matching, rating agreements, and recommendation algorithms."""
2
+
3
+ import math
4
+ from typing import Optional
5
+ from backloggd.models.game import GameEntry
6
+ from backloggd.models.library import UserLibrary
7
+ from backloggd.models.comparison import MatchComparison, RatingDisagreement
8
+
9
+
10
+ def calculate_similarity_raw(
11
+ ratings1: dict[str, Optional[float]],
12
+ ratings2: dict[str, Optional[float]],
13
+ k_shrinkage: int = 10,
14
+ ) -> tuple[float, float, float]:
15
+ """Calculate library overlap, rating agreement, and combined similarity score.
16
+
17
+ Returns:
18
+ tuple[float, float, float]: (final_score, library_score, rating_score)
19
+ """
20
+ if not ratings1 or not ratings2:
21
+ return 0.0, 0.0, 0.0
22
+
23
+ shared_slugs = ratings1.keys() & ratings2.keys()
24
+ if not shared_slugs:
25
+ return 0.0, 0.0, 0.0
26
+
27
+ # 1. Library Overlap (Szymkiewicz-Simpson & Jaccard blend)
28
+ total_unique = len(ratings1.keys() | ratings2.keys())
29
+ jaccard = len(shared_slugs) / total_unique if total_unique > 0 else 0.0
30
+ overlap_coeff = len(shared_slugs) / min(len(ratings1), len(ratings2))
31
+ library_score = 0.5 * jaccard + 0.5 * overlap_coeff # [0.0, 1.0]
32
+
33
+ # 2. Rating Agreement on mutually rated games
34
+ rated_pairs = [
35
+ (ratings1[s], ratings2[s])
36
+ for s in shared_slugs
37
+ if ratings1[s] is not None and ratings2[s] is not None
38
+ ]
39
+ n_rated = len(rated_pairs)
40
+ if n_rated == 0:
41
+ return round(library_score * 100, 2), round(library_score * 100, 2), 0.0
42
+
43
+ # Absolute agreement
44
+ mae = sum(abs(r1 - r2) for r1, r2 in rated_pairs) / n_rated
45
+ mae_sim = max(0.0, 1.0 - (mae / 4.5))
46
+
47
+ # Pearson relative correlation
48
+ mean1 = sum(r1 for r1, _ in rated_pairs) / n_rated
49
+ mean2 = sum(r2 for _, r2 in rated_pairs) / n_rated
50
+ diffs1 = [r1 - mean1 for r1, _ in rated_pairs]
51
+ diffs2 = [r2 - mean2 for _, r2 in rated_pairs]
52
+ mag1 = math.sqrt(sum(d * d for d in diffs1))
53
+ mag2 = math.sqrt(sum(d * d for d in diffs2))
54
+
55
+ if mag1 > 0 and mag2 > 0:
56
+ pearson = sum(a * b for a, b in zip(diffs1, diffs2)) / (mag1 * mag2)
57
+ pearson_sim = (pearson + 1.0) / 2.0
58
+ raw_rating_sim = 0.5 * pearson_sim + 0.5 * mae_sim
59
+ else:
60
+ raw_rating_sim = mae_sim
61
+
62
+ # Confidence shrinkage discount
63
+ confidence = n_rated / (n_rated + k_shrinkage)
64
+ rating_score = confidence * raw_rating_sim
65
+
66
+ final_score = (0.30 * library_score + 0.70 * rating_score) * 100
67
+ return (
68
+ round(final_score, 2),
69
+ round(library_score * 100, 2),
70
+ round(rating_score * 100, 2),
71
+ )
72
+
73
+
74
+ def compare_libraries(
75
+ lib1: UserLibrary,
76
+ lib2: UserLibrary,
77
+ k_shrinkage: int = 10,
78
+ recommendation_min_rating: float = 4.0,
79
+ ) -> MatchComparison:
80
+ """Perform full taste comparison between two user libraries."""
81
+ ratings1 = lib1.to_ratings_dict()
82
+ ratings2 = lib2.to_ratings_dict()
83
+
84
+ final_score, lib_score, rate_score = calculate_similarity_raw(
85
+ ratings1, ratings2, k_shrinkage=k_shrinkage
86
+ )
87
+
88
+ shared_slugs = lib1.slugs & lib2.slugs
89
+ mutually_rated_slugs = [
90
+ s for s in shared_slugs
91
+ if ratings1[s] is not None and ratings2[s] is not None
92
+ ]
93
+
94
+ # Mutual Favorites (both rated >= 4.0)
95
+ mutual_favorites: list[GameEntry] = []
96
+ for s in mutually_rated_slugs:
97
+ r1, r2 = ratings1[s], ratings2[s]
98
+ if r1 is not None and r2 is not None and r1 >= 4.0 and r2 >= 4.0:
99
+ mutual_favorites.append(lib1[s])
100
+ mutual_favorites.sort(
101
+ key=lambda g: ((ratings1[g.slug] or 0) + (ratings2[g.slug] or 0)),
102
+ reverse=True,
103
+ )
104
+
105
+ # Disagreements (highest absolute rating difference)
106
+ disagreements: list[RatingDisagreement] = []
107
+ for s in mutually_rated_slugs:
108
+ r1, r2 = ratings1[s], ratings2[s]
109
+ if r1 is not None and r2 is not None:
110
+ diff = abs(r1 - r2)
111
+ if diff >= 1.5: # Significant disagreement
112
+ disagreements.append(
113
+ RatingDisagreement(
114
+ game=lib1[s],
115
+ user1_rating=r1,
116
+ user2_rating=r2,
117
+ difference=round(diff, 2),
118
+ )
119
+ )
120
+ disagreements.sort(key=lambda d: d.difference, reverse=True)
121
+
122
+ # Recommendations: games User 2 loved (>= 4.0) that User 1 has not rated or logged
123
+ recs_for_user1: list[GameEntry] = [
124
+ g for g in lib2
125
+ if g.rating is not None
126
+ and g.rating >= recommendation_min_rating
127
+ and (g.slug not in lib1 or not lib1[g.slug].is_rated)
128
+ ]
129
+ recs_for_user1.sort(key=lambda g: g.rating or 0, reverse=True)
130
+
131
+ # Recommendations: games User 1 loved (>= 4.0) that User 2 has not rated or logged
132
+ recs_for_user2: list[GameEntry] = [
133
+ g for g in lib1
134
+ if g.rating is not None
135
+ and g.rating >= recommendation_min_rating
136
+ and (g.slug not in lib2 or not lib2[g.slug].is_rated)
137
+ ]
138
+ recs_for_user2.sort(key=lambda g: g.rating or 0, reverse=True)
139
+
140
+ return MatchComparison(
141
+ user1=lib1.username,
142
+ user2=lib2.username,
143
+ similarity_score=final_score,
144
+ library_score=lib_score,
145
+ rating_score=rate_score,
146
+ shared_games_count=len(shared_slugs),
147
+ mutually_rated_count=len(mutually_rated_slugs),
148
+ mutual_favorites=mutual_favorites,
149
+ disagreements=disagreements,
150
+ recommendations_for_user1=recs_for_user1,
151
+ recommendations_for_user2=recs_for_user2,
152
+ )