daily_service.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. """每日论文推荐服务 —— arXiv 新论文拉取、个性化推荐与缓存管理."""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. import logging
  6. import re
  7. from typing import Any
  8. from fastapi import HTTPException
  9. from fastapi.responses import Response
  10. from starlette.concurrency import run_in_threadpool
  11. from ...agents import get_search_agent
  12. from ...core.storage import PaperDatabase
  13. from ...models.schemas import DailyPapersRequest, DailyPapersResponse
  14. from ..daily.daily_cache_store import get_cache, set_cache
  15. from ..daily.daily_recommend_store import record_arxiv_recommendations
  16. from ..daily.daily_recommend_feedback import record_feedback
  17. from ..daily.daily_support import (
  18. extract_library_characteristics,
  19. fetch_external_candidates,
  20. get_or_load_user_context,
  21. llm_arxiv_categories,
  22. )
  23. from ..feedback.negative_feedback_memory import (
  24. maybe_promote_longterm_from_recent_skips,
  25. record_skip_negative_pref,
  26. )
  27. from ..llm.llm_service import coerce_hello_agents_llm_output_to_str, get_llm, is_llm_configured
  28. from ..retrieval.recall_jobs import dedupe_papers
  29. logger = logging.getLogger(__name__)
  30. def _select_personalized_and_general(
  31. *,
  32. candidates: list[Any],
  33. external_unique: list[Any],
  34. personalized_k: int,
  35. general_k: int,
  36. skipped_papers: set[str],
  37. mem_kw: set[str],
  38. daily_paper_identity_sig_fn: Any,
  39. diversify: bool = False,
  40. ) -> tuple[list[Any], list[Any]]:
  41. _identity = daily_paper_identity_sig_fn
  42. skip_sigs = set(skipped_papers or set())
  43. pool = [p for p in (candidates or []) if _identity(p) not in skip_sigs]
  44. if len(pool) < personalized_k + general_k:
  45. for p in external_unique or []:
  46. if _identity(p) not in skip_sigs:
  47. pool.append(p)
  48. if not pool:
  49. return [], []
  50. if diversify:
  51. import random
  52. random.shuffle(pool)
  53. else:
  54. pool.sort(key=lambda p: getattr(p, "year", 0) or 0, reverse=True)
  55. p_idxs = list(range(min(personalized_k, len(pool))))
  56. g_idxs = list(range(min(personalized_k, len(pool)), min(personalized_k + general_k, len(pool))))
  57. try:
  58. if is_llm_configured():
  59. papers_info = [
  60. {
  61. "idx": i,
  62. "title": str(getattr(p, "title", "") or "")[:200],
  63. "abstract": str(getattr(p, "abstract", "") or "")[:400],
  64. "year": getattr(p, "year", None),
  65. }
  66. for i, p in enumerate(pool)
  67. ]
  68. pref_keywords = sorted({str(k).strip().lower() for k in (mem_kw or set()) if str(k).strip()})[:30]
  69. prompt = json.dumps(
  70. {
  71. "task": f"Select {personalized_k} personalized and {general_k} exploration papers",
  72. "interests": pref_keywords,
  73. "candidates": papers_info,
  74. },
  75. ensure_ascii=False,
  76. )
  77. pick_temp = 0.55 if diversify else 0.2
  78. raw = coerce_hello_agents_llm_output_to_str(
  79. get_llm().invoke([{"role": "user", "content": prompt}], temperature=pick_temp, max_tokens=400)
  80. )
  81. data = json.loads(raw.strip().lstrip("```json").rstrip("```").strip())
  82. llm_p = [int(i) for i in (data.get("personalized") or [])[:personalized_k] if 0 <= int(i) < len(pool)]
  83. llm_g = [
  84. int(i)
  85. for i in (data.get("general") or [])[:general_k]
  86. if 0 <= int(i) < len(pool) and int(i) not in llm_p
  87. ]
  88. if llm_p or llm_g:
  89. p_idxs, g_idxs = llm_p, llm_g
  90. except Exception:
  91. pass
  92. return [pool[i] for i in p_idxs], [pool[i] for i in g_idxs]
  93. async def _record_daily_recommendations(
  94. *,
  95. db_path: str,
  96. date_key: str,
  97. personalized_final: list[Any],
  98. general_selected: list[Any],
  99. log: Any,
  100. ) -> None:
  101. try:
  102. items: list[tuple[str | None, str]] = []
  103. for p in personalized_final:
  104. items.append((getattr(p, "arxiv_id", None), f"[P] {getattr(p, 'title', '') or ''}"))
  105. for p in general_selected:
  106. items.append((getattr(p, "arxiv_id", None), f"[G] {getattr(p, 'title', '') or ''}"))
  107. if items:
  108. await run_in_threadpool(
  109. record_arxiv_recommendations, db_path, date_key=str(date_key), items=items,
  110. )
  111. except Exception as ex:
  112. log.debug("记录推荐列表失败: %s", ex)
  113. _MAX_TITLES = 18
  114. _TITLE_MAX_CHARS = 220
  115. _TAG_MAX_LEN = 28
  116. _TAGS_MAX_EACH = 5
  117. def titles_for_daily_theme_prompt(papers: list[Any]) -> list[str]:
  118. out: list[str] = []
  119. for p in papers[:_MAX_TITLES]:
  120. t = str(getattr(p, "title", None) or "").strip()
  121. if not t:
  122. continue
  123. if len(t) > _TITLE_MAX_CHARS:
  124. t = t[: _TITLE_MAX_CHARS - 1] + "\u2026"
  125. out.append(t)
  126. return out
  127. def _strip_json_fence(text: str) -> str:
  128. s = (text or "").strip()
  129. m = re.search(r"```(?:json)?\s*([\s\S]*?)```", s, re.I)
  130. return m.group(1).strip() if m else s
  131. def _sanitize_tags(raw: Any, *, max_n: int) -> list[str]:
  132. if not isinstance(raw, list):
  133. return []
  134. seen, out = set(), []
  135. for x in raw:
  136. if not isinstance(x, str):
  137. continue
  138. s = " ".join(x.split())
  139. if len(s) < 2 or len(s) > _TAG_MAX_LEN or s.casefold() in seen:
  140. continue
  141. seen.add(s.casefold())
  142. out.append(s)
  143. if len(out) >= max_n:
  144. break
  145. return out
  146. def summarize_daily_theme_keywords_sync(
  147. *,
  148. personalized_titles: list[str],
  149. general_titles: list[str],
  150. max_each: int = _TAGS_MAX_EACH,
  151. ) -> tuple[list[str], list[str]]:
  152. if not is_llm_configured() or (not personalized_titles and not general_titles):
  153. return [], []
  154. try:
  155. llm = get_llm()
  156. except Exception as e:
  157. logger.warning("daily_theme_keywords: llm init failed: %s", e)
  158. return [], []
  159. lines_p = "\n".join(f"- {t}" for t in personalized_titles) or "\uff08\u65e0\uff09"
  160. lines_g = "\n".join(f"- {t}" for t in general_titles) or "\uff08\u65e0\uff09"
  161. prompt = f"""\u4f60\u662f\u5b66\u672f\u6587\u732e\u63a8\u8350\u4ea7\u54c1\u7684\u6587\u6848\u52a9\u624b\u3002\u4e0b\u9762\u4e24\u7ec4\u6807\u9898\u5206\u522b\u6765\u81ea\u300c\u4e2a\u6027\u5316\u63a8\u8350\u300d\u4e0e\u300c\u5f53\u65e5\u7cbe\u9009\uff08\u968f\u673a\u63a2\u7d22\uff09\u300d\u4e24\u680f\u8bba\u6587\u3002
  162. \u3010\u4e2a\u6027\u5316\u63a8\u8350\u3011\u9898\u540d\uff1a
  163. {lines_p}
  164. \u3010\u5f53\u65e5\u7cbe\u9009\u3011\u9898\u540d\uff1a
  165. {lines_g}
  166. \u8bf7\u4e3a\u6bcf\u4e00\u7ec4\u5404\u63d0\u70bc\u4e0d\u8d85\u8fc7 {max_each} \u4e2a\u300c\u4e3b\u9898\u6807\u7b7e\u300d\uff0c\u7528\u4e8e\u754c\u9762\u6807\u7b7e\u5c55\u793a\u3002
  167. \u89c4\u5219\uff1a
  168. 1. \u6bcf\u4e2a\u6807\u7b7e 2\uff5e14 \u4e2a\u6c49\u5b57\u6216\u76f8\u5f53\u957f\u5ea6\uff1b\u53ef\u542b\u5fc5\u8981\u82f1\u6587\u7f29\u7565\u8bcd\uff08\u5982 LLM\u3001RL\u3001GNN\uff09\uff1b\u4e0d\u8981\u6574\u53e5\u590d\u5236\u539f\u6807\u9898\u3002
  169. 2. \u6982\u62ec\u8be5\u7ec4\u5171\u540c\u7684\u7814\u7a76\u65b9\u5411\u6216\u65b9\u6cd5\uff1b\u7ec4\u5185\u6807\u7b7e\u5c3d\u91cf\u4e0d\u91cd\u590d\u3002
  170. 3. \u53ea\u8f93\u51fa\u4e00\u6bb5\u4e25\u683c JSON\uff08\u4e0d\u8981 markdown \u56f4\u680f\u3001\u4e0d\u8981\u524d\u540e\u8bf4\u660e\uff09\uff0c\u683c\u5f0f\u56fa\u5b9a\u4e3a\uff1a
  171. {{\\"personalized\\":[\\"\u2026\\"],\\"general\\":[\\"\u2026\\"]}}
  172. \u952e\u540d\u5fc5\u987b\u4e3a\u82f1\u6587\uff1b\u82e5\u67d0\u7ec4\u65e0\u6709\u6548\u6807\u9898\u5219\u5bf9\u5e94\u6570\u7ec4\u4e3a []\u3002"""
  173. try:
  174. raw = coerce_hello_agents_llm_output_to_str(
  175. llm.invoke([{"role": "user", "content": prompt}], temperature=0.15, max_tokens=420)
  176. )
  177. except Exception as e:
  178. logger.warning("daily_theme_keywords: invoke failed: %s", e)
  179. return [], []
  180. try:
  181. data = json.loads(_strip_json_fence(raw))
  182. except json.JSONDecodeError:
  183. logger.warning("daily_theme_keywords: json parse failed, head=%r", (raw or "")[:240])
  184. return [], []
  185. if not isinstance(data, dict):
  186. return [], []
  187. return (
  188. _sanitize_tags(data.get("personalized"), max_n=max_each),
  189. _sanitize_tags(data.get("general"), max_n=max_each),
  190. )
  191. def _build_strategy_explanation(
  192. *, agent: Any, n_personalized: int, n_general: int, n_candidates: int, n_memory_kw: int,
  193. ) -> str:
  194. fallback = f"个性化{n_personalized}篇 · 通用{n_general}篇 · 候选{n_candidates}篇"
  195. if not is_llm_configured():
  196. return fallback
  197. prompt = (
  198. f"用一句话中文概括今日论文推荐策略:个性化{n_personalized}篇、通用{n_general}篇,"
  199. f"候选池{n_candidates}篇,记忆词{n_memory_kw}条参与"
  200. )
  201. try:
  202. raw = agent.llm.invoke([{"role": "user", "content": prompt}], temperature=0.3, max_tokens=80)
  203. return coerce_hello_agents_llm_output_to_str(raw).strip()[:200]
  204. except Exception:
  205. return fallback
  206. def _build_pick_hints(personalized_final, general_selected, _identity):
  207. from ...models.schemas import DailyPaperPickHint
  208. hints_p = [
  209. DailyPaperPickHint(identity_key=_identity(p), pick_kind="personalized", explanation="基于用户兴趣匹配")
  210. for p in personalized_final
  211. ]
  212. hints_g = [
  213. DailyPaperPickHint(identity_key=_identity(p), pick_kind="general", explanation="多样性探索推荐")
  214. for p in general_selected
  215. ]
  216. return hints_p, hints_g
  217. async def _build_daily_response(
  218. *,
  219. date_key,
  220. external_unique,
  221. general_selected,
  222. personalized_final,
  223. candidates,
  224. personalized_k,
  225. mem_kw_n,
  226. mem_kw_list,
  227. use_llm_theme_keywords,
  228. agent,
  229. daily_paper_identity_sig_fn,
  230. papergraph_to_api_fn,
  231. db_path,
  232. ) -> DailyPapersResponse:
  233. _to_api = papergraph_to_api_fn
  234. _identity = daily_paper_identity_sig_fn
  235. strategy_explanation = _build_strategy_explanation(
  236. agent=agent,
  237. n_personalized=len(personalized_final),
  238. n_general=len(general_selected),
  239. n_candidates=len(candidates),
  240. n_memory_kw=mem_kw_n,
  241. )
  242. hints_p, hints_g = _build_pick_hints(personalized_final, general_selected, _identity)
  243. p_theme: list[str] = []
  244. g_theme: list[str] = []
  245. if use_llm_theme_keywords:
  246. try:
  247. p_theme, g_theme = await run_in_threadpool(
  248. summarize_daily_theme_keywords_sync,
  249. personalized_titles=titles_for_daily_theme_prompt(personalized_final),
  250. general_titles=titles_for_daily_theme_prompt(general_selected),
  251. )
  252. except Exception as ex:
  253. logger.warning("每日主题关键词 LLM 总结失败: %s", ex)
  254. resp = DailyPapersResponse(
  255. success=True,
  256. date_key=date_key,
  257. arxiv_latest_total=len(external_unique),
  258. arxiv_selected_total=len(general_selected),
  259. personalized_total=len(personalized_final),
  260. arxiv_latest=[_to_api(p) for p in external_unique[:20]],
  261. arxiv_selected=[_to_api(p) for p in general_selected],
  262. personalized=[_to_api(p) for p in personalized_final],
  263. message=(
  264. f"候选 {len(candidates)} · 个性化 {len(personalized_final)}/{personalized_k} · "
  265. f"通用 {len(general_selected)}"
  266. + (f" · 记忆词 {mem_kw_n}" if mem_kw_n else "")
  267. ),
  268. memory_keywords_used=mem_kw_list,
  269. strategy_explanation=strategy_explanation,
  270. personalized_theme_keywords=p_theme,
  271. general_theme_keywords=g_theme,
  272. personalized_pick_hints=hints_p,
  273. general_pick_hints=hints_g,
  274. )
  275. try:
  276. from .daily_recommend_feedback import record_daily_shown_papers
  277. shown: list[dict[str, str]] = []
  278. for p in (resp.arxiv_selected or []) + (resp.personalized or []):
  279. title = (getattr(p, "title", None) or "").strip()
  280. if not title and isinstance(p, dict):
  281. title = str(p.get("title", "") or "").strip()
  282. if title:
  283. shown.append({"identity_key": str(_identity(p)), "title": title})
  284. if shown:
  285. logger.info("记录 %d 篇已推荐论文,下次刷新将排除", len(shown))
  286. await run_in_threadpool(record_daily_shown_papers, str(db_path), date_key, shown)
  287. except Exception as ex:
  288. logger.warning("记录已推荐论文失败: %s", ex)
  289. try:
  290. await run_in_threadpool(
  291. set_cache, db_path, date_key=date_key, cache_key="default", payload=resp.model_dump(mode="json")
  292. )
  293. except Exception as ex:
  294. logger.warning("每日论文缓存写入失败: %s", ex)
  295. return resp
  296. async def read_daily_cached_or_204(*, db_path: str) -> Response:
  297. import datetime as _dt
  298. date_key = _dt.datetime.now().strftime("%Y-%m-%d")
  299. try:
  300. cached = await run_in_threadpool(get_cache, db_path, date_key=date_key, cache_key="default")
  301. if not cached:
  302. return Response(status_code=204)
  303. return DailyPapersResponse(**cached)
  304. except Exception as e:
  305. raise HTTPException(status_code=500, detail=str(e))
  306. async def compute_daily_papers(
  307. *,
  308. body: DailyPapersRequest,
  309. db_path: str,
  310. searcher: Any,
  311. daily_paper_identity_sig_fn: Any,
  312. logger: Any,
  313. daily_arxiv_cs_categories: list[str],
  314. papergraph_to_api_fn: Any,
  315. ) -> DailyPapersResponse:
  316. _identity = daily_paper_identity_sig_fn
  317. try:
  318. import datetime
  319. date_key = datetime.datetime.now().strftime("%Y-%m-%d")
  320. force_refresh = bool(getattr(body, "force_refresh", False))
  321. try:
  322. _dbv = int(body.days_back if body.days_back is not None else 0)
  323. except (TypeError, ValueError):
  324. _dbv = 0
  325. days_back = max(0, min(9999, _dbv)) if _dbv > 0 else 1
  326. total_target = 30
  327. try:
  328. personalized_k = max(0, min(int(body.personalized_k if body.personalized_k is not None else 20), total_target))
  329. except (TypeError, ValueError):
  330. personalized_k = 20
  331. general_k = max(0, min(25, total_target - personalized_k))
  332. agent = get_search_agent()
  333. try:
  334. lib_lim = max(50, min(3000, int(body.library_limit if body.library_limit is not None else 800)))
  335. except (TypeError, ValueError):
  336. lib_lim = 800
  337. logger.info(
  338. "每日论文:开始计算 date=%s force_refresh=%s lib_limit=%s",
  339. date_key, force_refresh, lib_lim,
  340. )
  341. if force_refresh:
  342. from .daily_support import invalidate_user_profile_cache
  343. from .daily_recommend_feedback import clear_daily_shown_for_date
  344. invalidate_user_profile_cache()
  345. cleared = await run_in_threadpool(clear_daily_shown_for_date, str(db_path), date_key)
  346. if cleared:
  347. logger.info("每日论文:force_refresh 已清除当日 shown 记录 %s 条", cleared)
  348. library_papers = await run_in_threadpool(
  349. PaperDatabase(db_path).get_all_papers, limit=lib_lim, order_by="created_at DESC",
  350. )
  351. lib_ids = [int(getattr(p, "id", 0) or 0) for p in library_papers if int(getattr(p, "id", 0) or 0) > 0]
  352. (mem_kw, mem_kw_n, mem_kw_list, skipped_papers), (_, lib_kw) = await asyncio.gather(
  353. get_or_load_user_context(
  354. db_path=db_path,
  355. lib_ids=lib_ids,
  356. log=logger,
  357. force_reload=force_refresh,
  358. include_shown_exclusions=not force_refresh,
  359. ),
  360. run_in_threadpool(extract_library_characteristics, library_papers),
  361. )
  362. llm_categories = llm_arxiv_categories(agent, mem_kw_list, daily_arxiv_cs_categories)
  363. all_external, source_counts, _arxiv_query = await fetch_external_candidates(
  364. searcher=searcher,
  365. mem_kw=mem_kw,
  366. lib_kw=lib_kw,
  367. days_back=days_back,
  368. daily_arxiv_cs_categories=llm_categories,
  369. log=logger,
  370. exclude_sigs=skipped_papers,
  371. )
  372. logger.info(
  373. "每日论文:外源拉取完成 arxiv=%s external_raw=%s",
  374. source_counts.get("arxiv"),
  375. len(all_external),
  376. )
  377. external_unique = [
  378. p for p in dedupe_papers(all_external, identity_fn=_identity) if getattr(p, "title", None)
  379. ]
  380. candidates = list(external_unique)
  381. if len(candidates) < 30:
  382. logger.info("每日论文:候选不足(%s),放宽条件重新拉取", len(candidates))
  383. all_external2, _, _ = await fetch_external_candidates(
  384. searcher=searcher,
  385. mem_kw=mem_kw,
  386. lib_kw=lib_kw,
  387. days_back=max(7, days_back * 2),
  388. daily_arxiv_cs_categories=llm_categories,
  389. log=logger,
  390. exclude_sigs=skipped_papers,
  391. )
  392. seen_sigs = {_identity(p) for p in candidates}
  393. for p in dedupe_papers(all_external2, identity_fn=_identity):
  394. if getattr(p, "title", None) and _identity(p) not in seen_sigs:
  395. seen_sigs.add(_identity(p))
  396. candidates.append(p)
  397. logger.info("每日论文:补充拉取后候选=%s", len(candidates))
  398. if not candidates:
  399. return DailyPapersResponse(
  400. success=True,
  401. date_key=date_key,
  402. arxiv_latest_total=0,
  403. arxiv_selected_total=0,
  404. personalized_total=0,
  405. arxiv_latest=[],
  406. arxiv_selected=[],
  407. personalized=[],
  408. message="暂无可用论文推荐",
  409. memory_keywords_used=mem_kw_list,
  410. strategy_explanation="\n".join(
  411. [
  412. "候选不足,未形成当日推荐池",
  413. (f"记忆词约 {mem_kw_n} 个(下列为短词优先)" if mem_kw_n else "记忆词:无"),
  414. ]
  415. ),
  416. personalized_pick_hints=[],
  417. general_pick_hints=[],
  418. )
  419. personalized_final, general_selected = await run_in_threadpool(
  420. _select_personalized_and_general,
  421. candidates=candidates,
  422. external_unique=external_unique,
  423. personalized_k=personalized_k,
  424. general_k=general_k,
  425. skipped_papers=skipped_papers,
  426. mem_kw=mem_kw,
  427. daily_paper_identity_sig_fn=_identity,
  428. diversify=force_refresh,
  429. )
  430. await _record_daily_recommendations(
  431. db_path=db_path,
  432. date_key=date_key,
  433. personalized_final=personalized_final,
  434. general_selected=general_selected,
  435. log=logger,
  436. )
  437. return await _build_daily_response(
  438. date_key=date_key,
  439. external_unique=external_unique,
  440. general_selected=general_selected,
  441. personalized_final=personalized_final,
  442. candidates=candidates,
  443. personalized_k=personalized_k,
  444. mem_kw_n=mem_kw_n,
  445. mem_kw_list=mem_kw_list,
  446. use_llm_theme_keywords=getattr(body, "use_llm_theme_keywords", True),
  447. agent=agent,
  448. daily_paper_identity_sig_fn=_identity,
  449. papergraph_to_api_fn=papergraph_to_api_fn,
  450. db_path=db_path,
  451. )
  452. except Exception:
  453. logger.exception("daily_service.compute_daily_papers_failed")
  454. raise HTTPException(status_code=500, detail="daily papers failed")
  455. async def record_user_daily_feedback(*, body, db_path) -> Any:
  456. import datetime
  457. from .daily_recommend_feedback import FeedbackAction
  458. from ...models.schemas import DailyRecommendFeedbackResponse
  459. date_key = datetime.datetime.now().strftime("%Y-%m-%d")
  460. identity_key = body.identity_key
  461. identity_type = "title_hash"
  462. if identity_key.startswith("arxiv:"):
  463. identity_type, identity_key = "arxiv", identity_key[6:]
  464. elif identity_key.startswith("doi:"):
  465. identity_type, identity_key = "doi", identity_key[4:]
  466. elif identity_key.startswith("title_hash:"):
  467. identity_type, identity_key = "title_hash", identity_key[11:]
  468. ok = await run_in_threadpool(
  469. record_feedback,
  470. db_path,
  471. date_key=date_key,
  472. paper_identity_key=identity_key,
  473. identity_type=identity_type,
  474. title=body.title,
  475. action=FeedbackAction(body.action),
  476. source_list=body.source_list,
  477. score_at_recommend=body.score_at_recommend,
  478. keywords=body.keywords,
  479. category=body.category,
  480. )
  481. if str(body.action) == "skip":
  482. try:
  483. await run_in_threadpool(
  484. record_skip_negative_pref,
  485. db_path,
  486. identity_key=body.identity_key,
  487. title=str(body.title or ""),
  488. abstract=None,
  489. journal=body.journal,
  490. source=body.source,
  491. keywords=body.keywords,
  492. category=body.category,
  493. ttl_days=14,
  494. )
  495. await run_in_threadpool(
  496. maybe_promote_longterm_from_recent_skips,
  497. db_path,
  498. window_days=30,
  499. min_count=5,
  500. min_confidence=0.6,
  501. max_new_rules=2,
  502. )
  503. except Exception:
  504. pass
  505. return DailyRecommendFeedbackResponse(success=ok, message="反馈已记录" if ok else "记录失败")