negative_feedback_memory.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """负反馈记忆 —— 记录用户不感兴趣的论文/主题,优化后续推荐."""
  2. from __future__ import annotations
  3. import json
  4. import sqlite3
  5. import time
  6. from typing import Any
  7. from ..llm.agent_runtime import run_json_task
  8. from ..llm.llm_service import get_llm
  9. from ...utils.common import exec_sql
  10. def ensure_tables(db_path: str) -> None:
  11. exec_sql(db_path,
  12. """CREATE TABLE IF NOT EXISTS negative_pref_memory (
  13. id INTEGER PRIMARY KEY AUTOINCREMENT,
  14. created_at INTEGER NOT NULL,
  15. expires_at INTEGER NOT NULL,
  16. identity_key TEXT,
  17. title TEXT,
  18. payload_json TEXT NOT NULL
  19. )""",
  20. "CREATE INDEX IF NOT EXISTS idx_negpref_exp ON negative_pref_memory(expires_at)",
  21. """CREATE TABLE IF NOT EXISTS negative_pref_longterm (
  22. id INTEGER PRIMARY KEY AUTOINCREMENT,
  23. kind TEXT NOT NULL,
  24. value TEXT NOT NULL,
  25. weight REAL DEFAULT -0.2,
  26. created_at INTEGER NOT NULL,
  27. last_triggered_at INTEGER NOT NULL,
  28. trigger_count INTEGER DEFAULT 0,
  29. disabled INTEGER DEFAULT 0,
  30. evidence_json TEXT
  31. )""",
  32. "CREATE UNIQUE INDEX IF NOT EXISTS ux_negpref_longterm_kind_val ON negative_pref_longterm(kind, value)",
  33. "CREATE INDEX IF NOT EXISTS idx_negpref_longterm_disabled ON negative_pref_longterm(disabled, last_triggered_at)",
  34. )
  35. def _extract_pref_dims(payload: dict[str, Any]) -> dict[str, list[str]]:
  36. return {
  37. "topic": [str(x).strip().lower() for x in (payload.get("topics_to_downrank") or []) if str(x).strip()],
  38. "subdomain": [str(x).strip().lower() for x in (payload.get("subdomains_to_downrank") or []) if str(x).strip()],
  39. "style": [str(x).strip().lower() for x in (payload.get("styles_to_downrank") or []) if str(x).strip()],
  40. "venue": [str(x).strip().lower() for x in (payload.get("venues_to_downrank") or []) if str(x).strip()],
  41. "source": [str(x).strip().lower() for x in (payload.get("sources_to_downrank") or []) if str(x).strip()],
  42. }
  43. def maybe_promote_longterm_from_recent_skips(
  44. db_path: str,
  45. *,
  46. window_days: int = 30,
  47. min_count: int = 5,
  48. min_confidence: float = 0.6,
  49. max_new_rules: int = 2,
  50. ) -> list[tuple[str, str]]:
  51. ensure_tables(db_path)
  52. now = int(time.time())
  53. win = max(7, min(90, int(window_days))) * 86400
  54. since = now - win
  55. conn = sqlite3.connect(db_path)
  56. try:
  57. cur = conn.cursor()
  58. cur.execute(
  59. """SELECT created_at, title, payload_json
  60. FROM negative_pref_memory
  61. WHERE created_at >= ? ORDER BY created_at DESC LIMIT 1000""",
  62. (since,),
  63. )
  64. counts: dict[tuple[str, str], int] = {}
  65. evidences: dict[tuple[str, str], dict[str, Any]] = {}
  66. for created_at, title, payload_json in cur.fetchall():
  67. try:
  68. payload = json.loads(payload_json or "{}")
  69. except Exception:
  70. continue
  71. if not isinstance(payload, dict):
  72. continue
  73. conf = float(payload.get("confidence") or 0.0)
  74. if conf < float(min_confidence):
  75. continue
  76. dims = _extract_pref_dims(payload)
  77. for kind, vals in dims.items():
  78. for v in vals[:8]:
  79. vv = (v or "").strip().lower()[:64]
  80. if len(vv) < 2:
  81. continue
  82. key = (kind, vv)
  83. counts[key] = counts.get(key, 0) + 1
  84. if key not in evidences:
  85. evidences[key] = {
  86. "window_days": int(window_days),
  87. "min_confidence": float(min_confidence),
  88. "example_titles": [],
  89. "last_seen_at": int(created_at or 0),
  90. }
  91. if title and len(evidences[key]["example_titles"]) < 3:
  92. evidences[key]["example_titles"].append(str(title)[:160])
  93. evidences[key]["last_seen_at"] = max(int(evidences[key]["last_seen_at"]), int(created_at or 0))
  94. promoted: list[tuple[str, str]] = []
  95. items = sorted(counts.items(), key=lambda x: x[1], reverse=True)
  96. for (kind, value), cnt in items:
  97. if cnt < int(min_count):
  98. break
  99. if len(promoted) >= int(max_new_rules):
  100. break
  101. weight = -0.2
  102. ev = evidences.get((kind, value), {})
  103. ev["count"] = int(cnt)
  104. cur.execute(
  105. """INSERT INTO negative_pref_longterm(kind, value, weight, created_at, last_triggered_at, trigger_count, disabled, evidence_json)
  106. VALUES (?, ?, ?, ?, ?, ?, 0, ?)
  107. ON CONFLICT(kind, value) DO UPDATE SET
  108. last_triggered_at = excluded.last_triggered_at,
  109. trigger_count = COALESCE(negative_pref_longterm.trigger_count, 0) + 1,
  110. evidence_json = excluded.evidence_json""",
  111. (kind, value, float(weight), now, int(ev.get("last_seen_at") or now), 1, json.dumps(ev, ensure_ascii=False)),
  112. )
  113. promoted.append((kind, value))
  114. conn.commit()
  115. return promoted
  116. finally:
  117. conn.close()
  118. def record_skip_negative_pref(
  119. db_path: str,
  120. *,
  121. identity_key: str,
  122. title: str,
  123. abstract: str | None = None,
  124. journal: str | None = None,
  125. source: str | None = None,
  126. keywords: list[str | None] = None,
  127. category: str | None = None,
  128. ttl_days: int = 14,
  129. ) -> bool:
  130. ensure_tables(db_path)
  131. ttl = max(1, min(60, int(ttl_days or 14)))
  132. now = int(time.time())
  133. exp = now + ttl * 86400
  134. system_prompt = (
  135. "你是推荐系统的反馈分析器。用户点了「不感兴趣(skip)」。"
  136. "请把这一次 skip 总结成短期负偏好,用于未来 7-14 天轻微降权(不是硬过滤)。"
  137. "输出必须是 JSON,字段如下:\n"
  138. "- topics_to_downrank: string[](主题关键词,2-8 个)\n"
  139. "- subdomains_to_downrank: string[](子领域标签,0-5 个)\n"
  140. "- venues_to_downrank: string[](会议/期刊关键词,0-3 个)\n"
  141. "- sources_to_downrank: string[](arxiv/openalex/dblp,0-2 个)\n"
  142. "- styles_to_downrank: string[](survey/tutorial/benchmark/...,0-3 个)\n"
  143. "- confidence: number(0-1)\n"
  144. "规则:宁可少写,避免误伤;不要输出解释文字,只输出 JSON。"
  145. )
  146. prompt = json.dumps({
  147. "title": title, "abstract": (abstract or "")[:1200],
  148. "journal": journal or "", "source": source or "",
  149. "keywords": (keywords or [])[:20], "category": category or "",
  150. }, ensure_ascii=False)
  151. payload = run_json_task(
  152. task_name="negative_pref_summarizer", agent_name="neg_pref_summarizer",
  153. llm=get_llm(), system_prompt=system_prompt, user_prompt=prompt,
  154. timeout_sec=10.0, retries=1, default={},
  155. )
  156. payload_json = json.dumps(payload or {}, ensure_ascii=False)
  157. conn = sqlite3.connect(db_path)
  158. try:
  159. cur = conn.cursor()
  160. cur.execute(
  161. """INSERT INTO negative_pref_memory(created_at, expires_at, identity_key, title, payload_json)
  162. VALUES (?, ?, ?, ?, ?)""",
  163. (now, exp, (identity_key or "")[:160], (title or "")[:400], payload_json),
  164. )
  165. conn.commit()
  166. return True
  167. except Exception:
  168. return False
  169. finally:
  170. conn.close()