From e54d657c4a81576274b78bc94d1ebddaafc8a98b Mon Sep 17 00:00:00 2001 From: Color2333 <1552429809@qq.com> Date: Fri, 17 Jul 2026 20:33:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=E6=8E=A8=E8=8D=90=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=8D=B3=E6=97=B6=E5=A4=B1=E6=95=88=20+=20=E7=9B=B8?= =?UTF-8?q?=E4=BC=BC/=E6=9F=A5=E9=87=8D/=E5=BC=95=E7=94=A8=E8=A1=A5?= =?UTF-8?q?=E5=BC=BA=E6=8E=92=E9=99=A4=20rejected=20+=20cluster=5Fmap=20?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR1 — 后端一致性/性能/缓存基线 - recommendation_service: 新增 invalidate_recommendations(),按前缀清 recommend:* 与 today_summary 缓存 - papers.toggle_reject: 调用 invalidate_recommendations(),负反馈立即生效 (此前被拒论文最长 5min 仍留在推荐列表) - papers.similar / duplicates: get_by_id 抛 ValueError 统一转 404(此前 500) - repositories.similar_by_embedding / semantic_candidates: PG+SQLite 分支 均加 Paper.rejected.is_(False),与推荐策略对齐 - graph.cluster_map / similarity_map: 改 async + run_in_threadpool + 60s 缓存, 避免全库 k-means/UMAP 阻塞 ASGI 事件循环 - graph.similar_via_citation: paper_id 改 UUID 校验;论文不存在转 404 - similarity.similar_via_citation: co-citation 扩张加 LIMIT(200/2000/500) 防高被引 hub 无界扩张 OOM;候选加 rejected 过滤;去掉 if not seed 死代码 (get_by_id 已 raise) --- apps/api/routers/graph.py | 39 +++++++++++++++++++------- apps/api/routers/papers.py | 17 +++++++++-- packages/ai/graph/similarity.py | 21 ++++++++------ packages/ai/recommendation_service.py | 11 ++++++++ packages/storage/repositories/paper.py | 4 +++ 5 files changed, 72 insertions(+), 20 deletions(-) diff --git a/apps/api/routers/graph.py b/apps/api/routers/graph.py index 162e509..9231128 100644 --- a/apps/api/routers/graph.py +++ b/apps/api/routers/graph.py @@ -4,7 +4,8 @@ from uuid import UUID -from fastapi import APIRouter, Query +from fastapi import APIRouter, HTTPException, Query +from fastapi.concurrency import run_in_threadpool from apps.api.deps import cache, get_paper_title, graph_service from packages.domain.task_tracker import global_tracker @@ -100,30 +101,48 @@ def _fn(progress_callback=None): @router.get("/graph/similarity-map") -def similarity_map( +async def similarity_map( topic_id: str | None = None, limit: int = Query(default=200, ge=5, le=500), ) -> dict: - """论文相似度 2D 散点图(UMAP 降维)""" - return graph_service.similarity_map(topic_id=topic_id, limit=limit) + """论文相似度 2D 散点图(UMAP 降维,60s 缓存,线程池执行避免阻塞事件循环)""" + cache_key = f"graph_similarity_map_{topic_id}_{limit}" + cached = cache.get(cache_key) + if cached is not None: + return cached + result = await run_in_threadpool(graph_service.similarity_map, topic_id=topic_id, limit=limit) + cache.set(cache_key, result, ttl=60) + return result @router.get("/graph/cluster-map") -def cluster_map( +async def cluster_map( n_clusters: int = Query(default=12, ge=2, le=30), limit: int = Query(default=5000, ge=10, le=20000), ) -> dict: - """全库论文 embedding k-means 聚类(研究领域地图,每簇用 keywords 自动命名)""" - return graph_service.cluster_map(n_clusters=n_clusters, limit=limit) + """全库论文 embedding k-means 聚类(研究领域地图,60s 缓存,线程池执行)""" + cache_key = f"graph_cluster_map_{n_clusters}_{limit}" + cached = cache.get(cache_key) + if cached is not None: + return cached + result = await run_in_threadpool(graph_service.cluster_map, n_clusters=n_clusters, limit=limit) + cache.set(cache_key, result, ttl=60) + return result @router.get("/graph/similar-via-citation/{paper_id}") -def similar_via_citation( - paper_id: str, +async def similar_via_citation( + paper_id: UUID, top_k: int = Query(default=5, ge=1, le=20), ) -> dict: """引用同一篇论文且语义相近的论文(co-citation + 向量补强)""" - return graph_service.similar_via_citation(paper_id=paper_id, top_k=top_k) + try: + return await run_in_threadpool( + graph_service.similar_via_citation, paper_id=str(paper_id), top_k=top_k + ) + except ValueError as exc: + # get_by_id 在论文不存在时抛 ValueError,统一转 404(此前返回 500) + raise HTTPException(status_code=404, detail=str(exc)) from exc @router.get("/graph/citation-tree/{paper_id}") diff --git a/apps/api/routers/papers.py b/apps/api/routers/papers.py index 5a943be..8c5cbc0 100644 --- a/apps/api/routers/papers.py +++ b/apps/api/routers/papers.py @@ -290,6 +290,8 @@ def toggle_favorite(paper_id: UUID) -> dict: @router.patch("/papers/{paper_id}/reject") def toggle_reject(paper_id: UUID) -> dict: """切换论文"不感兴趣"状态(推荐系统负反馈)""" + from packages.ai.recommendation_service import invalidate_recommendations + with session_scope() as session: repo = PaperRepository(session) try: @@ -298,7 +300,10 @@ def toggle_reject(paper_id: UUID) -> dict: raise HTTPException(status_code=404, detail=str(exc)) from exc p.rejected = not getattr(p, "rejected", False) session.commit() + # 失效 folder_stats(左侧文件夹计数)+ 推荐缓存(被拒论文立即从推荐列表移除, + # 否则 recommend:{top_k} / today_summary 最长 5min 仍含该论文) cache.invalidate("folder_stats") + invalidate_recommendations() return {"id": str(p.id), "rejected": p.rejected} @@ -548,7 +553,11 @@ def similar( paper_id: UUID, top_k: int = Query(default=5, ge=1, le=20), ) -> dict: - ids = rag_service.similar_papers(paper_id, top_k=top_k) + try: + ids = rag_service.similar_papers(paper_id, top_k=top_k) + except ValueError as exc: + # get_by_id 在论文不存在时抛 ValueError,统一转 404(此前返回 500) + raise HTTPException(status_code=404, detail=str(exc)) from exc items = [] if ids: with session_scope() as session: @@ -589,7 +598,11 @@ def paper_duplicates( from packages.ai.pipelines import PaperPipelines pipelines = PaperPipelines() - return pipelines.detect_duplicates(paper_id, threshold=threshold) + try: + return pipelines.detect_duplicates(paper_id, threshold=threshold) + except ValueError as exc: + # get_by_id 在论文不存在时抛 ValueError,统一转 404(此前返回 500) + raise HTTPException(status_code=404, detail=str(exc)) from exc @router.post("/papers/{paper_id}/reasoning") diff --git a/packages/ai/graph/similarity.py b/packages/ai/graph/similarity.py index 42130a7..d5ad64d 100644 --- a/packages/ai/graph/similarity.py +++ b/packages/ai/graph/similarity.py @@ -185,8 +185,6 @@ def similar_via_citation(self, paper_id: str, top_k: int = 5) -> dict: with session_scope() as session: repo = PaperRepository(session) seed = repo.get_by_id(paper_id) - if not seed: - return {"paper_id": str(paper_id), "items": [], "note": "论文不存在"} if not seed.embedding: return { "paper_id": str(paper_id), @@ -195,13 +193,15 @@ def similar_via_citation(self, paper_id: str, top_k: int = 5) -> dict: } seed_vec = list(seed.embedding) - # 1. 取种子论文引用 / 被引的论文(双向) + # 1. 取种子论文引用 / 被引的论文(双向,限 200 防高被引 hub 过载) cited_rows = ( session.execute( - _sa_select(Citation).where( + _sa_select(Citation) + .where( (Citation.source_paper_id == str(paper_id)) | (Citation.target_paper_id == str(paper_id)) ) + .limit(200) ) .scalars() .all() @@ -215,13 +215,15 @@ def similar_via_citation(self, paper_id: str, top_k: int = 5) -> dict: if not cited_ids: return {"paper_id": str(paper_id), "items": [], "note": "无引用关系"} - # 2. 找也引用了 cited 中任一篇的论文(co-citation) + # 2. 找也引用了 cited 中任一篇的论文(co-citation,限 2000 防无界扩张) co_cite_rows = ( session.execute( - _sa_select(Citation).where( + _sa_select(Citation) + .where( Citation.source_paper_id.in_(list(cited_ids)) | Citation.target_paper_id.in_(list(cited_ids)) ) + .limit(2000) ) .scalars() .all() @@ -236,13 +238,16 @@ def similar_via_citation(self, paper_id: str, top_k: int = 5) -> dict: if not co_cite_ids: return {"paper_id": str(paper_id), "items": [], "note": "无 co-citation 候选"} - # 3. 在 co-citation 集合里按 embedding cosine 排序 + # 3. 在 co-citation 集合里按 embedding cosine 排序(排除被屏蔽论文) candidates = ( session.execute( - _sa_select(Paper).where( + _sa_select(Paper) + .where( Paper.id.in_(list(co_cite_ids)), Paper.embedding.is_not(None), + Paper.rejected.is_(False), ) + .limit(500) ) .scalars() .all() diff --git a/packages/ai/recommendation_service.py b/packages/ai/recommendation_service.py index 67578b8..433331d 100644 --- a/packages/ai/recommendation_service.py +++ b/packages/ai/recommendation_service.py @@ -41,6 +41,17 @@ def _set_cache(key: str, value: object): _ttl_cache[key] = (time.monotonic(), value) +def invalidate_recommendations() -> None: + """清空推荐与今日简报缓存(toggle_reject 后调用,使负反馈立即生效)。 + + 覆盖 `recommend:{top_k}` 与 `today_summary`(简报内嵌 recommend(top_k=5))。 + 与 deps.cache 是两套独立 dict,不能复用 deps.cache.invalidate。 + """ + with _ttl_lock: + for k in [k for k in _ttl_cache if k.startswith("recommend:") or k == "today_summary"]: + del _ttl_cache[k] + + def _mean_vector(vectors: list[list[float]]) -> list[float]: """计算向量集合的质心(自动过滤维度不一致的向量)""" if not vectors: diff --git a/packages/storage/repositories/paper.py b/packages/storage/repositories/paper.py index 8daa116..7dfdce6 100644 --- a/packages/storage/repositories/paper.py +++ b/packages/storage/repositories/paper.py @@ -392,6 +392,7 @@ def similar_by_embedding( select(Paper) .where(Paper.id != str(exclude)) .where(Paper.embedding.is_not(None)) + .where(Paper.rejected.is_(False)) .order_by(Paper.embedding.cosine_distance(vector)) .limit(limit) ) @@ -401,6 +402,7 @@ def similar_by_embedding( select(Paper) .where(Paper.id != str(exclude)) .where(Paper.embedding.is_not(None)) + .where(Paper.rejected.is_(False)) .order_by(Paper.created_at.desc()) .limit(max_candidates) ) @@ -438,6 +440,7 @@ def semantic_candidates( q = ( select(Paper) .where(Paper.embedding.is_not(None)) + .where(Paper.rejected.is_(False)) .order_by(Paper.embedding.cosine_distance(query_vector)) .limit(limit) ) @@ -446,6 +449,7 @@ def semantic_candidates( q = ( select(Paper) .where(Paper.embedding.is_not(None)) + .where(Paper.rejected.is_(False)) .order_by(Paper.created_at.desc()) .limit(max_candidates) )