user_behavior_analytics.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. """用户行为分析 —— 基于浏览/保存/搜索行为更新用户偏好画像."""
  2. from __future__ import annotations
  3. import contextlib
  4. import re
  5. import sqlite3
  6. from collections import Counter
  7. from dataclasses import dataclass
  8. from ...utils import build_in_clause
  9. @dataclass
  10. class UserInterestProfile:
  11. top_keywords: list[tuple[str, float]]
  12. top_subdomains: list[tuple[str, float]]
  13. high_interest_paper_ids: list[int]
  14. recent_active_topics: list[str]
  15. preferred_years: list[int]
  16. preferred_sources: list[str]
  17. class UserBehaviorAnalytics:
  18. def __init__(self, db_path: str) -> None:
  19. self.db_path = db_path
  20. def _get_connection(self) -> sqlite3.Connection:
  21. conn = sqlite3.connect(self.db_path)
  22. conn.row_factory = sqlite3.Row
  23. return conn
  24. @contextlib.contextmanager
  25. def _cursor(self):
  26. conn = self._get_connection()
  27. try:
  28. yield conn.cursor()
  29. finally:
  30. conn.close()
  31. def extract_keywords_from_text(self, text: str) -> list[str]:
  32. if not text:
  33. return []
  34. t = text.lower()
  35. t = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", t)
  36. tokens = [x.strip() for x in t.split() if x.strip()]
  37. stop = {
  38. "the", "a", "an", "and", "or", "of", "to", "in", "for", "with", "on",
  39. "we", "our", "is", "are", "be", "via", "from", "this", "that",
  40. "using", "use", "based", "towards", "paper", "propose", "method",
  41. "learning", "network", "model", "deep", "neural",
  42. }
  43. return [x for x in tokens if x not in stop and len(x) >= 4][:200]
  44. def get_papers_by_reading_time(
  45. self, *, days: int = 30, min_duration: int = 30, top_n: int = 50,
  46. ) -> list[tuple[int, float]]:
  47. with self._cursor() as cur:
  48. cur.execute(
  49. "SELECT paper_id,SUM(duration_sec) AS total_duration FROM paper_reading_sessions WHERE day_key>=date('now',?) AND duration_sec>=? GROUP BY paper_id ORDER BY total_duration DESC LIMIT ?",
  50. (f"-{days} days", min_duration, top_n),
  51. )
  52. return [(int(r["paper_id"]), float(r["total_duration"])) for r in cur.fetchall()]
  53. def get_papers_by_reading_frequency(
  54. self, *, days: int = 30, min_sessions: int = 2, top_n: int = 30,
  55. ) -> list[tuple[int, int]]:
  56. with self._cursor() as cur:
  57. cur.execute(
  58. "SELECT paper_id,COUNT(*) AS session_count FROM paper_reading_sessions WHERE day_key>=date('now',?) GROUP BY paper_id HAVING COUNT(*)>=? ORDER BY session_count DESC LIMIT ?",
  59. (f"-{days} days", min_sessions, top_n),
  60. )
  61. return [(int(r["paper_id"]), int(r["session_count"])) for r in cur.fetchall()]
  62. def get_recently_saved_papers(
  63. self, *, days: int = 30, top_n: int = 50,
  64. ) -> list[tuple[int, str]]:
  65. with self._cursor() as cur:
  66. cur.execute(
  67. "SELECT id,category FROM papers WHERE created_at>=strftime('%s','now',?) ORDER BY created_at DESC LIMIT ?",
  68. (f"-{days} days", top_n),
  69. )
  70. return [(int(r["id"]), str(r["category"] or "")) for r in cur.fetchall()]
  71. def extract_keywords_from_high_interest_papers(
  72. self, paper_ids: list[int],
  73. ) -> Counter[str]:
  74. if not paper_ids:
  75. return Counter()
  76. with self._cursor() as cur:
  77. in_clause, params = build_in_clause("id", paper_ids)
  78. cur.execute(f"SELECT title,abstract,keywords FROM papers WHERE {in_clause}", params)
  79. all_kw: list[str] = []
  80. for row in cur.fetchall():
  81. all_kw.extend(self.extract_keywords_from_text(str(row["title"] or "")))
  82. all_kw.extend(self.extract_keywords_from_text(str(row["abstract"] or "")))
  83. for kw in (str(row["keywords"] or "")).split(","):
  84. k = kw.strip().lower()
  85. if k and len(k) >= 3:
  86. all_kw.append(k)
  87. return Counter(all_kw)
  88. def get_interest_subdomains_from_papers(self, paper_ids: list[int]) -> Counter[str]:
  89. if not paper_ids:
  90. return Counter()
  91. with self._cursor() as cur:
  92. in_clause, params = build_in_clause("id", paper_ids)
  93. cur.execute(f"SELECT title,category,journal FROM papers WHERE {in_clause}", params)
  94. subdomains = self._extract_subdomains_from_rows(cur.fetchall())
  95. return subdomains
  96. def _extract_subdomains_from_rows(self, rows) -> Counter[str]:
  97. """Count topics from paper metadata. LLM daily pipeline handles semantic classification."""
  98. subdomains: Counter[str] = Counter()
  99. for row in rows:
  100. cat = (row.get("category") or "").strip()
  101. if cat:
  102. subdomains[cat.lower()] += 1
  103. return subdomains
  104. def get_feedback_enhanced_keywords(
  105. self,
  106. *,
  107. days: int = 21,
  108. ) -> Counter[str]:
  109. from .daily_recommend_feedback import get_high_value_keywords_from_feedback
  110. try:
  111. keywords_set = get_high_value_keywords_from_feedback(self.db_path, days=days, top_n=30)
  112. return Counter({kw: 2.0 for kw in keywords_set})
  113. except Exception:
  114. return Counter()
  115. def get_user_interest_profile(
  116. self,
  117. *,
  118. reading_days: int = 30,
  119. saved_days: int = 60,
  120. feedback_days: int = 21,
  121. ) -> UserInterestProfile:
  122. high_duration_papers = self.get_papers_by_reading_time(days=reading_days, top_n=50)
  123. high_duration_ids = [pid for pid, _ in high_duration_papers]
  124. freq_papers = self.get_papers_by_reading_frequency(days=reading_days, top_n=30)
  125. freq_ids = [pid for pid, _ in freq_papers]
  126. saved_papers = self.get_recently_saved_papers(days=saved_days, top_n=50)
  127. saved_ids = [pid for pid, _ in saved_papers]
  128. all_interest_ids = list(set(high_duration_ids + freq_ids + saved_ids))
  129. reading_keywords = self.extract_keywords_from_high_interest_papers(all_interest_ids)
  130. feedback_keywords = self.get_feedback_enhanced_keywords(days=feedback_days)
  131. combined_keywords: Counter[str] = Counter()
  132. for kw, count in reading_keywords.items():
  133. combined_keywords[kw] += count * 1.0
  134. for kw, weight in feedback_keywords.items():
  135. combined_keywords[kw] += weight
  136. if saved_ids:
  137. saved_paper_ids = [pid for pid, _ in saved_papers]
  138. if saved_paper_ids:
  139. saved_keywords = self.extract_keywords_from_high_interest_papers(saved_paper_ids)
  140. for kw, count in saved_keywords.items():
  141. if kw in reading_keywords:
  142. combined_keywords[kw] += count * 0.5
  143. else:
  144. combined_keywords[kw] += count * 1.5
  145. subdomains = self.get_interest_subdomains_from_papers(all_interest_ids)
  146. preferred_years = self._extract_preferred_years(all_interest_ids)
  147. recent_topics = self._extract_recent_active_topics(reading_days=14)
  148. top_keywords = combined_keywords.most_common(40)
  149. top_subdomains = subdomains.most_common(10)
  150. return UserInterestProfile(
  151. top_keywords=top_keywords,
  152. top_subdomains=top_subdomains,
  153. high_interest_paper_ids=all_interest_ids[:100],
  154. recent_active_topics=recent_topics,
  155. preferred_years=preferred_years,
  156. preferred_sources=[],
  157. )
  158. def _extract_preferred_years(self, paper_ids: list[int]) -> list[int]:
  159. if not paper_ids:
  160. return []
  161. with self._cursor() as cur:
  162. in_clause, params = build_in_clause("id", paper_ids)
  163. cur.execute(f"SELECT year,COUNT(*) AS cnt FROM papers WHERE {in_clause} AND year IS NOT NULL GROUP BY year ORDER BY cnt DESC LIMIT 5", params)
  164. return [int(r["year"]) for r in cur.fetchall() if r["year"]]
  165. def _extract_recent_active_topics(self, reading_days: int = 14) -> list[str]:
  166. with self._cursor() as cur:
  167. cur.execute(
  168. "SELECT DISTINCT p.title,p.abstract,p.category FROM papers p INNER JOIN paper_reading_sessions prs ON p.id=prs.paper_id WHERE prs.day_key>=date('now',?) LIMIT 30",
  169. (f"-{reading_days} days",),
  170. )
  171. topics = self._extract_subdomains_from_rows(cur.fetchall())
  172. return [t for t, _ in topics.most_common(5)]
  173. def get_user_interest_profile_for_daily_recommend(db_path: str) -> UserInterestProfile:
  174. analytics = UserBehaviorAnalytics(db_path)
  175. return analytics.get_user_interest_profile(
  176. reading_days=30,
  177. saved_days=60,
  178. feedback_days=21,
  179. )