daily_recommend_feedback.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. """推荐反馈收集 —— 用户对每日推荐的评分与偏好记录."""
  2. from __future__ import annotations
  3. import contextlib
  4. import re
  5. import sqlite3
  6. import time
  7. from collections import Counter
  8. from ...models.schemas import FeedbackActionEnum as FeedbackAction
  9. from ...utils.common import exec_sql
  10. @contextlib.contextmanager
  11. def _conn(db_path: str):
  12. ensure_tables(db_path)
  13. conn = sqlite3.connect(db_path)
  14. try:
  15. yield conn
  16. conn.commit()
  17. finally:
  18. conn.close()
  19. def ensure_tables(db_path: str) -> None:
  20. exec_sql(db_path,
  21. """CREATE TABLE IF NOT EXISTS daily_recommend_feedback (
  22. id INTEGER PRIMARY KEY AUTOINCREMENT,
  23. date_key TEXT NOT NULL,
  24. paper_identity_key TEXT NOT NULL,
  25. identity_type TEXT NOT NULL,
  26. title TEXT,
  27. action TEXT NOT NULL,
  28. source_list TEXT,
  29. score_at_recommend REAL,
  30. created_at INTEGER NOT NULL
  31. )""",
  32. """CREATE TABLE IF NOT EXISTS paper_impressions (
  33. paper_identity_key TEXT PRIMARY KEY,
  34. identity_type TEXT NOT NULL,
  35. title TEXT,
  36. first_seen_date TEXT NOT NULL,
  37. last_seen_date TEXT NOT NULL,
  38. total_impressions INTEGER DEFAULT 0,
  39. clicks INTEGER DEFAULT 0,
  40. saves INTEGER DEFAULT 0,
  41. skips INTEGER DEFAULT 0,
  42. reads INTEGER DEFAULT 0,
  43. ctr REAL DEFAULT 0.0,
  44. save_rate REAL DEFAULT 0.0,
  45. skip_rate REAL DEFAULT 0.0,
  46. updated_at INTEGER NOT NULL
  47. )""",
  48. """CREATE TABLE IF NOT EXISTS user_interest_evolution (
  49. id INTEGER PRIMARY KEY AUTOINCREMENT,
  50. date_key TEXT NOT NULL,
  51. keyword TEXT NOT NULL,
  52. category TEXT,
  53. interaction_weight REAL DEFAULT 0.0,
  54. source TEXT,
  55. created_at INTEGER NOT NULL,
  56. UNIQUE(date_key, keyword, category, source)
  57. )""",
  58. "CREATE INDEX IF NOT EXISTS idx_feedback_date ON daily_recommend_feedback(date_key, created_at)",
  59. "CREATE INDEX IF NOT EXISTS idx_feedback_paper ON daily_recommend_feedback(paper_identity_key, identity_type)",
  60. "CREATE INDEX IF NOT EXISTS idx_feedback_action ON daily_recommend_feedback(action)",
  61. "CREATE INDEX IF NOT EXISTS idx_interest_date ON user_interest_evolution(date_key)",
  62. "CREATE INDEX IF NOT EXISTS idx_interest_kw ON user_interest_evolution(keyword, date_key)",
  63. )
  64. def record_feedback(
  65. db_path: str,
  66. *,
  67. date_key: str,
  68. paper_identity_key: str,
  69. identity_type: str,
  70. title: str | None = None,
  71. action: FeedbackAction,
  72. source_list: str | None = None,
  73. score_at_recommend: float | None = None,
  74. keywords: list[str | None] = None,
  75. category: str | None = None,
  76. ) -> bool:
  77. try:
  78. now = int(time.time())
  79. with _conn(db_path) as conn:
  80. cur = conn.cursor()
  81. cur.execute(
  82. """INSERT INTO daily_recommend_feedback(date_key,paper_identity_key,identity_type,title,action,source_list,score_at_recommend,created_at)
  83. VALUES(?,?,?,?,?,?,?,?)""",
  84. (str(date_key), str(paper_identity_key), str(identity_type),
  85. (title or "")[:400] if title else None, str(action.value),
  86. source_list, float(score_at_recommend) if score_at_recommend is not None else None, now),
  87. )
  88. _update_impression_stats(cur, paper_identity_key, identity_type, title or "", date_key, action, now)
  89. if keywords:
  90. weight = _action_to_weight(action)
  91. for kw in keywords[:8]:
  92. if kw and len(kw.strip()) >= 3:
  93. _upsert_interest_evolution(cur, date_key, kw.strip(), category, weight, "feedback", now)
  94. return True
  95. except Exception as e:
  96. import logging
  97. logging.getLogger(__name__).warning(f"记录推荐反馈失败: {e}")
  98. return False
  99. def _action_to_weight(action: FeedbackAction) -> float:
  100. weights = {
  101. FeedbackAction.SAVE: 3.0,
  102. FeedbackAction.READ: 2.5,
  103. FeedbackAction.CLICK: 1.5,
  104. FeedbackAction.SKIP: -1.0,
  105. FeedbackAction.IGNORE: 0.0,
  106. }
  107. return weights.get(action, 0.0)
  108. def _update_impression_stats(
  109. cur: sqlite3.Cursor,
  110. paper_identity_key: str,
  111. identity_type: str,
  112. title: str,
  113. date_key: str,
  114. action: FeedbackAction,
  115. now: int,
  116. ) -> None:
  117. cur.execute(
  118. """
  119. SELECT total_impressions, clicks, saves, skips, reads
  120. FROM paper_impressions WHERE paper_identity_key = ?
  121. """,
  122. (str(paper_identity_key),),
  123. )
  124. row = cur.fetchone()
  125. if row:
  126. total, clicks, saves, skips, reads = row
  127. total = (total or 0) + 1
  128. clicks = (clicks or 0) + (1 if action == FeedbackAction.CLICK else 0)
  129. saves = (saves or 0) + (1 if action == FeedbackAction.SAVE else 0)
  130. skips = (skips or 0) + (1 if action == FeedbackAction.SKIP else 0)
  131. reads = (reads or 0) + (1 if action == FeedbackAction.READ else 0)
  132. else:
  133. total, clicks, saves, skips, reads = 1, 0, 0, 0, 0
  134. if action == FeedbackAction.CLICK:
  135. clicks = 1
  136. elif action == FeedbackAction.SAVE:
  137. saves = 1
  138. elif action == FeedbackAction.SKIP:
  139. skips = 1
  140. elif action == FeedbackAction.READ:
  141. reads = 1
  142. ctr = clicks / total if total > 0 else 0.0
  143. save_rate = saves / total if total > 0 else 0.0
  144. skip_rate = skips / total if total > 0 else 0.0
  145. cur.execute(
  146. """
  147. INSERT INTO paper_impressions
  148. (paper_identity_key, identity_type, title, first_seen_date, last_seen_date,
  149. total_impressions, clicks, saves, skips, reads, ctr, save_rate, skip_rate, updated_at)
  150. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  151. ON CONFLICT(paper_identity_key) DO UPDATE SET
  152. title = excluded.title,
  153. last_seen_date = excluded.last_seen_date,
  154. total_impressions = excluded.total_impressions,
  155. clicks = excluded.clicks,
  156. saves = excluded.saves,
  157. skips = excluded.skips,
  158. reads = excluded.reads,
  159. ctr = excluded.ctr,
  160. save_rate = excluded.save_rate,
  161. skip_rate = excluded.skip_rate,
  162. updated_at = excluded.updated_at
  163. """,
  164. (
  165. str(paper_identity_key),
  166. str(identity_type),
  167. title[:400] if title else "",
  168. str(date_key),
  169. str(date_key),
  170. total,
  171. clicks,
  172. saves,
  173. skips,
  174. reads,
  175. ctr,
  176. save_rate,
  177. skip_rate,
  178. now,
  179. ),
  180. )
  181. def _upsert_interest_evolution(
  182. cur: sqlite3.Cursor,
  183. date_key: str,
  184. keyword: str,
  185. category: str | None,
  186. weight: float,
  187. source: str,
  188. now: int,
  189. ) -> None:
  190. cur.execute(
  191. """
  192. INSERT INTO user_interest_evolution (date_key, keyword, category, interaction_weight, source, created_at)
  193. VALUES (?, ?, ?, ?, ?, ?)
  194. ON CONFLICT(date_key, keyword, category, source) DO UPDATE SET
  195. interaction_weight = interaction_weight + excluded.interaction_weight,
  196. created_at = excluded.created_at
  197. """,
  198. (str(date_key), keyword.lower(), category, weight, source, now),
  199. )
  200. def get_skipped_papers(
  201. db_path: str,
  202. *,
  203. days: int = 30,
  204. include_shown: bool = True,
  205. ) -> set[str]:
  206. cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - days * 86400))
  207. actions = ("skip", "shown") if include_shown else ("skip",)
  208. placeholders = ",".join("?" for _ in actions)
  209. with _conn(db_path) as conn:
  210. cur = conn.cursor()
  211. cur.execute(
  212. f"SELECT DISTINCT paper_identity_key FROM daily_recommend_feedback "
  213. f"WHERE date_key>=? AND action IN ({placeholders})",
  214. (cutoff, *actions),
  215. )
  216. skipped = {str(row[0]) for row in cur.fetchall()}
  217. return skipped
  218. def clear_daily_shown_for_date(db_path: str, date_key: str) -> int:
  219. """手动刷新时清除当日 shown 记录,避免候选池被永久锁死。"""
  220. with _conn(db_path) as conn:
  221. cur = conn.cursor()
  222. cur.execute(
  223. "DELETE FROM daily_recommend_feedback WHERE date_key=? AND action='shown'",
  224. (str(date_key),),
  225. )
  226. return int(cur.rowcount or 0)
  227. def record_daily_shown_papers(
  228. db_path: str,
  229. date_key: str,
  230. papers: list[dict[str, str]],
  231. ) -> None:
  232. if not papers:
  233. return
  234. now = int(time.time())
  235. with _conn(db_path) as conn:
  236. conn.cursor().executemany(
  237. """INSERT OR IGNORE INTO daily_recommend_feedback(date_key,paper_identity_key,identity_type,title,action,source_list,score_at_recommend,created_at)
  238. VALUES(?,?,'title_hash',?,'shown','daily',0.0,?)""",
  239. [(date_key, p.get("identity_key", ""), p.get("title", ""), now) for p in papers],
  240. )
  241. def get_high_value_keywords_from_feedback(
  242. db_path: str,
  243. *,
  244. days: int = 21,
  245. top_n: int = 20,
  246. ) -> set[str]:
  247. cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - days * 86400))
  248. with _conn(db_path) as conn:
  249. cur = conn.cursor()
  250. cur.execute(
  251. "SELECT title FROM daily_recommend_feedback WHERE date_key>=? AND action IN ('click','save','read') AND title IS NOT NULL ORDER BY created_at DESC LIMIT 200",
  252. (cutoff,),
  253. )
  254. titles = [str(row[0]) for row in cur.fetchall() if row[0]]
  255. def _extract_tokens(text: str) -> list[str]:
  256. t = (text or "").lower()
  257. t = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", t)
  258. tokens = [x.strip() for x in t.split() if x.strip()]
  259. stop = {
  260. "the", "a", "an", "and", "or", "of", "to", "in", "for", "with", "on",
  261. "we", "our", "is", "are", "be", "via", "from", "this", "that",
  262. "using", "use", "based", "towards", "paper", "propose", "method",
  263. "learning", "network", "model", "deep", "neural",
  264. }
  265. return [x for x in tokens if x not in stop and len(x) >= 4][:50]
  266. all_tokens = []
  267. for t in titles:
  268. all_tokens.extend(_extract_tokens(t))
  269. freq = Counter(all_tokens)
  270. top_keywords = {w for w, _ in freq.most_common(top_n)}
  271. return top_keywords