diff --git a/src/paperbot/api/routes/research.py b/src/paperbot/api/routes/research.py index 4b61c7b2..f5106148 100644 --- a/src/paperbot/api/routes/research.py +++ b/src/paperbot/api/routes/research.py @@ -26,9 +26,9 @@ router = APIRouter() -_research_store = SqlAlchemyResearchStore() -_memory_store = SqlAlchemyMemoryStore() -_track_router = TrackRouter(research_store=_research_store, memory_store=_memory_store) +_research_store: Optional[SqlAlchemyResearchStore] = None +_memory_store: Optional[SqlAlchemyMemoryStore] = None +_track_router: Optional[TrackRouter] = None _metric_collector: Optional[MemoryMetricCollector] = None _workflow_metric_store: Optional[WorkflowMetricStore] = None _paper_store: Optional["PaperStore"] = None @@ -36,6 +36,31 @@ _anchor_service: Optional["AnchorService"] = None _subscription_service: Optional["SubscriptionService"] = None + +def _get_research_store() -> SqlAlchemyResearchStore: + global _research_store + if _research_store is None: + _research_store = SqlAlchemyResearchStore() + return _research_store + + +def _get_memory_store() -> SqlAlchemyMemoryStore: + global _memory_store + if _memory_store is None: + _memory_store = SqlAlchemyMemoryStore() + return _memory_store + + +def _get_track_router() -> TrackRouter: + global _track_router + if _track_router is None: + _track_router = TrackRouter( + research_store=_get_research_store(), + memory_store=_get_memory_store(), + ) + return _track_router + + ENABLE_ANCHOR_AUTHORS = os.getenv("PAPERBOT_ENABLE_ANCHOR_AUTHORS", "true").lower() == "true" _DISCOVERY_STOPWORDS: Set[str] = { @@ -208,7 +233,7 @@ def _schedule_embedding_precompute( def _run() -> None: try: - _track_router.precompute_track_embeddings(user_id=user_id, track_ids=ids) + _get_track_router().precompute_track_embeddings(user_id=user_id, track_ids=ids) except Exception: return @@ -239,7 +264,7 @@ class TrackResponse(BaseModel): @router.post("/research/tracks", response_model=TrackResponse) def create_track(req: TrackCreateRequest, background_tasks: BackgroundTasks): - track = _research_store.create_track( + track = _get_research_store().create_track( user_id=req.user_id, name=req.name, description=req.description, @@ -265,7 +290,7 @@ def list_tracks( include_archived: bool = Query(False), limit: int = Query(100, ge=1, le=500), ): - tracks = _research_store.list_tracks( + tracks = _get_research_store().list_tracks( user_id=user_id, include_archived=include_archived, limit=limit ) return TrackListResponse(user_id=user_id, tracks=tracks) @@ -297,7 +322,7 @@ def get_deadline_radar( now = datetime.now(timezone.utc) cutoff = now + timedelta(days=int(days)) - tracks = _research_store.list_tracks(user_id=user_id, include_archived=False, limit=200) + tracks = _get_research_store().list_tracks(user_id=user_id, include_archived=False, limit=200) track_tokens: Dict[int, set[str]] = {} for track in tracks: track_id = int(track.get("id") or 0) @@ -369,7 +394,7 @@ def get_deadline_radar( @router.get("/research/tracks/active", response_model=TrackResponse) def get_active_track(user_id: str = "default"): - track = _research_store.get_active_track(user_id=user_id) + track = _get_research_store().get_active_track(user_id=user_id) if not track: raise HTTPException(status_code=404, detail="No active track for user") return TrackResponse(track=track) @@ -388,7 +413,9 @@ def update_track( raise HTTPException(status_code=400, detail="No fields to update") try: - track = _research_store.update_track(user_id=user_id, track_id=track_id, **update_data) + track = _get_research_store().update_track( + user_id=user_id, track_id=track_id, **update_data + ) except IntegrityError: raise HTTPException(status_code=409, detail="Track name already exists") from None if not track: @@ -399,7 +426,7 @@ def update_track( @router.post("/research/tracks/{track_id}/activate", response_model=TrackResponse) def activate_track(track_id: int, background_tasks: BackgroundTasks, user_id: str = "default"): - track = _research_store.activate_track(user_id=user_id, track_id=track_id) + track = _get_research_store().activate_track(user_id=user_id, track_id=track_id) if not track: raise HTTPException(status_code=404, detail="Track not found") _schedule_embedding_precompute(background_tasks, user_id=user_id, track_ids=[track_id]) @@ -422,7 +449,7 @@ class TaskResponse(BaseModel): @router.post("/research/tracks/{track_id}/tasks", response_model=TaskResponse) def add_task(track_id: int, req: TaskCreateRequest, background_tasks: BackgroundTasks): - task = _research_store.add_task( + task = _get_research_store().add_task( user_id=req.user_id, track_id=track_id, title=req.title, @@ -451,7 +478,7 @@ def list_tasks( status: Optional[str] = None, limit: int = Query(100, ge=1, le=500), ): - tasks = _research_store.list_tasks( + tasks = _get_research_store().list_tasks( user_id=user_id, track_id=track_id, status=status, limit=limit ) return TaskListResponse(user_id=user_id, track_id=track_id, tasks=tasks) @@ -480,7 +507,7 @@ def _resolve_track_scope_id( return scope_id if scope_id: return scope_id - active = _research_store.get_active_track(user_id=user_id) + active = _get_research_store().get_active_track(user_id=user_id) if not active: return None return str(active["id"]) @@ -503,7 +530,7 @@ def create_memory_item(req: MemoryItemCreateRequest, background_tasks: Backgroun scope_id=scope_id, status=req.status, ) - created, _, rows = _memory_store.add_memories(user_id=req.user_id, memories=[cand]) + created, _, rows = _get_memory_store().add_memories(user_id=req.user_id, memories=[cand]) if created <= 0 or not rows: raise HTTPException( status_code=409, detail="Duplicate memory item (same scope/kind/content)" @@ -530,7 +557,7 @@ def list_memory_items( include_pending: bool = False, limit: int = Query(100, ge=1, le=500), ): - items = _memory_store.list_memories( + items = _get_memory_store().list_memories( user_id=user_id, limit=limit, kind=kind, @@ -549,12 +576,12 @@ def list_memory_inbox( limit: int = Query(100, ge=1, le=500), ): if track_id is None: - active = _research_store.get_active_track(user_id=user_id) + active = _get_research_store().get_active_track(user_id=user_id) if not active: raise HTTPException(status_code=404, detail="No active track for user") track_id = int(active["id"]) - items = _memory_store.list_memories( + items = _get_memory_store().list_memories( user_id=user_id, limit=limit, scope_type="track", @@ -607,7 +634,7 @@ def suggest_memories(req: MemorySuggestRequest, background_tasks: BackgroundTask ) for m in extracted ] - created, skipped, rows = _memory_store.add_memories(user_id=req.user_id, memories=pending) + created, skipped, rows = _get_memory_store().add_memories(user_id=req.user_id, memories=pending) if scope_type == "track": _schedule_embedding_precompute( background_tasks, user_id=req.user_id, track_ids=[int(scope_id or 0)] @@ -632,7 +659,7 @@ class MemoryModerateRequest(BaseModel): @router.post("/research/memory/items/{item_id}/moderate", response_model=MemoryItemResponse) def moderate_memory_item(item_id: int, req: MemoryModerateRequest): - updated = _memory_store.update_item( + updated = _get_memory_store().update_item( user_id=req.user_id, item_id=item_id, status=req.status, @@ -662,9 +689,9 @@ class BulkModerateResponse(BaseModel): @router.post("/research/memory/bulk_moderate", response_model=BulkModerateResponse) def bulk_moderate(req: BulkModerateRequest, background_tasks: BackgroundTasks): # Get items before update to check their confidence for P0 metrics - items_before = _memory_store.get_items_by_ids(user_id=req.user_id, item_ids=req.item_ids) + items_before = _get_memory_store().get_items_by_ids(user_id=req.user_id, item_ids=req.item_ids) - updated = _memory_store.bulk_update_items( + updated = _get_memory_store().bulk_update_items( user_id=req.user_id, item_ids=req.item_ids, status=req.status, @@ -718,7 +745,7 @@ def bulk_move(req: BulkMoveRequest, background_tasks: BackgroundTasks): scope_id = _resolve_track_scope_id(req.user_id, scope_type, req.scope_id) if scope_type == "track" and not scope_id: raise HTTPException(status_code=400, detail="scope_id missing and no active track") - updated = _memory_store.bulk_update_items( + updated = _get_memory_store().bulk_update_items( user_id=req.user_id, item_ids=req.item_ids, scope_type=scope_type, @@ -820,7 +847,7 @@ def clear_track_memory( ): if not confirm: raise HTTPException(status_code=400, detail="confirm=true required") - deleted = _memory_store.soft_delete_by_scope( + deleted = _get_memory_store().soft_delete_by_scope( user_id=user_id, scope_type="track", scope_id=str(track_id), @@ -832,7 +859,7 @@ def clear_track_memory( # P0 Hook: Verify deletion compliance - deleted items should not be retrievable if deleted > 0: # Try to retrieve items from the cleared scope (should return empty) - retrieved_after_delete = _memory_store.list_memories( + retrieved_after_delete = _get_memory_store().list_memories( user_id=user_id, scope_type="track", scope_id=str(track_id), @@ -841,7 +868,7 @@ def clear_track_memory( limit=100, ) # Also try searching - search_results = _memory_store.search_memories( + search_results = _get_memory_store().search_memories( user_id=user_id, query="*", # broad query scope_type="track", @@ -878,7 +905,7 @@ class PrecomputeEmbeddingsResponse(BaseModel): @router.post("/research/embeddings/precompute", response_model=PrecomputeEmbeddingsResponse) def precompute_embeddings(req: PrecomputeEmbeddingsRequest): - result = _track_router.precompute_track_embeddings( + result = _get_track_router().precompute_track_embeddings( user_id=req.user_id, track_ids=req.track_ids or None ) return PrecomputeEmbeddingsResponse(user_id=req.user_id, result=result) @@ -896,7 +923,7 @@ def eval_summary( track_id: Optional[int] = None, days: int = Query(30, ge=1, le=365), ): - summary = _research_store.summarize_eval(user_id=user_id, track_id=track_id, days=days) + summary = _get_research_store().summarize_eval(user_id=user_id, track_id=track_id, days=days) return EvalSummaryResponse(user_id=user_id, track_id=track_id, summary=summary) @@ -904,7 +931,7 @@ class PaperFeedbackRequest(BaseModel): user_id: str = "default" track_id: Optional[int] = None paper_id: str = Field(..., min_length=1) - action: str = Field(..., min_length=1) # like/dislike/skip/save/cite + action: str = Field(..., min_length=1) # like/unlike/dislike/undislike/skip/save/unsave/cite weight: float = 0.0 metadata: Dict[str, Any] = {} context_run_id: Optional[int] = None @@ -923,17 +950,19 @@ class PaperFeedbackRequest(BaseModel): class PaperFeedbackResponse(BaseModel): feedback: Dict[str, Any] library_paper_id: Optional[int] = None # ID in papers table if saved + current_action: Optional[str] = None @router.post("/research/papers/feedback", response_model=PaperFeedbackResponse) def add_paper_feedback(req: PaperFeedbackRequest): set_trace_id() # Initialize trace_id for this request Logger.info(f"Received paper feedback request, action={req.action}", file=LogFiles.HARVEST) + research_store = _get_research_store() track_id = req.track_id if track_id is None: Logger.info("No track specified, getting active track", file=LogFiles.HARVEST) - active = _research_store.get_active_track(user_id=req.user_id) + active = research_store.get_active_track(user_id=req.user_id) if not active: Logger.error("No active track found", file=LogFiles.HARVEST) raise HTTPException(status_code=400, detail="track_id missing and no active track") @@ -997,7 +1026,7 @@ def add_paper_feedback(req: PaperFeedbackRequest): Logger.warning(f"Failed to save paper to library: {e}", file=LogFiles.HARVEST) Logger.info("Recording paper feedback to research store", file=LogFiles.HARVEST) - fb = _research_store.add_paper_feedback( + fb = research_store.add_paper_feedback( user_id=req.user_id, track_id=track_id, paper_id=req.paper_id, # Always use external ID for consistency @@ -1009,7 +1038,13 @@ def add_paper_feedback(req: PaperFeedbackRequest): Logger.error("Failed to record feedback - track not found", file=LogFiles.HARVEST) raise HTTPException(status_code=404, detail="Track not found") Logger.info("Paper feedback recorded successfully", file=LogFiles.HARVEST) - return PaperFeedbackResponse(feedback=fb, library_paper_id=library_paper_id) + normalized_action = research_store._normalize_feedback_action(req.action) + current_action = research_store._effective_feedback_action(normalized_action) + return PaperFeedbackResponse( + feedback=fb, + library_paper_id=library_paper_id, + current_action=current_action, + ) class PaperFeedbackListResponse(BaseModel): @@ -1025,7 +1060,7 @@ def list_paper_feedback( action: Optional[str] = None, limit: int = Query(200, ge=1, le=1000), ): - items = _research_store.list_paper_feedback( + items = _get_research_store().list_paper_feedback( user_id=user_id, track_id=track_id, action=action, limit=limit ) return PaperFeedbackListResponse(user_id=user_id, track_id=track_id, items=items) @@ -1156,7 +1191,7 @@ class PaperRepoListResponse(BaseModel): @router.post("/research/papers/{paper_id}/status", response_model=PaperReadingStatusResponse) def update_paper_status(paper_id: str, req: PaperReadingStatusRequest): - status = _research_store.set_paper_reading_status( + status = _get_research_store().set_paper_reading_status( user_id=req.user_id, paper_id=paper_id, status=req.status, @@ -1176,7 +1211,7 @@ def list_saved_papers( sort_by: str = Query("saved_at"), limit: int = Query(200, ge=1, le=1000), ): - items = _research_store.list_saved_papers( + items = _get_research_store().list_saved_papers( user_id=user_id, track_id=track_id, collection_id=collection_id, @@ -1293,7 +1328,10 @@ async def discover_from_seed(req: DiscoverySeedRequest): ) try: - openalex_work = openalex.resolve_work(seed_type=req.seed_type, seed_id=req.seed_id) + openalex_work = await openalex.resolve_work( + seed_type=req.seed_type, + seed_id=req.seed_id, + ) except Exception: openalex_work = None if openalex_work: @@ -1303,7 +1341,10 @@ async def discover_from_seed(req: DiscoverySeedRequest): seed_info["year"] = openalex_work.get("publication_year") if req.include_related: try: - related_rows = openalex.get_related_works(openalex_work, limit=req.limit) + related_rows = await openalex.get_related_works( + openalex_work, + limit=req.limit, + ) except Exception: related_rows = [] for row in related_rows: @@ -1317,7 +1358,10 @@ async def discover_from_seed(req: DiscoverySeedRequest): ) if req.include_cited: try: - cited_rows = openalex.get_referenced_works(openalex_work, limit=req.limit) + cited_rows = await openalex.get_referenced_works( + openalex_work, + limit=req.limit, + ) except Exception: cited_rows = [] for row in cited_rows: @@ -1331,7 +1375,10 @@ async def discover_from_seed(req: DiscoverySeedRequest): ) if req.include_citing: try: - citing_rows = openalex.get_citing_works(openalex_work, limit=req.limit) + citing_rows = await openalex.get_citing_works( + openalex_work, + limit=req.limit, + ) except Exception: citing_rows = [] for row in citing_rows: @@ -1345,6 +1392,7 @@ async def discover_from_seed(req: DiscoverySeedRequest): ) finally: await client.close() + await openalex.close() candidates = list(candidate_map.values()) filtered = _filter_discovery_candidates( @@ -1427,7 +1475,7 @@ async def discover_from_seed(req: DiscoverySeedRequest): @router.post("/research/collections", response_model=PaperCollectionResponse) def create_collection(req: PaperCollectionCreateRequest): try: - collection = _research_store.create_collection( + collection = _get_research_store().create_collection( user_id=req.user_id, name=req.name, description=req.description, @@ -1445,7 +1493,7 @@ def list_collections( track_id: Optional[int] = Query(default=None), limit: int = Query(200, ge=1, le=1000), ): - items = _research_store.list_collections( + items = _get_research_store().list_collections( user_id=user_id, include_archived=include_archived, track_id=track_id, @@ -1457,7 +1505,7 @@ def list_collections( @router.patch("/research/collections/{collection_id}", response_model=PaperCollectionResponse) def update_collection(collection_id: int, req: PaperCollectionUpdateRequest): try: - collection = _research_store.update_collection( + collection = _get_research_store().update_collection( user_id=req.user_id, collection_id=collection_id, name=req.name, @@ -1480,7 +1528,7 @@ def list_collection_items( user_id: str = "default", limit: int = Query(500, ge=1, le=5000), ): - items = _research_store.list_collection_items( + items = _get_research_store().list_collection_items( user_id=user_id, collection_id=collection_id, limit=limit, @@ -1493,7 +1541,7 @@ def list_collection_items( response_model=PaperCollectionItemsResponse, ) def upsert_collection_item(collection_id: int, req: PaperCollectionItemUpsertRequest): - item = _research_store.upsert_collection_item( + item = _get_research_store().upsert_collection_item( user_id=req.user_id, collection_id=collection_id, paper_id=req.paper_id, @@ -1502,7 +1550,9 @@ def upsert_collection_item(collection_id: int, req: PaperCollectionItemUpsertReq ) if item is None: raise HTTPException(status_code=404, detail="Collection or paper not found") - items = _research_store.list_collection_items(user_id=req.user_id, collection_id=collection_id) + items = _get_research_store().list_collection_items( + user_id=req.user_id, collection_id=collection_id + ) return PaperCollectionItemsResponse( user_id=req.user_id, collection_id=collection_id, items=items ) @@ -1513,7 +1563,7 @@ def upsert_collection_item(collection_id: int, req: PaperCollectionItemUpsertReq response_model=PaperCollectionItemsResponse, ) def patch_collection_item(collection_id: int, paper_id: str, req: PaperCollectionItemPatchRequest): - item = _research_store.upsert_collection_item( + item = _get_research_store().upsert_collection_item( user_id=req.user_id, collection_id=collection_id, paper_id=paper_id, @@ -1522,7 +1572,9 @@ def patch_collection_item(collection_id: int, paper_id: str, req: PaperCollectio ) if item is None: raise HTTPException(status_code=404, detail="Collection or paper not found") - items = _research_store.list_collection_items(user_id=req.user_id, collection_id=collection_id) + items = _get_research_store().list_collection_items( + user_id=req.user_id, collection_id=collection_id + ) return PaperCollectionItemsResponse( user_id=req.user_id, collection_id=collection_id, items=items ) @@ -1530,7 +1582,7 @@ def patch_collection_item(collection_id: int, paper_id: str, req: PaperCollectio @router.delete("/research/collections/{collection_id}/items/{paper_id}") def delete_collection_item(collection_id: int, paper_id: str, user_id: str = "default"): - ok = _research_store.remove_collection_item( + ok = _get_research_store().remove_collection_item( user_id=user_id, collection_id=collection_id, paper_id=paper_id, @@ -1547,11 +1599,11 @@ def get_track_feed( limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), ): - track = _research_store.get_track(user_id=user_id, track_id=track_id) + track = _get_research_store().get_track(user_id=user_id, track_id=track_id) if not track: raise HTTPException(status_code=404, detail="Track not found") - payload = _research_store.list_track_feed( + payload = _get_research_store().list_track_feed( user_id=user_id, track_id=track_id, limit=limit, @@ -1573,7 +1625,7 @@ def export_papers( track_id: Optional[int] = None, format: str = Query("bibtex", pattern="^(bibtex|ris|markdown|csl_json)$"), ): - items = _research_store.list_saved_papers(user_id=user_id, track_id=track_id, limit=1000) + items = _get_research_store().list_saved_papers(user_id=user_id, track_id=track_id, limit=1000) papers = [item["paper"] for item in items if item.get("paper")] if not papers: @@ -1654,7 +1706,7 @@ def import_bibtex(req: BibtexImportRequest): track_pk = int(track["id"]) paper_store = _get_paper_store() - existing_saved_ids = _research_store.list_paper_feedback_ids( + existing_saved_ids = _get_research_store().list_paper_feedback_ids( user_id=req.user_id, track_id=track_pk, action="save", @@ -1695,7 +1747,7 @@ def import_bibtex(req: BibtexImportRequest): "citation_key": str(entry.get("key") or ""), "entry_type": str(entry.get("entry_type") or ""), } - _research_store.add_paper_feedback( + _get_research_store().add_paper_feedback( user_id=req.user_id, track_id=track_pk, paper_id=paper_ref, @@ -1767,7 +1819,7 @@ def pull_from_zotero(req: ZoteroSyncRequest): raise HTTPException(status_code=502, detail=f"Failed to pull from Zotero: {exc}") from exc paper_store = _get_paper_store() - existing_saved_ids = _research_store.list_paper_feedback_ids( + existing_saved_ids = _get_research_store().list_paper_feedback_ids( user_id=req.user_id, track_id=track_pk, action="save", @@ -1808,7 +1860,7 @@ def pull_from_zotero(req: ZoteroSyncRequest): "zotero_library_type": req.library_type, "zotero_library_id": req.library_id, } - _research_store.add_paper_feedback( + _get_research_store().add_paper_feedback( user_id=req.user_id, track_id=track_pk, paper_id=paper_ref, @@ -1855,12 +1907,12 @@ def push_to_zotero(req: ZoteroPushRequest): from paperbot.infrastructure.connectors.zotero_connector import ZoteroConnector if req.track_id is not None: - track = _research_store.get_track(user_id=req.user_id, track_id=req.track_id) + track = _get_research_store().get_track(user_id=req.user_id, track_id=req.track_id) if not track: raise HTTPException(status_code=404, detail="Track not found") connector = ZoteroConnector() - local_items = _research_store.list_saved_papers( + local_items = _get_research_store().list_saved_papers( user_id=req.user_id, track_id=req.track_id, sort_by="saved_at", @@ -1941,7 +1993,7 @@ def discover_track_anchors( ): _ensure_anchor_feature_enabled() - track = _research_store.get_track(user_id=user_id, track_id=track_id) + track = _get_research_store().get_track(user_id=user_id, track_id=track_id) if not track: raise HTTPException(status_code=404, detail="Track not found") @@ -1975,7 +2027,7 @@ def discover_track_anchors( def set_anchor_action(track_id: int, author_id: int, req: AnchorActionRequest): _ensure_anchor_feature_enabled() - track = _research_store.get_track(user_id=req.user_id, track_id=track_id) + track = _get_research_store().get_track(user_id=req.user_id, track_id=track_id) if not track: raise HTTPException(status_code=404, detail="Track not found") @@ -2011,7 +2063,7 @@ def set_anchor_action(track_id: int, author_id: int, req: AnchorActionRequest): def list_anchor_actions(track_id: int, user_id: str = "default"): _ensure_anchor_feature_enabled() - track = _research_store.get_track(user_id=user_id, track_id=track_id) + track = _get_research_store().get_track(user_id=user_id, track_id=track_id) if not track: raise HTTPException(status_code=404, detail="Track not found") @@ -2021,7 +2073,7 @@ def list_anchor_actions(track_id: int, user_id: str = "default"): @router.get("/research/papers/{paper_id}", response_model=PaperDetailResponse) def get_paper_detail(paper_id: str, user_id: str = "default"): - detail = _research_store.get_paper_detail(paper_id=paper_id, user_id=user_id) + detail = _get_research_store().get_paper_detail(paper_id=paper_id, user_id=user_id) if not detail: raise HTTPException(status_code=404, detail="Paper not found in registry") return PaperDetailResponse(detail=detail) @@ -2029,7 +2081,7 @@ def get_paper_detail(paper_id: str, user_id: str = "default"): @router.get("/research/papers/{paper_id}/repos", response_model=PaperRepoListResponse) def get_paper_repos(paper_id: str): - repos = _research_store.list_paper_repos(paper_id=paper_id) + repos = _get_research_store().list_paper_repos(paper_id=paper_id) if repos is None: raise HTTPException(status_code=404, detail="Paper not found in registry") return PaperRepoListResponse(paper_id=paper_id, repos=repos) @@ -2046,10 +2098,10 @@ class RouterSuggestResponse(BaseModel): @router.post("/research/router/suggest", response_model=RouterSuggestResponse) def suggest_track(req: RouterSuggestRequest): - active = _research_store.get_active_track(user_id=req.user_id) + active = _get_research_store().get_active_track(user_id=req.user_id) if not active: return RouterSuggestResponse(suggestion=None) - suggestion = _track_router.suggest_track( + suggestion = _get_track_router().suggest_track( user_id=req.user_id, query=req.query, active_track_id=int(active["id"]) ) return RouterSuggestResponse(suggestion=suggestion) @@ -2148,7 +2200,7 @@ async def build_context(req: ContextRequest): if req.activate_track_id is not None: Logger.info("Activating research track", file=LogFiles.HARVEST) - activated = _research_store.activate_track( + activated = _get_research_store().activate_track( user_id=req.user_id, track_id=req.activate_track_id ) if not activated: @@ -2167,11 +2219,11 @@ async def build_context(req: ContextRequest): ) engine = ContextEngine( - research_store=_research_store, - memory_store=_memory_store, + research_store=_get_research_store(), + memory_store=_get_memory_store(), paper_store=_get_paper_store(), search_service=search_service, - track_router=_track_router, + track_router=_get_track_router(), config=ContextEngineConfig( memory_limit=req.memory_limit, paper_limit=req.paper_limit, @@ -3144,20 +3196,20 @@ def _build_feedback_profile(user_id: str, track_id: Optional[int]) -> Dict[str, limit = 400 feedback_rows: List[Dict[str, Any]] if track_id is not None: - feedback_rows = _research_store.list_paper_feedback( + feedback_rows = _get_research_store().list_effective_paper_feedback( user_id=user_id, track_id=int(track_id), - action=None, limit=limit, ) else: feedback_rows = [] - for track in _research_store.list_tracks(user_id=user_id, include_archived=False, limit=20): + for track in _get_research_store().list_tracks( + user_id=user_id, include_archived=False, limit=20 + ): feedback_rows.extend( - _research_store.list_paper_feedback( + _get_research_store().list_effective_paper_feedback( user_id=user_id, track_id=int(track.get("id") or 0), - action=None, limit=100, ) ) @@ -3188,7 +3240,9 @@ def _build_feedback_profile(user_id: str, track_id: Optional[int]) -> Dict[str, for term in _extract_profile_terms(paper_text): profile[term] = profile.get(term, 0.0) + coeff - saved_rows = _research_store.list_saved_papers(user_id=user_id, track_id=track_id, limit=200) + saved_rows = _get_research_store().list_saved_papers( + user_id=user_id, track_id=track_id, limit=200 + ) for item in saved_rows: paper = item.get("paper") or {} paper_text = " ".join( @@ -3259,7 +3313,7 @@ def _find_track_by_name(*, user_id: str, track_name: str) -> Optional[Dict[str, target = (track_name or "").strip().casefold() if not target: return None - tracks = _research_store.list_tracks(user_id=user_id, include_archived=True, limit=500) + tracks = _get_research_store().list_tracks(user_id=user_id, include_archived=True, limit=500) for track in tracks: if str(track.get("name") or "").strip().casefold() == target: return track @@ -3274,7 +3328,7 @@ def _resolve_or_create_import_track( default_track_name: str, ) -> Dict[str, Any]: if track_id is not None: - track = _research_store.get_track(user_id=user_id, track_id=int(track_id)) + track = _get_research_store().get_track(user_id=user_id, track_id=int(track_id)) if not track: raise HTTPException(status_code=404, detail="Track not found") return track @@ -3283,19 +3337,19 @@ def _resolve_or_create_import_track( found = _find_track_by_name(user_id=user_id, track_name=track_name) if found: return found - return _research_store.create_track( + return _get_research_store().create_track( user_id=user_id, name=(track_name or "").strip(), description=f"Imported from {default_track_name.lower()}", activate=True, ) - active = _research_store.get_active_track(user_id=user_id) + active = _get_research_store().get_active_track(user_id=user_id) if active: return active today = datetime.now(timezone.utc).strftime("%Y-%m-%d") - return _research_store.create_track( + return _get_research_store().create_track( user_id=user_id, name=f"{default_track_name} {today}", description=f"Auto-created for {default_track_name.lower()}", @@ -3715,7 +3769,7 @@ class StructuredCardResponse(BaseModel): @router.get("/research/papers/{paper_id}/card", response_model=StructuredCardResponse) def get_structured_card(paper_id: str, user_id: str = "default"): - detail = _research_store.get_paper_detail(paper_id=paper_id, user_id=user_id) + detail = _get_research_store().get_paper_detail(paper_id=paper_id, user_id=user_id) if not detail: raise HTTPException(status_code=404, detail="Paper not found") @@ -3780,7 +3834,7 @@ class RelatedWorkResponse(BaseModel): @router.post("/research/papers/related-work", response_model=RelatedWorkResponse) def generate_related_work(req: RelatedWorkRequest): - items = _research_store.list_saved_papers( + items = _get_research_store().list_saved_papers( user_id=req.user_id, track_id=req.track_id, limit=req.limit ) papers = [item["paper"] for item in items if item.get("paper")] diff --git a/src/paperbot/application/services/anchor_service.py b/src/paperbot/application/services/anchor_service.py index ac8eb998..c4b8e9ba 100644 --- a/src/paperbot/application/services/anchor_service.py +++ b/src/paperbot/application/services/anchor_service.py @@ -69,6 +69,58 @@ def _safe_json_obj(text: str) -> dict[str, Any]: return {} +_FEEDBACK_ACTION_ALIASES = { + "not_relevant": "dislike", + "not-relevant": "dislike", + "not related": "dislike", +} +_FEEDBACK_GROUP_BY_ACTION = { + "save": "save_state", + "unsave": "save_state", + "like": "preference_state", + "unlike": "preference_state", + "dislike": "preference_state", + "undislike": "preference_state", + "skip": "preference_state", + "cite": "cite_state", +} +_FEEDBACK_EFFECTIVE_ACTIONS = { + "save": "save", + "unsave": None, + "like": "like", + "unlike": None, + "dislike": "dislike", + "undislike": None, + "skip": "skip", + "cite": "cite", +} + + +def _normalize_feedback_action(value: str) -> str: + normalized = (value or "").strip().lower().replace(" ", "_") + return _FEEDBACK_ACTION_ALIASES.get(normalized, normalized) + + +def _collapse_effective_feedback_actions(rows: list[PaperFeedbackModel]) -> list[str]: + effective_actions: list[str] = [] + seen: set[tuple[int, str]] = set() + for row in rows: + paper_id = int(row.canonical_paper_id or row.paper_ref_id or 0) + if paper_id <= 0: + continue + normalized_action = _normalize_feedback_action(str(row.action or "")) + group = _FEEDBACK_GROUP_BY_ACTION.get(normalized_action, normalized_action) + state_key = (paper_id, group) + if state_key in seen: + continue + seen.add(state_key) + + effective_action = _FEEDBACK_EFFECTIVE_ACTIONS.get(normalized_action, normalized_action) + if effective_action: + effective_actions.append(effective_action) + return effective_actions + + class AnchorService: """Discover anchor authors with intrinsic + relevance + network scoring.""" @@ -158,6 +210,7 @@ def discover( PaperFeedbackModel.paper_ref_id.in_(paper_ids), ) ) + .order_by(desc(PaperFeedbackModel.ts), desc(PaperFeedbackModel.id)) ) .scalars() .all() @@ -180,11 +233,14 @@ def discover( "dislike": -1.0, "skip": -0.3, } + effective_feedback_actions = _collapse_effective_feedback_actions(feedback_rows) raw_feedback = 0.0 - for row in feedback_rows: - raw_feedback += action_weights.get((row.action or "").lower(), 0.0) + for action in effective_feedback_actions: + raw_feedback += action_weights.get(action, 0.0) feedback_signal = ( - (math.tanh(raw_feedback / 4.0) + 1.0) / 2.0 if feedback_rows else 0.0 + (math.tanh(raw_feedback / 4.0) + 1.0) / 2.0 + if effective_feedback_actions + else 0.0 ) relevance_score = keyword_match_rate diff --git a/src/paperbot/context_engine/engine.py b/src/paperbot/context_engine/engine.py index b395133f..bedbafcc 100644 --- a/src/paperbot/context_engine/engine.py +++ b/src/paperbot/context_engine/engine.py @@ -524,7 +524,7 @@ def __init__( config=self.config.track_router, ) self._layer0_cache: Dict[str, Dict[str, Any]] = {} # keyed by user_id - self._layer0_cache_ts: Dict[str, float] = {} # keyed by user_id + self._layer0_cache_ts: Dict[str, float] = {} # keyed by user_id self._layer0_ttl: float = 300.0 # 5 minutes def _attach_latest_judge(self, papers: List[Dict[str, Any]]) -> None: @@ -591,9 +591,7 @@ def _load_layer0_profile(self, user_id: str) -> List[Dict[str, Any]]: self._layer0_cache_ts[user_id] = now return prefs - def _load_layer1_track( - self, user_id: str, track: Optional[Dict[str, Any]] - ) -> Dict[str, Any]: + def _load_layer1_track(self, user_id: str, track: Optional[Dict[str, Any]]) -> Dict[str, Any]: """Layer 1: track context — goals, keywords, tasks, milestones (~500 tokens).""" tasks: List[Dict[str, Any]] = [] milestones: List[Dict[str, Any]] = [] @@ -771,9 +769,7 @@ def _total_tokens() -> int: return prefs, task_list, milestone_list, relevant, cross_track, paper, layers, trimmed - def _load_layer3_paper( - self, user_id: str, paper_id: Optional[str] - ) -> List[Dict[str, Any]]: + def _load_layer3_paper(self, user_id: str, paper_id: Optional[str]) -> List[Dict[str, Any]]: """Layer 3: paper-scoped memories (on-demand, only when paper_id given).""" if not paper_id: return [] @@ -917,11 +913,23 @@ async def build_context_pack( if self.config.personalized and routed_track: try: - feedback_rows = self.research_store.list_paper_feedback( - user_id=user_id, - track_id=int(routed_track["id"]), - limit=500, + list_effective_feedback = getattr( + self.research_store, + "list_effective_paper_feedback", + None, ) + if callable(list_effective_feedback): + feedback_rows = list_effective_feedback( + user_id=user_id, + track_id=int(routed_track["id"]), + limit=500, + ) + else: + feedback_rows = self.research_store.list_paper_feedback( + user_id=user_id, + track_id=int(routed_track["id"]), + limit=500, + ) default_rrf_weights = getattr( self.search_service, "DEFAULT_SOURCE_WEIGHTS", diff --git a/src/paperbot/infrastructure/stores/research_store.py b/src/paperbot/infrastructure/stores/research_store.py index ac96daec..5d278f7b 100644 --- a/src/paperbot/infrastructure/stores/research_store.py +++ b/src/paperbot/infrastructure/stores/research_store.py @@ -2,12 +2,14 @@ import hashlib import json +from collections import defaultdict from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Iterable, List, Optional from sqlalchemy import desc, func, or_, select from sqlalchemy.exc import IntegrityError +from paperbot.application.ports.feedback_port import FeedbackPort from paperbot.application.services.identity_resolver import IdentityResolver from paperbot.domain.paper_identity import normalize_arxiv_id, normalize_doi from paperbot.infrastructure.stores.models import ( @@ -80,7 +82,34 @@ def _parse_datetime(value: Any) -> Optional[datetime]: return None -class SqlAlchemyResearchStore: +_FEEDBACK_ACTION_ALIASES: Dict[str, str] = { + "not_relevant": "dislike", + "not-relevant": "dislike", + "not related": "dislike", +} +_FEEDBACK_GROUP_BY_ACTION: Dict[str, str] = { + "save": "save_state", + "unsave": "save_state", + "like": "preference_state", + "unlike": "preference_state", + "dislike": "preference_state", + "undislike": "preference_state", + "skip": "preference_state", + "cite": "cite_state", +} +_FEEDBACK_EFFECTIVE_ACTIONS: Dict[str, Optional[str]] = { + "save": "save", + "unsave": None, + "like": "like", + "unlike": None, + "dislike": "dislike", + "undislike": None, + "skip": "skip", + "cite": "cite", +} + + +class SqlAlchemyResearchStore(FeedbackPort): """ Track/progress store for personalized paper recommendation. @@ -94,9 +123,70 @@ class SqlAlchemyResearchStore: def __init__(self, db_url: Optional[str] = None, *, auto_create_schema: bool = True): self.db_url = db_url or get_db_url() self._provider = SessionProvider(self.db_url) - self._identity_resolver = IdentityResolver(db_url=self.db_url) if auto_create_schema: Base.metadata.create_all(self._provider.engine) + self._identity_resolver = IdentityResolver(db_url=self.db_url) + + @staticmethod + def _normalize_feedback_action(value: str) -> str: + normalized = (value or "").strip().lower().replace(" ", "_") + return _FEEDBACK_ACTION_ALIASES.get(normalized, normalized) + + @staticmethod + def _feedback_group(action: str) -> str: + return _FEEDBACK_GROUP_BY_ACTION.get(action, action) + + @staticmethod + def _effective_feedback_action(action: str) -> Optional[str]: + return _FEEDBACK_EFFECTIVE_ACTIONS.get(action, action or None) + + @staticmethod + def _feedback_identity_key( + *, + paper_ref_id: Optional[int], + canonical_paper_id: Optional[int], + paper_id: str, + ) -> str: + resolved_ref_id = int(canonical_paper_id or paper_ref_id or 0) + if resolved_ref_id > 0: + return f"ref:{resolved_ref_id}" + normalized_paper_id = str(paper_id or "").strip() + return f"external:{normalized_paper_id}" + + @classmethod + def _feedback_state_key( + cls, row: PaperFeedbackModel, normalized_action: str + ) -> tuple[str, str]: + return ( + cls._feedback_identity_key( + paper_ref_id=row.paper_ref_id, + canonical_paper_id=row.canonical_paper_id, + paper_id=row.paper_id, + ), + cls._feedback_group(normalized_action), + ) + + @classmethod + def _collapse_effective_feedback_rows( + cls, rows: Iterable[PaperFeedbackModel] + ) -> List[Dict[str, Any]]: + collapsed: List[Dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for row in rows: + normalized_action = cls._normalize_feedback_action(str(row.action or "")) + state_key = cls._feedback_state_key(row, normalized_action) + if state_key in seen: + continue + seen.add(state_key) + + effective_action = cls._effective_feedback_action(normalized_action) + if effective_action is None: + continue + + payload = cls._feedback_to_dict(row) + payload["action"] = effective_action + collapsed.append(payload) + return collapsed def create_track( self, @@ -156,9 +246,7 @@ def list_tracks( stmt = select(ResearchTrackModel).where(ResearchTrackModel.user_id == user_id) if not include_archived: stmt = stmt.where(ResearchTrackModel.archived_at.is_(None)) - stmt = stmt.order_by( - desc(ResearchTrackModel.is_active), desc(ResearchTrackModel.updated_at) - ).limit(limit) + stmt = stmt.order_by(ResearchTrackModel.id).limit(limit) tracks = session.execute(stmt).scalars().all() return [self._track_to_dict(t) for t in tracks] @@ -406,6 +494,7 @@ def add_paper_feedback( Logger.info("Recording paper feedback", file=LogFiles.HARVEST) now = _utcnow() metadata = dict(metadata or {}) + normalized_action = self._normalize_feedback_action(action) with self._provider.session() as session: track = session.execute( select(ResearchTrackModel).where( @@ -428,23 +517,44 @@ def add_paper_feedback( paper_id=(paper_id or "").strip(), paper_ref_id=resolved_paper_ref_id, canonical_paper_id=resolved_paper_ref_id, # dual-write - action=(action or "").strip(), + action=normalized_action, weight=float(weight or 0.0), ts=now, metadata_json=json.dumps(metadata or {}, ensure_ascii=False), ) session.add(row) - if resolved_paper_ref_id and (action or "").strip() == "save": - self._upsert_reading_status_row( - session=session, - user_id=user_id, - paper_ref_id=resolved_paper_ref_id, - status="unread", - mark_saved=True, - metadata=metadata, - now=now, - ) + if resolved_paper_ref_id: + if normalized_action == "save": + self._upsert_reading_status_row( + session=session, + user_id=user_id, + paper_ref_id=resolved_paper_ref_id, + status="unread", + mark_saved=True, + metadata=metadata, + now=now, + ) + elif normalized_action == "unsave": + existing_status = session.execute( + select(PaperReadingStatusModel).where( + PaperReadingStatusModel.user_id == user_id, + PaperReadingStatusModel.paper_id == int(resolved_paper_ref_id), + ) + ).scalar_one_or_none() + self._upsert_reading_status_row( + session=session, + user_id=user_id, + paper_ref_id=resolved_paper_ref_id, + status=( + str(existing_status.status or "unread") + if existing_status is not None + else "unread" + ), + mark_saved=False, + metadata=metadata, + now=now, + ) track.updated_at = now session.add(track) @@ -453,6 +563,37 @@ def add_paper_feedback( Logger.info("Feedback record created successfully", file=LogFiles.HARVEST) return self._feedback_to_dict(row) + def list_effective_paper_feedback( + self, + *, + user_id: str, + track_id: int, + limit: int = 200, + ) -> List[Dict[str, Any]]: + with self._provider.session() as session: + track = session.execute( + select(ResearchTrackModel).where( + ResearchTrackModel.user_id == user_id, ResearchTrackModel.id == track_id + ) + ).scalar_one_or_none() + if track is None: + return [] + + rows = ( + session.execute( + select(PaperFeedbackModel) + .where( + PaperFeedbackModel.user_id == user_id, + PaperFeedbackModel.track_id == track_id, + ) + .order_by(desc(PaperFeedbackModel.ts), desc(PaperFeedbackModel.id)) + ) + .scalars() + .all() + ) + collapsed = self._collapse_effective_feedback_rows(rows) + return collapsed[: max(1, int(limit))] + def list_paper_feedback( self, *, @@ -474,7 +615,9 @@ def list_paper_feedback( PaperFeedbackModel.user_id == user_id, PaperFeedbackModel.track_id == track_id ) if action: - stmt = stmt.where(PaperFeedbackModel.action == action) + stmt = stmt.where( + PaperFeedbackModel.action == self._normalize_feedback_action(action) + ) stmt = stmt.order_by(desc(PaperFeedbackModel.ts)).limit(limit) rows = session.execute(stmt).scalars().all() return [self._feedback_to_dict(r) for r in rows] @@ -488,9 +631,14 @@ def list_paper_feedback_ids( limit: int = 500, ) -> set[str]: ids: set[str] = set() - for row in self.list_paper_feedback( - user_id=user_id, track_id=track_id, action=action, limit=limit + normalized_action = self._normalize_feedback_action(action) + for row in self.list_effective_paper_feedback( + user_id=user_id, + track_id=track_id, + limit=limit, ): + if row.get("action") != normalized_action: + continue pid = str(row.get("paper_id") or "").strip() if pid: ids.add(pid) @@ -540,6 +688,7 @@ def list_saved_papers( ) -> List[Dict[str, Any]]: with self._provider.session() as session: saved_at_by_paper: Dict[int, datetime] = {} + saved_track_membership: Dict[int, set[int]] = defaultdict(set) status_rows = ( session.execute( @@ -557,29 +706,45 @@ def list_saved_papers( feedback_rows = ( session.execute( - select(PaperFeedbackModel).where( + select(PaperFeedbackModel) + .where( PaperFeedbackModel.user_id == user_id, - PaperFeedbackModel.action == "save", + PaperFeedbackModel.action.in_(["save", "unsave"]), PaperFeedbackModel.paper_ref_id.is_not(None), - ( - PaperFeedbackModel.track_id == int(track_id) - if track_id is not None - else True - ), ) + .order_by(desc(PaperFeedbackModel.ts), desc(PaperFeedbackModel.id)) ) .scalars() .all() ) + latest_save_state: Dict[int, tuple[bool, Optional[datetime]]] = {} for row in feedback_rows: pid = int(row.paper_ref_id or 0) if pid <= 0: continue + normalized_action = self._normalize_feedback_action(str(row.action or "")) + if normalized_action == "save": + saved_track_membership[pid].add(int(row.track_id or 0)) + if pid not in latest_save_state: + latest_save_state[pid] = (normalized_action == "save", row.ts) + + for pid, (is_saved, ts) in latest_save_state.items(): + if not is_saved: + saved_at_by_paper.pop(pid, None) + continue current = saved_at_by_paper.get(pid) - if current is None or (row.ts and row.ts > current): - saved_at_by_paper[pid] = row.ts or _utcnow() + if current is None or ((ts or _utcnow()) > current): + saved_at_by_paper[pid] = ts or _utcnow() - paper_ids = list(saved_at_by_paper.keys()) + if track_id is not None: + scoped_paper_ids = [ + pid + for pid in saved_at_by_paper.keys() + if int(track_id) in saved_track_membership.get(pid, set()) + ] + paper_ids = scoped_paper_ids + else: + paper_ids = list(saved_at_by_paper.keys()) if not paper_ids: return [] @@ -721,7 +886,18 @@ def list_track_feed( .where( PaperFeedbackModel.user_id == user_id, PaperFeedbackModel.track_id == int(track_id), - PaperFeedbackModel.action.in_(["save", "like", "dislike", "skip"]), + PaperFeedbackModel.action.in_( + [ + "save", + "unsave", + "like", + "unlike", + "dislike", + "undislike", + "skip", + "cite", + ] + ), ) .order_by(desc(PaperFeedbackModel.ts), desc(PaperFeedbackModel.id)) ) @@ -764,18 +940,53 @@ def list_track_feed( candidate_ids = [int(p.id) for p in candidates] - feedback_by_paper: Dict[int, PaperFeedbackModel] = {} feedback_summary_by_paper: Dict[int, Dict[str, int]] = {} + feedback_state_by_paper: Dict[int, Dict[str, Any]] = {} + resolved_feedback_groups: set[tuple[int, str]] = set() for row in feedback_rows: pid = int(row.canonical_paper_id or row.paper_ref_id or 0) if pid <= 0: continue - action = str(row.action or "").strip().lower() - if action: + normalized_action = self._normalize_feedback_action(str(row.action or "")) + if normalized_action: action_counter = feedback_summary_by_paper.setdefault(pid, {}) - action_counter[action] = action_counter.get(action, 0) + 1 - if pid not in feedback_by_paper: - feedback_by_paper[pid] = row + action_counter[normalized_action] = action_counter.get(normalized_action, 0) + 1 + + group = self._feedback_group(normalized_action) + group_key = (pid, group) + if group_key in resolved_feedback_groups: + continue + resolved_feedback_groups.add(group_key) + + effective_action = self._effective_feedback_action(normalized_action) + if effective_action is None: + continue + + state = feedback_state_by_paper.setdefault( + pid, + { + "is_saved": False, + "is_liked": False, + "is_disliked": False, + "latest_effective_action": None, + "latest_effective_ts": None, + }, + ) + + if effective_action == "save": + state["is_saved"] = True + elif effective_action == "like": + state["is_liked"] = True + state["is_disliked"] = False + elif effective_action == "dislike": + state["is_disliked"] = True + state["is_liked"] = False + + event_ts = row.ts or _utcnow() + latest_effective_ts = state.get("latest_effective_ts") + if latest_effective_ts is None or event_ts >= latest_effective_ts: + state["latest_effective_action"] = effective_action + state["latest_effective_ts"] = event_ts status_by_paper = { int(row.paper_id): row @@ -820,16 +1031,19 @@ def list_track_feed( matched_terms = [term for term in terms if term and term in text_blob] keyword_score = float(len(matched_terms)) - latest_feedback = feedback_by_paper.get(pid) + feedback_state = feedback_state_by_paper.get(pid, {}) latest_feedback_action = ( - str(latest_feedback.action or "").strip().lower() if latest_feedback else "" + str(feedback_state.get("latest_effective_action") or "").strip().lower() ) - feedback_boost = { - "save": 3.0, - "like": 2.0, - "skip": -1.0, - "dislike": -4.0, - }.get(latest_feedback_action, 0.0) + feedback_boost = 0.0 + if bool(feedback_state.get("is_saved")): + feedback_boost += 3.0 + if bool(feedback_state.get("is_liked")): + feedback_boost += 2.0 + if bool(feedback_state.get("is_disliked")): + feedback_boost -= 4.0 + if latest_feedback_action == "skip": + feedback_boost -= 1.0 citation_score = min(float(paper.citation_count or 0) / 200.0, 2.0) judge_row = latest_judge_by_paper.get(pid) @@ -855,6 +1069,9 @@ def list_track_feed( else None ), "latest_feedback_action": latest_feedback_action or None, + "is_saved": bool(feedback_state.get("is_saved")), + "is_liked": bool(feedback_state.get("is_liked")), + "is_disliked": bool(feedback_state.get("is_disliked")), "feedback_summary": feedback_summary_by_paper.get(pid, {}), "matched_terms": matched_terms, "keyword_score": keyword_score, @@ -1993,6 +2210,7 @@ def _feedback_to_dict(f: PaperFeedbackModel) -> Dict[str, Any]: "track_id": f.track_id, "paper_id": f.paper_id, "paper_ref_id": f.paper_ref_id, + "canonical_paper_id": f.canonical_paper_id, "action": f.action, "weight": float(f.weight or 0.0), "ts": f.ts.isoformat() if f.ts else None, diff --git a/tests/unit/test_anchor_service.py b/tests/unit/test_anchor_service.py index 3221db08..e3ad252f 100644 --- a/tests/unit/test_anchor_service.py +++ b/tests/unit/test_anchor_service.py @@ -7,7 +7,10 @@ import pytest from sqlalchemy import select -from paperbot.application.services.anchor_service import AnchorService +from paperbot.application.services.anchor_service import ( + AnchorService, + _collapse_effective_feedback_actions, +) from paperbot.infrastructure.stores.author_store import AuthorStore from paperbot.infrastructure.stores.models import ( AuthorModel, @@ -153,11 +156,54 @@ def test_anchor_service_discovers_and_scores_authors(tmp_path: Path): def test_anchor_service_raises_for_unknown_track(tmp_path: Path): db_url = f"sqlite:///{tmp_path / 'anchor-track-missing.db'}" + provider = SessionProvider(db_url) + Base.metadata.create_all(provider.engine) service = AnchorService(db_url=db_url) with pytest.raises(ValueError, match="track not found"): service.discover(track_id=999, user_id="default") +def test_collapse_effective_feedback_actions_ignores_toggled_off_state() -> None: + now = datetime.now(timezone.utc) + rows = [ + PaperFeedbackModel( + user_id="u1", + track_id=1, + paper_id="42", + paper_ref_id=42, + canonical_paper_id=42, + action="unlike", + weight=0.0, + ts=now, + metadata_json="{}", + ), + PaperFeedbackModel( + user_id="u1", + track_id=1, + paper_id="42", + paper_ref_id=42, + canonical_paper_id=42, + action="like", + weight=0.0, + ts=now, + metadata_json="{}", + ), + PaperFeedbackModel( + user_id="u1", + track_id=1, + paper_id="84", + paper_ref_id=84, + canonical_paper_id=84, + action="dislike", + weight=0.0, + ts=now, + metadata_json="{}", + ), + ] + + assert _collapse_effective_feedback_actions(rows) == ["dislike"] + + def test_recompute_author_network_scores_updates_metadata(tmp_path: Path): db_url = f"sqlite:///{tmp_path / 'anchor-network-recompute.db'}" paper_store = PaperStore(db_url=db_url) @@ -204,3 +250,33 @@ def test_recompute_author_network_scores_updates_metadata(tmp_path: Path): found += 1 assert metadata["network_score"] >= 0 assert found >= 3 + + +def test_cleared_feedback_does_not_contribute_to_anchor_personalization() -> None: + now = datetime.now(timezone.utc) + rows = [ + PaperFeedbackModel( + user_id="default", + track_id=1, + paper_id="paper-1", + paper_ref_id=1, + canonical_paper_id=1, + action="unlike", + weight=0.0, + ts=now, + metadata_json="{}", + ), + PaperFeedbackModel( + user_id="default", + track_id=1, + paper_id="paper-1", + paper_ref_id=1, + canonical_paper_id=1, + action="like", + weight=0.0, + ts=now, + metadata_json="{}", + ), + ] + + assert _collapse_effective_feedback_actions(rows) == [] diff --git a/tests/unit/test_context_engine_personalized_mode.py b/tests/unit/test_context_engine_personalized_mode.py index c65b1516..5e46c531 100644 --- a/tests/unit/test_context_engine_personalized_mode.py +++ b/tests/unit/test_context_engine_personalized_mode.py @@ -96,7 +96,7 @@ async def test_personalized_mode_applies_saved_and_anchor_boosts(monkeypatch): async def test_global_mode_disables_personalization_boosts(monkeypatch): monkeypatch.setattr(engine_module, "_get_anchor_service", lambda: _FakeAnchorService()) - engine = ContextEngine( + global_engine = ContextEngine( research_store=_FakeResearchStore(), memory_store=_FakeMemoryStore(), paper_store=None, @@ -104,8 +104,23 @@ async def test_global_mode_disables_personalization_boosts(monkeypatch): track_router=_FakeTrackRouter(), config=ContextEngineConfig(personalized=False, paper_limit=5), ) + personalized_engine = ContextEngine( + research_store=_FakeResearchStore(), + memory_store=_FakeMemoryStore(), + paper_store=None, + search_service=_FakeSearchService(), + track_router=_FakeTrackRouter(), + config=ContextEngineConfig(personalized=True, paper_limit=5), + ) - pack = await engine.build_context_pack(user_id="u1", query="transformer", track_id=1) - score = float(pack["paper_recommendation_scores"]["1"]) + global_pack = await global_engine.build_context_pack(user_id="u1", query="transformer", track_id=1) + personalized_pack = await personalized_engine.build_context_pack( + user_id="u1", + query="transformer", + track_id=1, + ) + global_score = float(global_pack["paper_recommendation_scores"]["1"]) + personalized_score = float(personalized_pack["paper_recommendation_scores"]["1"]) - assert score < 0.30 + assert global_score < personalized_score + assert (personalized_score - global_score) > 0.35 diff --git a/tests/unit/test_feed_ranking.py b/tests/unit/test_feed_ranking.py index 3a0093d2..79379cb2 100644 --- a/tests/unit/test_feed_ranking.py +++ b/tests/unit/test_feed_ranking.py @@ -71,29 +71,33 @@ def test_saved_papers_rank_higher_than_skipped(tmp_path: Path): ) # Add "save" feedback for saved_pid - session.add(PaperFeedbackModel( - user_id=user_id, - track_id=track_id, - paper_id=str(saved_pid), - paper_ref_id=saved_pid, - canonical_paper_id=saved_pid, - action="save", - weight=0.0, - ts=now, - metadata_json="{}", - )) + session.add( + PaperFeedbackModel( + user_id=user_id, + track_id=track_id, + paper_id=str(saved_pid), + paper_ref_id=saved_pid, + canonical_paper_id=saved_pid, + action="save", + weight=0.0, + ts=now, + metadata_json="{}", + ) + ) # Add "skip" feedback for skipped_pid - session.add(PaperFeedbackModel( - user_id=user_id, - track_id=track_id, - paper_id=str(skipped_pid), - paper_ref_id=skipped_pid, - canonical_paper_id=skipped_pid, - action="skip", - weight=0.0, - ts=now, - metadata_json="{}", - )) + session.add( + PaperFeedbackModel( + user_id=user_id, + track_id=track_id, + paper_id=str(skipped_pid), + paper_ref_id=skipped_pid, + canonical_paper_id=skipped_pid, + action="skip", + weight=0.0, + ts=now, + metadata_json="{}", + ) + ) session.commit() result = store.list_track_feed(user_id=user_id, track_id=track_id, limit=10, offset=0) @@ -113,3 +117,112 @@ def test_saved_papers_rank_higher_than_skipped(tmp_path: Path): f"Saved paper score ({scores_by_pid[saved_pid]}) should be higher " f"than skipped paper score ({scores_by_pid[skipped_pid]})" ) + + +def test_track_feed_restores_saved_state_after_preference_is_cleared(tmp_path: Path): + """Clearing a preference should preserve an independent save state.""" + db_url = f"sqlite:///{tmp_path / 'feed-effective-state.db'}" + store = SqlAlchemyResearchStore(db_url=db_url) + + user_id = "test-user" + track = store.create_track( + user_id=user_id, + name="ML Research", + keywords=["machine learning"], + activate=True, + ) + track_id = int(track["id"]) + + with store._provider.session() as session: + paper_id = _insert_paper( + session, + title="Effective Feedback State Paper", + keywords=["machine learning", "optimization"], + ) + session.commit() + + store.add_paper_feedback( + user_id=user_id, + track_id=track_id, + paper_id=str(paper_id), + action="save", + ) + store.add_paper_feedback( + user_id=user_id, + track_id=track_id, + paper_id=str(paper_id), + action="like", + ) + store.add_paper_feedback( + user_id=user_id, + track_id=track_id, + paper_id=str(paper_id), + action="unlike", + ) + + result = store.list_track_feed(user_id=user_id, track_id=track_id, limit=10, offset=0) + item = next(row for row in result["items"] if int(row["paper"]["id"]) == paper_id) + + assert item["is_saved"] is True + assert item["is_liked"] is False + assert item["is_disliked"] is False + assert item["latest_feedback_action"] == "save" + + +def test_track_feed_keeps_save_and_like_as_independent_flags(tmp_path: Path): + db_url = f"sqlite:///{tmp_path / 'feed-flags.db'}" + store = SqlAlchemyResearchStore(db_url=db_url) + + user_id = "stateful-user" + track = store.create_track( + user_id=user_id, + name="Agents", + keywords=["agent"], + activate=True, + ) + track_id = int(track["id"]) + + with store._provider.session() as session: + paper_id = _insert_paper( + session, + title="Agent Planning Systems", + keywords=["agent", "planning"], + ) + session.commit() + + store.add_paper_feedback( + user_id=user_id, + track_id=track_id, + paper_id=str(paper_id), + action="save", + metadata={"title": "Agent Planning Systems"}, + ) + store.add_paper_feedback( + user_id=user_id, + track_id=track_id, + paper_id=str(paper_id), + action="like", + metadata={"title": "Agent Planning Systems"}, + ) + + first = store.list_track_feed(user_id=user_id, track_id=track_id, limit=10, offset=0) + item = next(row for row in first["items"] if int(row["paper"]["id"]) == paper_id) + assert item["is_saved"] is True + assert item["is_liked"] is True + assert item["is_disliked"] is False + assert item["latest_feedback_action"] == "like" + + store.add_paper_feedback( + user_id=user_id, + track_id=track_id, + paper_id=str(paper_id), + action="unlike", + metadata={"title": "Agent Planning Systems"}, + ) + + second = store.list_track_feed(user_id=user_id, track_id=track_id, limit=10, offset=0) + item = next(row for row in second["items"] if int(row["paper"]["id"]) == paper_id) + assert item["is_saved"] is True + assert item["is_liked"] is False + assert item["is_disliked"] is False + assert item["latest_feedback_action"] == "save" diff --git a/tests/unit/test_paper_judge_persistence.py b/tests/unit/test_paper_judge_persistence.py index cd80dca3..4576ffb1 100644 --- a/tests/unit/test_paper_judge_persistence.py +++ b/tests/unit/test_paper_judge_persistence.py @@ -6,7 +6,7 @@ from sqlalchemy import select from paperbot.domain.identity import PaperIdentity -from paperbot.infrastructure.stores.models import PaperJudgeScoreModel +from paperbot.infrastructure.stores.models import PaperJudgeScoreModel, PaperReadingStatusModel from paperbot.infrastructure.stores.paper_store import SqlAlchemyPaperStore from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore from paperbot.infrastructure.stores.identity_store import IdentityStore @@ -140,6 +140,126 @@ def test_saved_list_and_detail_from_research_store(tmp_path: Path): assert detail["reading_status"]["status"] == "read" +def test_unsave_removes_saved_state_from_library(tmp_path: Path): + db_path = tmp_path / "saved-unsave.db" + db_url = f"sqlite:///{db_path}" + + paper_store = SqlAlchemyPaperStore(db_url=db_url) + research_store = SqlAlchemyResearchStore(db_url=db_url) + + paper = paper_store.upsert_paper( + paper={ + "title": "Toggle Save Paper", + "url": "https://example.com/toggle-save", + "pdf_url": "https://example.com/toggle-save.pdf", + } + ) + + track = research_store.create_track(user_id="u-save", name="track-save", activate=True) + research_store.add_paper_feedback( + user_id="u-save", + track_id=int(track["id"]), + paper_id=str(paper["id"]), + action="save", + metadata={"title": "Toggle Save Paper"}, + ) + + assert len(research_store.list_saved_papers(user_id="u-save", limit=10)) == 1 + + research_store.add_paper_feedback( + user_id="u-save", + track_id=int(track["id"]), + paper_id=str(paper["id"]), + action="unsave", + metadata={"title": "Toggle Save Paper"}, + ) + + assert research_store.list_saved_papers(user_id="u-save", limit=10) == [] + + with research_store._provider.session() as session: + status = session.execute( + select(PaperReadingStatusModel).where( + PaperReadingStatusModel.user_id == "u-save", + PaperReadingStatusModel.paper_id == int(paper["id"]), + ) + ).scalar_one_or_none() + + assert status is not None + assert status.saved_at is None + + +def test_feedback_ids_follow_effective_toggle_state(tmp_path: Path): + db_path = tmp_path / "feedback-effective-state.db" + db_url = f"sqlite:///{db_path}" + + paper_store = SqlAlchemyPaperStore(db_url=db_url) + research_store = SqlAlchemyResearchStore(db_url=db_url) + + paper = paper_store.upsert_paper( + paper={ + "title": "Toggle Reaction Paper", + "url": "https://example.com/toggle-reaction", + "pdf_url": "https://example.com/toggle-reaction.pdf", + } + ) + + track = research_store.create_track(user_id="u-react", name="track-react", activate=True) + track_id = int(track["id"]) + paper_id = str(paper["id"]) + + research_store.add_paper_feedback( + user_id="u-react", + track_id=track_id, + paper_id=paper_id, + action="like", + metadata={"title": "Toggle Reaction Paper"}, + ) + assert research_store.list_paper_feedback_ids( + user_id="u-react", track_id=track_id, action="like" + ) == {paper_id} + + research_store.add_paper_feedback( + user_id="u-react", + track_id=track_id, + paper_id=paper_id, + action="unlike", + metadata={"title": "Toggle Reaction Paper"}, + ) + assert ( + research_store.list_paper_feedback_ids(user_id="u-react", track_id=track_id, action="like") + == set() + ) + + research_store.add_paper_feedback( + user_id="u-react", + track_id=track_id, + paper_id=paper_id, + action="dislike", + metadata={"title": "Toggle Reaction Paper"}, + ) + assert research_store.list_paper_feedback_ids( + user_id="u-react", track_id=track_id, action="dislike" + ) == {paper_id} + assert ( + research_store.list_paper_feedback_ids(user_id="u-react", track_id=track_id, action="like") + == set() + ) + + research_store.add_paper_feedback( + user_id="u-react", + track_id=track_id, + paper_id=paper_id, + action="undislike", + metadata={"title": "Toggle Reaction Paper"}, + ) + assert ( + research_store.list_paper_feedback_ids( + user_id="u-react", track_id=track_id, action="dislike" + ) + == set() + ) + + def test_feedback_resolves_via_identity_store_mapping(tmp_path: Path): db_path = tmp_path / "identity-feedback.db" db_url = f"sqlite:///{db_path}" diff --git a/tests/unit/test_research_feedback_state.py b/tests/unit/test_research_feedback_state.py new file mode 100644 index 00000000..13180b40 --- /dev/null +++ b/tests/unit/test_research_feedback_state.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from paperbot.api import main as api_main +from paperbot.api.routes import research as research_route +from paperbot.infrastructure.stores.paper_store import SqlAlchemyPaperStore +from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore + + +def _prepare_feedback_state_db(tmp_path: Path) -> tuple[SqlAlchemyResearchStore, dict, dict]: + db_path = tmp_path / "feedback-state.db" + db_url = f"sqlite:///{db_path}" + + paper_store = SqlAlchemyPaperStore(db_url=db_url) + research_store = SqlAlchemyResearchStore(db_url=db_url) + + paper = paper_store.upsert_paper( + paper={ + "title": "Transformer Alignment in Practice", + "abstract": "transformer alignment retrieval analysis", + "url": "https://example.com/paper", + } + ) + track = research_store.create_track( + user_id="u-feedback", + name="feedback-track", + keywords=["transformer"], + activate=True, + ) + return research_store, paper, track + + +def test_feedback_route_returns_effective_action_after_toggle(tmp_path, monkeypatch): + store, paper, track = _prepare_feedback_state_db(tmp_path) + monkeypatch.setattr(research_route, "_research_store", store) + + with TestClient(api_main.app) as client: + liked = client.post( + "/api/research/papers/feedback", + json={ + "user_id": "u-feedback", + "track_id": int(track["id"]), + "paper_id": str(paper["id"]), + "action": "like", + }, + ) + cleared = client.post( + "/api/research/papers/feedback", + json={ + "user_id": "u-feedback", + "track_id": int(track["id"]), + "paper_id": str(paper["id"]), + "action": "unlike", + }, + ) + + assert liked.status_code == 200 + assert liked.json()["current_action"] == "like" + + assert cleared.status_code == 200 + assert cleared.json()["current_action"] is None + + +def test_like_keeps_saved_state_while_updating_effective_preference_ids(tmp_path: Path): + store, paper, track = _prepare_feedback_state_db(tmp_path) + track_id = int(track["id"]) + paper_id = str(paper["id"]) + + store.add_paper_feedback( + user_id="u-feedback", + track_id=track_id, + paper_id=paper_id, + action="save", + metadata={"title": paper["title"]}, + ) + saved_before = store.list_saved_papers(user_id="u-feedback", track_id=track_id) + assert len(saved_before) == 1 + + store.add_paper_feedback( + user_id="u-feedback", + track_id=track_id, + paper_id=paper_id, + action="like", + metadata={}, + ) + + saved_after = store.list_saved_papers(user_id="u-feedback", track_id=track_id) + assert len(saved_after) == 1 + assert store.list_paper_feedback_ids( + user_id="u-feedback", + track_id=track_id, + action="save", + ) == {paper_id} + assert store.list_paper_feedback_ids( + user_id="u-feedback", + track_id=track_id, + action="like", + ) == {paper_id} + + +def test_unsave_clears_feed_saved_flags(tmp_path: Path): + store, paper, track = _prepare_feedback_state_db(tmp_path) + track_id = int(track["id"]) + paper_id = int(paper["id"]) + + store.add_paper_feedback( + user_id="u-feedback", + track_id=track_id, + paper_id=str(paper_id), + action="save", + metadata={"title": paper["title"]}, + ) + store.add_paper_feedback( + user_id="u-feedback", + track_id=track_id, + paper_id=str(paper_id), + action="unsave", + metadata={}, + ) + + feed = store.list_track_feed(user_id="u-feedback", track_id=track_id, limit=10, offset=0) + item = next(row for row in feed["items"] if int(row["paper"]["id"]) == paper_id) + + assert item["latest_feedback_action"] is None + assert item["is_saved"] is False diff --git a/web/src/components/research/FeedTab.tsx b/web/src/components/research/FeedTab.tsx index c02d7242..0dad48af 100644 --- a/web/src/components/research/FeedTab.tsx +++ b/web/src/components/research/FeedTab.tsx @@ -5,6 +5,11 @@ import { Loader2, RefreshCw } from "lucide-react" import { Button } from "@/components/ui/button" import { getErrorMessage } from "@/lib/fetch" +import { + normalizePaperPreferenceAction, + type PaperFeedbackAction, + type PaperFeedbackRequestAction, +} from "@/lib/paper-feedback" import { Card, CardContent } from "@/components/ui/card" import { PaperCard, type Paper } from "./PaperCard" @@ -22,6 +27,9 @@ type FeedItem = { } latest_judge?: Paper["latest_judge"] latest_feedback_action?: string | null + is_saved?: boolean + is_liked?: boolean + is_disliked?: boolean } type FeedResponse = { @@ -34,13 +42,19 @@ type FeedResponse = { interface FeedTabProps { userId: string trackId: number | null - onLike?: (paperId: string, rank: number) => Promise | void - onSave?: (paperId: string, rank: number, paper: Paper) => Promise | void - onDislike?: (paperId: string, rank: number) => Promise | void + onFeedbackAction?: ( + paperId: string, + action: PaperFeedbackRequestAction, + rank: number, + paper: Paper + ) => Promise | PaperFeedbackAction | null | undefined } function toPaper(item: FeedItem): Paper { const id = String(item.paper.id || "") + const preferenceAction = + normalizePaperPreferenceAction(item.latest_feedback_action) || + (item.is_liked ? "like" : item.is_disliked ? "dislike" : null) return { paper_id: id, title: item.paper.title || "Untitled", @@ -51,11 +65,14 @@ function toPaper(item: FeedItem): Paper { citation_count: item.paper.citation_count || 0, url: item.paper.url, latest_judge: item.latest_judge, - is_saved: (item.latest_feedback_action || "").toLowerCase() === "save", + feedback_action: preferenceAction, + is_saved: Boolean(item.is_saved), + is_liked: preferenceAction === "like", + is_disliked: preferenceAction === "dislike", } } -export function FeedTab({ userId, trackId, onLike, onSave, onDislike }: FeedTabProps) { +export function FeedTab({ userId, trackId, onFeedbackAction }: FeedTabProps) { const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -125,9 +142,11 @@ export function FeedTab({ userId, trackId, onLike, onSave, onDislike }: FeedTabP key={`${paper.paper_id}-${idx}`} paper={paper} rank={idx} - onLike={onLike ? () => onLike(paper.paper_id, idx) : undefined} - onSave={onSave ? () => onSave(paper.paper_id, idx, paper) : undefined} - onDislike={onDislike ? () => onDislike(paper.paper_id, idx) : undefined} + onFeedbackAction={ + onFeedbackAction + ? (action) => onFeedbackAction(paper.paper_id, action, idx, paper) + : undefined + } /> ))} diff --git a/web/src/components/research/PaperCard.tsx b/web/src/components/research/PaperCard.tsx index 6e920d3a..34f9997d 100644 --- a/web/src/components/research/PaperCard.tsx +++ b/web/src/components/research/PaperCard.tsx @@ -3,6 +3,14 @@ import { useEffect, useState } from "react" import { Check, ChevronDown, ChevronRight, ExternalLink, FlaskConical, Database, CheckCircle, AlertTriangle, Heart, Loader2, Save, ThumbsDown } from "lucide-react" +import { + normalizePaperPreferenceAction, + togglePaperPreferenceAction, + toggleSaveFeedbackAction, + type PaperFeedbackAction, + type PaperPreferenceAction, + type PaperFeedbackRequestAction, +} from "@/lib/paper-feedback" import { cn, safeHref } from "@/lib/utils" import { ReasoningBlock, ToolActionsGroup } from "@/components/ai-elements" import { Badge } from "@/components/ui/badge" @@ -24,6 +32,9 @@ export type Paper = { evidence_quotes?: Array<{ text: string; source_url?: string; page_hint?: string }> } is_saved?: boolean + is_liked?: boolean + is_disliked?: boolean + feedback_action?: PaperFeedbackAction | null retrieval_sources?: string[] retrieval_score?: number source?: string @@ -39,30 +50,56 @@ interface PaperCardProps { paper: Paper rank?: number reasons?: string[] - onLike?: () => Promise | void - onSave?: () => Promise | void - onDislike?: () => Promise | void + onFeedbackAction?: ( + action: PaperFeedbackRequestAction + ) => Promise | PaperFeedbackAction | null | undefined isLoading?: boolean className?: string } +function derivePreferenceAction( + feedbackAction: Paper["feedback_action"], + isLiked: Paper["is_liked"], + isDisliked: Paper["is_disliked"] +): PaperPreferenceAction | null { + const explicitAction = normalizePaperPreferenceAction(feedbackAction) + if (explicitAction) { + return explicitAction + } + if (isLiked) { + return "like" + } + if (isDisliked) { + return "dislike" + } + return null +} + export function PaperCard({ paper, rank, reasons, - onLike, - onSave, - onDislike, + onFeedbackAction, isLoading = false, className, }: PaperCardProps) { const [isSaved, setIsSaved] = useState(Boolean(paper.is_saved)) + const derivedPreferenceAction = derivePreferenceAction( + paper.feedback_action, + paper.is_liked, + paper.is_disliked + ) + const [preferenceAction, setPreferenceAction] = useState( + derivedPreferenceAction + ) useEffect(() => { setIsSaved(Boolean(paper.is_saved)) - }, [paper.is_saved]) - const [isLiked, setIsLiked] = useState(false) - const [isDisliked, setIsDisliked] = useState(false) + setPreferenceAction(derivedPreferenceAction) + }, [derivedPreferenceAction, paper.is_saved]) + + const isLiked = preferenceAction === "like" + const isDisliked = preferenceAction === "dislike" const [actionLoading, setActionLoading] = useState(null) const [evidenceOpen, setEvidenceOpen] = useState(false) const [cardOpen, setCardOpen] = useState(false) @@ -77,54 +114,40 @@ export function PaperCard({ const judgeRec = String(judge?.recommendation || "").replace(/_/g, " ") const evidenceQuotes = judge?.evidence_quotes || [] - const handleSave = async () => { - if (!onSave) return - // Toggle local saved state; only call onSave when transitioning from unsaved -> saved. - if (isSaved) { - setIsSaved(false) - return - } + const runSaveAction = async () => { + if (!onFeedbackAction) return + const requestAction = toggleSaveFeedbackAction(isSaved) setActionLoading("save") try { - await onSave() - setIsSaved(true) + await onFeedbackAction(requestAction) + setIsSaved((prev) => !prev) } finally { setActionLoading(null) } } - const handleLike = async () => { - if (!onLike) return - // Toggle like; when turning off, only update local state. - if (isLiked) { - setIsLiked(false) - return - } - setActionLoading("like") + const runPreferenceAction = async (targetAction: PaperPreferenceAction) => { + if (!onFeedbackAction) return + const requestAction = togglePaperPreferenceAction(preferenceAction, targetAction) + setActionLoading(targetAction) try { - await onLike() - setIsLiked(true) - setIsDisliked(false) + await onFeedbackAction(requestAction) + setPreferenceAction(requestAction === targetAction ? targetAction : null) } finally { setActionLoading(null) } } + const handleSave = async () => { + await runSaveAction() + } + + const handleLike = async () => { + await runPreferenceAction("like") + } + const handleDislike = async () => { - if (!onDislike) return - // Toggle dislike; when turning off, only update local state. - if (isDisliked) { - setIsDisliked(false) - return - } - setActionLoading("dislike") - try { - await onDislike() - setIsDisliked(true) - setIsLiked(false) - } finally { - setActionLoading(null) - } + await runPreferenceAction("dislike") } const handleToggleCard = async () => { @@ -324,7 +347,7 @@ export function PaperCard({ className="pt-1" ariaLabel="Paper actions" actions={[ - ...(onSave + ...(onFeedbackAction ? [ { id: "save", @@ -352,7 +375,7 @@ export function PaperCard({ }, ] : []), - ...(onLike + ...(onFeedbackAction ? [ { id: "like", @@ -370,7 +393,7 @@ export function PaperCard({ }, ] : []), - ...(onDislike + ...(onFeedbackAction ? [ { id: "dislike", diff --git a/web/src/components/research/ResearchDashboard.tsx b/web/src/components/research/ResearchDashboard.tsx index cce4031a..afc30e07 100644 --- a/web/src/components/research/ResearchDashboard.tsx +++ b/web/src/components/research/ResearchDashboard.tsx @@ -2,8 +2,7 @@ import { useEffect, useMemo, useState } from "react" -import { fetchJson } from "@/lib/fetch" -import { mergeTracksStable } from "@/lib/utils" +import { fetchJson, getErrorMessage } from "@/lib/fetch" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -91,8 +90,6 @@ type ConfirmAction = | { type: "bulk_moderate"; status: "approved" | "rejected"; itemIds: number[] } | { type: "bulk_move"; itemIds: number[]; targetTrackId: number } | { type: "clear_track_memory"; trackId: number } - -// Using shared fetch helpers from @/lib/fetch; remove local duplicates function clampNumber(value: number, min: number, max: number, fallback: number) { if (!Number.isFinite(value)) return fallback return Math.min(max, Math.max(min, value)) @@ -187,9 +184,8 @@ export default function ResearchDashboard() { async function refreshTracks(): Promise { const data = await fetchJson<{ tracks: Track[] }>(`/api/research/tracks?user_id=${encodeURIComponent(userId)}`) - const tracksFromApi = data.tracks || [] - setTracks((prev) => mergeTracksStable(prev, tracksFromApi)) - const active = tracksFromApi.find((t) => t.is_active) + setTracks(data.tracks || []) + const active = data.tracks.find((t) => t.is_active) const activeId = active?.id ?? null setActiveTrackId(activeId) setMoveTargetTrackId("") @@ -214,7 +210,7 @@ export default function ResearchDashboard() { useEffect(() => { setError(null) - refreshTracks().catch((e) => setError(e instanceof Error ? e.message : String(e))) + refreshTracks().catch((e) => setError(getErrorMessage(e))) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -267,7 +263,7 @@ export default function ResearchDashboard() { await refreshTracks() } } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -292,7 +288,7 @@ export default function ResearchDashboard() { setSuggestText("") await refreshInbox(activeTrackId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -315,7 +311,7 @@ export default function ResearchDashboard() { }) await refreshInbox(activeTrackId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -339,7 +335,7 @@ export default function ResearchDashboard() { }) await refreshInbox(activeTrackId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -378,7 +374,7 @@ export default function ResearchDashboard() { const activeId = await refreshTracks() await refreshInbox(activeId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -416,7 +412,7 @@ export default function ResearchDashboard() { }) await buildContext(false) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -432,7 +428,7 @@ export default function ResearchDashboard() { ) await refreshInbox(trackId) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -931,7 +927,7 @@ export default function ResearchDashboard() { /> ) : ( diff --git a/web/src/components/research/ResearchDiscoveryPage.tsx b/web/src/components/research/ResearchDiscoveryPage.tsx index b6b35238..c9f2b29e 100644 --- a/web/src/components/research/ResearchDiscoveryPage.tsx +++ b/web/src/components/research/ResearchDiscoveryPage.tsx @@ -5,8 +5,7 @@ import { useEffect, useMemo, useState } from "react" import { useSearchParams } from "next/navigation" import { ArrowLeft, Compass } from "lucide-react" -import { mergeTracksStable } from "@/lib/utils" -import { fetchJson } from "@/lib/fetch" +import { fetchJson, getErrorMessage } from "@/lib/fetch" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -15,8 +14,6 @@ import DiscoveryGraphWorkspace from "./DiscoveryGraphWorkspace" import type { Track } from "./TrackSelector" type SeedType = "doi" | "arxiv" | "openalex" | "semantic_scholar" | "author" - -// Note: fetchJson is imported from @/lib/fetch export default function ResearchDiscoveryPage() { const searchParams = useSearchParams() const [userId] = useState("default") @@ -48,7 +45,7 @@ export default function ResearchDiscoveryPage() { ) useEffect(() => { - refreshTracks().catch((err) => setError(err instanceof Error ? err.message : String(err))) + refreshTracks().catch((err) => setError(getErrorMessage(err))) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -64,9 +61,8 @@ export default function ResearchDiscoveryPage() { const data = await fetchJson<{ tracks: Track[] }>( `/api/research/tracks?user_id=${encodeURIComponent(userId)}` ) - const tracksFromApi = data.tracks || [] - setTracks((prev) => mergeTracksStable(prev, tracksFromApi)) - const active = tracksFromApi.find((track) => track.is_active) + setTracks(data.tracks || []) + const active = data.tracks.find((track) => track.is_active) setActiveTrackId(active?.id ?? null) } @@ -84,7 +80,7 @@ export default function ResearchDiscoveryPage() { ) await refreshTracks() } catch (err) { - setError(err instanceof Error ? err.message : String(err)) + setError(getErrorMessage(err)) } finally { setLoading(false) } diff --git a/web/src/components/research/ResearchPageNew.tsx b/web/src/components/research/ResearchPageNew.tsx index 418e7664..99479db8 100644 --- a/web/src/components/research/ResearchPageNew.tsx +++ b/web/src/components/research/ResearchPageNew.tsx @@ -4,9 +4,14 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { useSearchParams } from "next/navigation" -import { cn, mergeTracksStable } from "@/lib/utils" -import { fetchJson } from "@/lib/fetch" -import { showDiscoveryLink } from "@/config/features" +import { + currentFeedbackFromRequestAction, + normalizePaperFeedbackAction, + type PaperFeedbackAction, + type PaperFeedbackRequestAction, +} from "@/lib/paper-feedback" +import { cn } from "@/lib/utils" +import { fetchJson, getErrorMessage } from "@/lib/fetch" import { ArrowRight, BookOpen } from "lucide-react" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -47,9 +52,6 @@ type ContextPack = { paper_recommendations?: Paper[] paper_recommendation_reasons?: Record } - -// Removed local UpstreamErrorBody/toFriendlyErrorMessage/fetchJson duplicates — using @/lib/fetch - function getGreeting(): string { const hour = new Date().getHours() if (hour < 12) return "Good morning" @@ -110,7 +112,7 @@ export default function ResearchPageNew() { // Load tracks on mount useEffect(() => { - refreshTracks().catch((e) => setError(e instanceof Error ? e.message : String(e))) + refreshTracks().catch((e) => setError(getErrorMessage(e))) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -132,9 +134,8 @@ export default function ResearchPageNew() { const data = await fetchJson<{ tracks: Track[] }>( `/api/research/tracks?user_id=${encodeURIComponent(userId)}` ) - const tracksFromApi = data.tracks || [] - setTracks((prev) => mergeTracksStable(prev, tracksFromApi)) - const active = tracksFromApi.find((t) => t.is_active) + setTracks(data.tracks || []) + const active = data.tracks.find((t) => t.is_active) const activeId = active?.id ?? null setActiveTrackId(activeId) return activeId @@ -153,7 +154,7 @@ export default function ResearchPageNew() { ) await refreshTracks() } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -203,7 +204,7 @@ export default function ResearchPageNew() { setContextPack(data.context_pack) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setIsSearching(false) } @@ -274,7 +275,7 @@ export default function ResearchPageNew() { await refreshTracks() return true } catch (e) { - const message = e instanceof Error ? e.message : String(e) + const message = getErrorMessage(e) if (message.startsWith("409")) { setCreateError(`Track "${name}" already exists.`) } else { @@ -320,7 +321,7 @@ export default function ResearchPageNew() { await refreshTracks() return true } catch (e) { - const message = e instanceof Error ? e.message : String(e) + const message = getErrorMessage(e) if (message.startsWith("409")) { setEditError(`Track "${name}" already exists.`) } else { @@ -354,7 +355,7 @@ export default function ResearchPageNew() { setConfirmClearOpen(false) setTrackToClear(null) } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + setError(getErrorMessage(e)) } finally { setLoading(false) } @@ -362,10 +363,10 @@ export default function ResearchPageNew() { async function handleFeedback( paperId: string, - action: string, + action: PaperFeedbackRequestAction, rank?: number, paper?: Paper - ): Promise { + ): Promise { // Don't set global loading - PaperCard handles its own loading state setError(null) const body: Record = { @@ -398,11 +399,12 @@ export default function ResearchPageNew() { body.paper_source = paper.source || "semantic_scholar" } - await fetchJson(`/api/research/papers/feedback`, { + const payload = await fetchJson<{ current_action?: string | null }>(`/api/research/papers/feedback`, { method: "POST", body: JSON.stringify(body), headers: { "Content-Type": "application/json" }, }) + return normalizePaperFeedbackAction(payload.current_action) ?? currentFeedbackFromRequestAction(action) } const trackToClearName = tracks.find((t) => t.id === trackToClear)?.name || "this track" @@ -414,6 +416,19 @@ export default function ResearchPageNew() { return qs ? `/research/discovery?${qs}` : "/research/discovery" }, [query, activeTrackId]) + const communityRadarHref = useMemo(() => { + const params = new URLSearchParams() + if (activeTrackId) params.set("radar_track", String(activeTrackId)) + + const keywordSeed = query.trim() || activeTrack?.keywords?.[0] || "" + if (keywordSeed) { + params.set("radar_keyword", keywordSeed) + } + + const qs = params.toString() + return qs ? `/dashboard?${qs}` : "/dashboard" + }, [query, activeTrack, activeTrackId]) + return (
Sources: {searchSources.length} Results: {papers.length}
- {showDiscoveryLink() && ( +
+ - )} +
@@ -600,9 +621,9 @@ export default function ResearchPageNew() { hasSearched={hasSearched} selectedSources={searchSources} onToggleSource={toggleSearchSource} - onLike={(paperId, rank, paper) => handleFeedback(paperId, "like", rank, paper)} - onSave={(paperId, rank, paper) => handleFeedback(paperId, "save", rank, paper)} - onDislike={(paperId, rank, paper) => handleFeedback(paperId, "dislike", rank, paper)} + onFeedbackAction={(paperId, action, rank, paper) => + handleFeedback(paperId, action, rank, paper) + } /> )} diff --git a/web/src/components/research/SearchResults.tsx b/web/src/components/research/SearchResults.tsx index 44db4e4d..99b7879c 100644 --- a/web/src/components/research/SearchResults.tsx +++ b/web/src/components/research/SearchResults.tsx @@ -2,6 +2,10 @@ import { Loader2 } from "lucide-react" +import { + type PaperFeedbackAction, + type PaperFeedbackRequestAction, +} from "@/lib/paper-feedback" import { cn } from "@/lib/utils" import { Card, CardContent } from "@/components/ui/card" import { Skeleton } from "@/components/ui/skeleton" @@ -16,9 +20,12 @@ interface SearchResultsProps { className?: string selectedSources?: string[] onToggleSource?: (source: string) => void - onLike?: (paperId: string, rank: number, paper: Paper) => Promise | void - onSave?: (paperId: string, rank: number, paper: Paper) => Promise | void - onDislike?: (paperId: string, rank: number, paper: Paper) => Promise | void + onFeedbackAction?: ( + paperId: string, + action: PaperFeedbackRequestAction, + rank: number, + paper: Paper + ) => Promise | PaperFeedbackAction | null | undefined } const SOURCE_OPTIONS: Array<{ value: string; label: string }> = [ @@ -60,9 +67,7 @@ export function SearchResults({ className, selectedSources = ["semantic_scholar"], onToggleSource, - onLike, - onSave, - onDislike, + onFeedbackAction, }: SearchResultsProps) { // Not searched yet - show nothing if (!hasSearched) { @@ -143,9 +148,11 @@ export function SearchResults({ paper={paper} rank={idx} reasons={reasons?.[paper.paper_id]} - onLike={onLike ? () => onLike(paper.paper_id, idx, paper) : undefined} - onSave={onSave ? () => onSave(paper.paper_id, idx, paper) : undefined} - onDislike={onDislike ? () => onDislike(paper.paper_id, idx, paper) : undefined} + onFeedbackAction={ + onFeedbackAction + ? (action) => onFeedbackAction(paper.paper_id, action, idx, paper) + : undefined + } className={cn( "animate-in fade-in slide-in-from-bottom-2", // Staggered animation delay diff --git a/web/src/components/research/TrackPills.test.ts b/web/src/components/research/TrackPills.test.ts new file mode 100644 index 00000000..94da7d9a --- /dev/null +++ b/web/src/components/research/TrackPills.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest" + +import { getVisibleTracks } from "./TrackPills" +import type { Track } from "./TrackSelector" + +function makeTrack(id: number, name: string): Track { + return { id, name } +} + +describe("getVisibleTracks", () => { + it("keeps the active track visible when it falls outside the initial slice", () => { + const tracks = [ + makeTrack(1, "Track 1"), + makeTrack(2, "Track 2"), + makeTrack(3, "Track 3"), + makeTrack(4, "Track 4"), + makeTrack(5, "Track 5"), + makeTrack(6, "Track 6"), + ] + + const visible = getVisibleTracks(tracks, 6, 5) + + expect(visible.map((track) => track.id)).toEqual([1, 2, 3, 4, 6]) + }) + + it("does not reorder tracks when the active one is already visible", () => { + const tracks = [ + makeTrack(1, "Track 1"), + makeTrack(2, "Track 2"), + makeTrack(3, "Track 3"), + ] + + const visible = getVisibleTracks(tracks, 2, 5) + + expect(visible.map((track) => track.id)).toEqual([1, 2, 3]) + }) +}) diff --git a/web/src/components/research/TrackPills.tsx b/web/src/components/research/TrackPills.tsx index 5bc68343..7ee6d456 100644 --- a/web/src/components/research/TrackPills.tsx +++ b/web/src/components/research/TrackPills.tsx @@ -41,6 +41,26 @@ function getTrackIcon(name: string): LucideIcon { return trackIcons[name] || defaultIcon } +export function getVisibleTracks( + tracks: Track[], + activeTrackId: number | null, + maxVisible: number, +): Track[] { + if (maxVisible <= 0) { + return [] + } + + const activeTrack = tracks.find((track) => track.id === activeTrackId) || null + const visibleTracks = tracks.slice(0, maxVisible) + if ( + activeTrack && + !visibleTracks.some((track) => track.id === activeTrack.id) + ) { + visibleTracks.splice(Math.max(visibleTracks.length - 1, 0), 1, activeTrack) + } + return visibleTracks +} + export function TrackPills({ tracks, activeTrackId, @@ -49,7 +69,7 @@ export function TrackPills({ disabled = false, maxVisible = 5, }: TrackPillsProps) { - const visibleTracks = tracks.slice(0, maxVisible) + const visibleTracks = getVisibleTracks(tracks, activeTrackId, maxVisible) const hasMore = tracks.length > maxVisible return ( diff --git a/web/src/lib/paper-feedback.test.ts b/web/src/lib/paper-feedback.test.ts new file mode 100644 index 00000000..eb016b74 --- /dev/null +++ b/web/src/lib/paper-feedback.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest" + +import { + currentFeedbackFromRequestAction, + normalizePaperFeedbackAction, + normalizePaperPreferenceAction, + togglePaperPreferenceAction, + toggleSaveFeedbackAction, +} from "./paper-feedback" + +describe("paper-feedback", () => { + it("normalizes persisted feedback actions into active UI state", () => { + expect(normalizePaperFeedbackAction("save")).toBe("save") + expect(normalizePaperFeedbackAction("not_relevant")).toBe("dislike") + expect(normalizePaperFeedbackAction("unlike")).toBeNull() + expect(normalizePaperPreferenceAction("save")).toBeNull() + expect(normalizePaperPreferenceAction("not_relevant")).toBe("dislike") + }) + + it("derives save toggle request actions from the current state", () => { + expect(toggleSaveFeedbackAction(false)).toBe("save") + expect(toggleSaveFeedbackAction(true)).toBe("unsave") + }) + + it("derives preference toggle request actions from the current state", () => { + expect(togglePaperPreferenceAction(null, "like")).toBe("like") + expect(togglePaperPreferenceAction("like", "like")).toBe("unlike") + expect(togglePaperPreferenceAction("dislike", "dislike")).toBe("undislike") + }) + + it("maps request actions back into active state after a successful mutation", () => { + expect(currentFeedbackFromRequestAction("save")).toBe("save") + expect(currentFeedbackFromRequestAction("unsave")).toBeNull() + expect(currentFeedbackFromRequestAction("undislike")).toBeNull() + }) +}) diff --git a/web/src/lib/paper-feedback.ts b/web/src/lib/paper-feedback.ts new file mode 100644 index 00000000..a1e5563d --- /dev/null +++ b/web/src/lib/paper-feedback.ts @@ -0,0 +1,56 @@ +export type PaperPreferenceAction = "like" | "dislike" + +export type PaperFeedbackAction = "save" | PaperPreferenceAction + +export type PaperFeedbackRequestAction = + | PaperFeedbackAction + | "unsave" + | "unlike" + | "undislike" + +function normalizeFeedbackKey(action: string | null | undefined): string { + return String(action || "").trim().toLowerCase().replace(/\s+/g, "_") +} + +export function normalizePaperFeedbackAction( + action: string | null | undefined, +): PaperFeedbackAction | null { + const normalized = normalizeFeedbackKey(action) + if (normalized === "not_relevant" || normalized === "not-related") { + return "dislike" + } + if (normalized === "save" || normalized === "like" || normalized === "dislike") { + return normalized + } + return null +} + +export function normalizePaperPreferenceAction( + action: string | null | undefined, +): PaperPreferenceAction | null { + const normalized = normalizePaperFeedbackAction(action) + if (normalized === "like" || normalized === "dislike") { + return normalized + } + return null +} + +export function currentFeedbackFromRequestAction( + action: PaperFeedbackRequestAction, +): PaperFeedbackAction | null { + return normalizePaperFeedbackAction(action) +} + +export function toggleSaveFeedbackAction(isSaved: boolean): PaperFeedbackRequestAction { + return isSaved ? "unsave" : "save" +} + +export function togglePaperPreferenceAction( + currentAction: PaperPreferenceAction | null, + targetAction: PaperPreferenceAction, +): PaperFeedbackRequestAction { + if (currentAction !== targetAction) { + return targetAction + } + return targetAction === "like" ? "unlike" : "undislike" +} diff --git a/web/vitest.config.ts b/web/vitest.config.ts index ab76a35a..02949a02 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,6 +1,13 @@ +import path from "node:path" + import { defineConfig } from "vitest/config" export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, test: { environment: "node", },