| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- """每日论文推荐服务 —— arXiv 新论文拉取、个性化推荐与缓存管理."""
- from __future__ import annotations
- import asyncio
- import json
- import logging
- import re
- from typing import Any
- from fastapi import HTTPException
- from fastapi.responses import Response
- from starlette.concurrency import run_in_threadpool
- from ...agents import get_search_agent
- from ...core.storage import PaperDatabase
- from ...models.schemas import DailyPapersRequest, DailyPapersResponse
- from ..daily.daily_cache_store import get_cache, set_cache
- from ..daily.daily_recommend_store import record_arxiv_recommendations
- from ..daily.daily_recommend_feedback import record_feedback
- from ..daily.daily_support import (
- extract_library_characteristics,
- fetch_external_candidates,
- get_or_load_user_context,
- llm_arxiv_categories,
- )
- from ..feedback.negative_feedback_memory import (
- maybe_promote_longterm_from_recent_skips,
- record_skip_negative_pref,
- )
- from ..llm.llm_service import coerce_hello_agents_llm_output_to_str, get_llm, is_llm_configured
- from ..retrieval.recall_jobs import dedupe_papers
- logger = logging.getLogger(__name__)
- def _select_personalized_and_general(
- *,
- candidates: list[Any],
- external_unique: list[Any],
- personalized_k: int,
- general_k: int,
- skipped_papers: set[str],
- mem_kw: set[str],
- daily_paper_identity_sig_fn: Any,
- diversify: bool = False,
- ) -> tuple[list[Any], list[Any]]:
- _identity = daily_paper_identity_sig_fn
- skip_sigs = set(skipped_papers or set())
- pool = [p for p in (candidates or []) if _identity(p) not in skip_sigs]
- if len(pool) < personalized_k + general_k:
- for p in external_unique or []:
- if _identity(p) not in skip_sigs:
- pool.append(p)
- if not pool:
- return [], []
- if diversify:
- import random
- random.shuffle(pool)
- else:
- pool.sort(key=lambda p: getattr(p, "year", 0) or 0, reverse=True)
- p_idxs = list(range(min(personalized_k, len(pool))))
- g_idxs = list(range(min(personalized_k, len(pool)), min(personalized_k + general_k, len(pool))))
- try:
- if is_llm_configured():
- papers_info = [
- {
- "idx": i,
- "title": str(getattr(p, "title", "") or "")[:200],
- "abstract": str(getattr(p, "abstract", "") or "")[:400],
- "year": getattr(p, "year", None),
- }
- for i, p in enumerate(pool)
- ]
- pref_keywords = sorted({str(k).strip().lower() for k in (mem_kw or set()) if str(k).strip()})[:30]
- prompt = json.dumps(
- {
- "task": f"Select {personalized_k} personalized and {general_k} exploration papers",
- "interests": pref_keywords,
- "candidates": papers_info,
- },
- ensure_ascii=False,
- )
- pick_temp = 0.55 if diversify else 0.2
- raw = coerce_hello_agents_llm_output_to_str(
- get_llm().invoke([{"role": "user", "content": prompt}], temperature=pick_temp, max_tokens=400)
- )
- data = json.loads(raw.strip().lstrip("```json").rstrip("```").strip())
- llm_p = [int(i) for i in (data.get("personalized") or [])[:personalized_k] if 0 <= int(i) < len(pool)]
- llm_g = [
- int(i)
- for i in (data.get("general") or [])[:general_k]
- if 0 <= int(i) < len(pool) and int(i) not in llm_p
- ]
- if llm_p or llm_g:
- p_idxs, g_idxs = llm_p, llm_g
- except Exception:
- pass
- return [pool[i] for i in p_idxs], [pool[i] for i in g_idxs]
- async def _record_daily_recommendations(
- *,
- db_path: str,
- date_key: str,
- personalized_final: list[Any],
- general_selected: list[Any],
- log: Any,
- ) -> None:
- try:
- items: list[tuple[str | None, str]] = []
- for p in personalized_final:
- items.append((getattr(p, "arxiv_id", None), f"[P] {getattr(p, 'title', '') or ''}"))
- for p in general_selected:
- items.append((getattr(p, "arxiv_id", None), f"[G] {getattr(p, 'title', '') or ''}"))
- if items:
- await run_in_threadpool(
- record_arxiv_recommendations, db_path, date_key=str(date_key), items=items,
- )
- except Exception as ex:
- log.debug("记录推荐列表失败: %s", ex)
- _MAX_TITLES = 18
- _TITLE_MAX_CHARS = 220
- _TAG_MAX_LEN = 28
- _TAGS_MAX_EACH = 5
- def titles_for_daily_theme_prompt(papers: list[Any]) -> list[str]:
- out: list[str] = []
- for p in papers[:_MAX_TITLES]:
- t = str(getattr(p, "title", None) or "").strip()
- if not t:
- continue
- if len(t) > _TITLE_MAX_CHARS:
- t = t[: _TITLE_MAX_CHARS - 1] + "\u2026"
- out.append(t)
- return out
- def _strip_json_fence(text: str) -> str:
- s = (text or "").strip()
- m = re.search(r"```(?:json)?\s*([\s\S]*?)```", s, re.I)
- return m.group(1).strip() if m else s
- def _sanitize_tags(raw: Any, *, max_n: int) -> list[str]:
- if not isinstance(raw, list):
- return []
- seen, out = set(), []
- for x in raw:
- if not isinstance(x, str):
- continue
- s = " ".join(x.split())
- if len(s) < 2 or len(s) > _TAG_MAX_LEN or s.casefold() in seen:
- continue
- seen.add(s.casefold())
- out.append(s)
- if len(out) >= max_n:
- break
- return out
- def summarize_daily_theme_keywords_sync(
- *,
- personalized_titles: list[str],
- general_titles: list[str],
- max_each: int = _TAGS_MAX_EACH,
- ) -> tuple[list[str], list[str]]:
- if not is_llm_configured() or (not personalized_titles and not general_titles):
- return [], []
- try:
- llm = get_llm()
- except Exception as e:
- logger.warning("daily_theme_keywords: llm init failed: %s", e)
- return [], []
- lines_p = "\n".join(f"- {t}" for t in personalized_titles) or "\uff08\u65e0\uff09"
- lines_g = "\n".join(f"- {t}" for t in general_titles) or "\uff08\u65e0\uff09"
- 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
- \u3010\u4e2a\u6027\u5316\u63a8\u8350\u3011\u9898\u540d\uff1a
- {lines_p}
- \u3010\u5f53\u65e5\u7cbe\u9009\u3011\u9898\u540d\uff1a
- {lines_g}
- \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
- \u89c4\u5219\uff1a
- 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
- 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
- 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
- {{\\"personalized\\":[\\"\u2026\\"],\\"general\\":[\\"\u2026\\"]}}
- \u952e\u540d\u5fc5\u987b\u4e3a\u82f1\u6587\uff1b\u82e5\u67d0\u7ec4\u65e0\u6709\u6548\u6807\u9898\u5219\u5bf9\u5e94\u6570\u7ec4\u4e3a []\u3002"""
- try:
- raw = coerce_hello_agents_llm_output_to_str(
- llm.invoke([{"role": "user", "content": prompt}], temperature=0.15, max_tokens=420)
- )
- except Exception as e:
- logger.warning("daily_theme_keywords: invoke failed: %s", e)
- return [], []
- try:
- data = json.loads(_strip_json_fence(raw))
- except json.JSONDecodeError:
- logger.warning("daily_theme_keywords: json parse failed, head=%r", (raw or "")[:240])
- return [], []
- if not isinstance(data, dict):
- return [], []
- return (
- _sanitize_tags(data.get("personalized"), max_n=max_each),
- _sanitize_tags(data.get("general"), max_n=max_each),
- )
- def _build_strategy_explanation(
- *, agent: Any, n_personalized: int, n_general: int, n_candidates: int, n_memory_kw: int,
- ) -> str:
- fallback = f"个性化{n_personalized}篇 · 通用{n_general}篇 · 候选{n_candidates}篇"
- if not is_llm_configured():
- return fallback
- prompt = (
- f"用一句话中文概括今日论文推荐策略:个性化{n_personalized}篇、通用{n_general}篇,"
- f"候选池{n_candidates}篇,记忆词{n_memory_kw}条参与"
- )
- try:
- raw = agent.llm.invoke([{"role": "user", "content": prompt}], temperature=0.3, max_tokens=80)
- return coerce_hello_agents_llm_output_to_str(raw).strip()[:200]
- except Exception:
- return fallback
- def _build_pick_hints(personalized_final, general_selected, _identity):
- from ...models.schemas import DailyPaperPickHint
- hints_p = [
- DailyPaperPickHint(identity_key=_identity(p), pick_kind="personalized", explanation="基于用户兴趣匹配")
- for p in personalized_final
- ]
- hints_g = [
- DailyPaperPickHint(identity_key=_identity(p), pick_kind="general", explanation="多样性探索推荐")
- for p in general_selected
- ]
- return hints_p, hints_g
- async def _build_daily_response(
- *,
- date_key,
- external_unique,
- general_selected,
- personalized_final,
- candidates,
- personalized_k,
- mem_kw_n,
- mem_kw_list,
- use_llm_theme_keywords,
- agent,
- daily_paper_identity_sig_fn,
- papergraph_to_api_fn,
- db_path,
- ) -> DailyPapersResponse:
- _to_api = papergraph_to_api_fn
- _identity = daily_paper_identity_sig_fn
- strategy_explanation = _build_strategy_explanation(
- agent=agent,
- n_personalized=len(personalized_final),
- n_general=len(general_selected),
- n_candidates=len(candidates),
- n_memory_kw=mem_kw_n,
- )
- hints_p, hints_g = _build_pick_hints(personalized_final, general_selected, _identity)
- p_theme: list[str] = []
- g_theme: list[str] = []
- if use_llm_theme_keywords:
- try:
- p_theme, g_theme = await run_in_threadpool(
- summarize_daily_theme_keywords_sync,
- personalized_titles=titles_for_daily_theme_prompt(personalized_final),
- general_titles=titles_for_daily_theme_prompt(general_selected),
- )
- except Exception as ex:
- logger.warning("每日主题关键词 LLM 总结失败: %s", ex)
- resp = DailyPapersResponse(
- success=True,
- date_key=date_key,
- arxiv_latest_total=len(external_unique),
- arxiv_selected_total=len(general_selected),
- personalized_total=len(personalized_final),
- arxiv_latest=[_to_api(p) for p in external_unique[:20]],
- arxiv_selected=[_to_api(p) for p in general_selected],
- personalized=[_to_api(p) for p in personalized_final],
- message=(
- f"候选 {len(candidates)} · 个性化 {len(personalized_final)}/{personalized_k} · "
- f"通用 {len(general_selected)}"
- + (f" · 记忆词 {mem_kw_n}" if mem_kw_n else "")
- ),
- memory_keywords_used=mem_kw_list,
- strategy_explanation=strategy_explanation,
- personalized_theme_keywords=p_theme,
- general_theme_keywords=g_theme,
- personalized_pick_hints=hints_p,
- general_pick_hints=hints_g,
- )
- try:
- from .daily_recommend_feedback import record_daily_shown_papers
- shown: list[dict[str, str]] = []
- for p in (resp.arxiv_selected or []) + (resp.personalized or []):
- title = (getattr(p, "title", None) or "").strip()
- if not title and isinstance(p, dict):
- title = str(p.get("title", "") or "").strip()
- if title:
- shown.append({"identity_key": str(_identity(p)), "title": title})
- if shown:
- logger.info("记录 %d 篇已推荐论文,下次刷新将排除", len(shown))
- await run_in_threadpool(record_daily_shown_papers, str(db_path), date_key, shown)
- except Exception as ex:
- logger.warning("记录已推荐论文失败: %s", ex)
- try:
- await run_in_threadpool(
- set_cache, db_path, date_key=date_key, cache_key="default", payload=resp.model_dump(mode="json")
- )
- except Exception as ex:
- logger.warning("每日论文缓存写入失败: %s", ex)
- return resp
- async def read_daily_cached_or_204(*, db_path: str) -> Response:
- import datetime as _dt
- date_key = _dt.datetime.now().strftime("%Y-%m-%d")
- try:
- cached = await run_in_threadpool(get_cache, db_path, date_key=date_key, cache_key="default")
- if not cached:
- return Response(status_code=204)
- return DailyPapersResponse(**cached)
- except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
- async def compute_daily_papers(
- *,
- body: DailyPapersRequest,
- db_path: str,
- searcher: Any,
- daily_paper_identity_sig_fn: Any,
- logger: Any,
- daily_arxiv_cs_categories: list[str],
- papergraph_to_api_fn: Any,
- ) -> DailyPapersResponse:
- _identity = daily_paper_identity_sig_fn
- try:
- import datetime
- date_key = datetime.datetime.now().strftime("%Y-%m-%d")
- force_refresh = bool(getattr(body, "force_refresh", False))
- try:
- _dbv = int(body.days_back if body.days_back is not None else 0)
- except (TypeError, ValueError):
- _dbv = 0
- days_back = max(0, min(9999, _dbv)) if _dbv > 0 else 1
- total_target = 30
- try:
- personalized_k = max(0, min(int(body.personalized_k if body.personalized_k is not None else 20), total_target))
- except (TypeError, ValueError):
- personalized_k = 20
- general_k = max(0, min(25, total_target - personalized_k))
- agent = get_search_agent()
- try:
- lib_lim = max(50, min(3000, int(body.library_limit if body.library_limit is not None else 800)))
- except (TypeError, ValueError):
- lib_lim = 800
- logger.info(
- "每日论文:开始计算 date=%s force_refresh=%s lib_limit=%s",
- date_key, force_refresh, lib_lim,
- )
- if force_refresh:
- from .daily_support import invalidate_user_profile_cache
- from .daily_recommend_feedback import clear_daily_shown_for_date
- invalidate_user_profile_cache()
- cleared = await run_in_threadpool(clear_daily_shown_for_date, str(db_path), date_key)
- if cleared:
- logger.info("每日论文:force_refresh 已清除当日 shown 记录 %s 条", cleared)
- library_papers = await run_in_threadpool(
- PaperDatabase(db_path).get_all_papers, limit=lib_lim, order_by="created_at DESC",
- )
- lib_ids = [int(getattr(p, "id", 0) or 0) for p in library_papers if int(getattr(p, "id", 0) or 0) > 0]
- (mem_kw, mem_kw_n, mem_kw_list, skipped_papers), (_, lib_kw) = await asyncio.gather(
- get_or_load_user_context(
- db_path=db_path,
- lib_ids=lib_ids,
- log=logger,
- force_reload=force_refresh,
- include_shown_exclusions=not force_refresh,
- ),
- run_in_threadpool(extract_library_characteristics, library_papers),
- )
- llm_categories = llm_arxiv_categories(agent, mem_kw_list, daily_arxiv_cs_categories)
- all_external, source_counts, _arxiv_query = await fetch_external_candidates(
- searcher=searcher,
- mem_kw=mem_kw,
- lib_kw=lib_kw,
- days_back=days_back,
- daily_arxiv_cs_categories=llm_categories,
- log=logger,
- exclude_sigs=skipped_papers,
- )
- logger.info(
- "每日论文:外源拉取完成 arxiv=%s external_raw=%s",
- source_counts.get("arxiv"),
- len(all_external),
- )
- external_unique = [
- p for p in dedupe_papers(all_external, identity_fn=_identity) if getattr(p, "title", None)
- ]
- candidates = list(external_unique)
- if len(candidates) < 30:
- logger.info("每日论文:候选不足(%s),放宽条件重新拉取", len(candidates))
- all_external2, _, _ = await fetch_external_candidates(
- searcher=searcher,
- mem_kw=mem_kw,
- lib_kw=lib_kw,
- days_back=max(7, days_back * 2),
- daily_arxiv_cs_categories=llm_categories,
- log=logger,
- exclude_sigs=skipped_papers,
- )
- seen_sigs = {_identity(p) for p in candidates}
- for p in dedupe_papers(all_external2, identity_fn=_identity):
- if getattr(p, "title", None) and _identity(p) not in seen_sigs:
- seen_sigs.add(_identity(p))
- candidates.append(p)
- logger.info("每日论文:补充拉取后候选=%s", len(candidates))
- if not candidates:
- return DailyPapersResponse(
- success=True,
- date_key=date_key,
- arxiv_latest_total=0,
- arxiv_selected_total=0,
- personalized_total=0,
- arxiv_latest=[],
- arxiv_selected=[],
- personalized=[],
- message="暂无可用论文推荐",
- memory_keywords_used=mem_kw_list,
- strategy_explanation="\n".join(
- [
- "候选不足,未形成当日推荐池",
- (f"记忆词约 {mem_kw_n} 个(下列为短词优先)" if mem_kw_n else "记忆词:无"),
- ]
- ),
- personalized_pick_hints=[],
- general_pick_hints=[],
- )
- personalized_final, general_selected = await run_in_threadpool(
- _select_personalized_and_general,
- candidates=candidates,
- external_unique=external_unique,
- personalized_k=personalized_k,
- general_k=general_k,
- skipped_papers=skipped_papers,
- mem_kw=mem_kw,
- daily_paper_identity_sig_fn=_identity,
- diversify=force_refresh,
- )
- await _record_daily_recommendations(
- db_path=db_path,
- date_key=date_key,
- personalized_final=personalized_final,
- general_selected=general_selected,
- log=logger,
- )
- return await _build_daily_response(
- date_key=date_key,
- external_unique=external_unique,
- general_selected=general_selected,
- personalized_final=personalized_final,
- candidates=candidates,
- personalized_k=personalized_k,
- mem_kw_n=mem_kw_n,
- mem_kw_list=mem_kw_list,
- use_llm_theme_keywords=getattr(body, "use_llm_theme_keywords", True),
- agent=agent,
- daily_paper_identity_sig_fn=_identity,
- papergraph_to_api_fn=papergraph_to_api_fn,
- db_path=db_path,
- )
- except Exception:
- logger.exception("daily_service.compute_daily_papers_failed")
- raise HTTPException(status_code=500, detail="daily papers failed")
- async def record_user_daily_feedback(*, body, db_path) -> Any:
- import datetime
- from .daily_recommend_feedback import FeedbackAction
- from ...models.schemas import DailyRecommendFeedbackResponse
- date_key = datetime.datetime.now().strftime("%Y-%m-%d")
- identity_key = body.identity_key
- identity_type = "title_hash"
- if identity_key.startswith("arxiv:"):
- identity_type, identity_key = "arxiv", identity_key[6:]
- elif identity_key.startswith("doi:"):
- identity_type, identity_key = "doi", identity_key[4:]
- elif identity_key.startswith("title_hash:"):
- identity_type, identity_key = "title_hash", identity_key[11:]
- ok = await run_in_threadpool(
- record_feedback,
- db_path,
- date_key=date_key,
- paper_identity_key=identity_key,
- identity_type=identity_type,
- title=body.title,
- action=FeedbackAction(body.action),
- source_list=body.source_list,
- score_at_recommend=body.score_at_recommend,
- keywords=body.keywords,
- category=body.category,
- )
- if str(body.action) == "skip":
- try:
- await run_in_threadpool(
- record_skip_negative_pref,
- db_path,
- identity_key=body.identity_key,
- title=str(body.title or ""),
- abstract=None,
- journal=body.journal,
- source=body.source,
- keywords=body.keywords,
- category=body.category,
- ttl_days=14,
- )
- await run_in_threadpool(
- maybe_promote_longterm_from_recent_skips,
- db_path,
- window_days=30,
- min_count=5,
- min_confidence=0.6,
- max_new_rules=2,
- )
- except Exception:
- pass
- return DailyRecommendFeedbackResponse(success=ok, message="反馈已记录" if ok else "记录失败")
|