Skip to content

feat(search): RRF fusion, personalized ranking & date filters#122

Merged
jerry609 merged 7 commits into
masterfrom
feat/rrf-personalized-ranking-v1
Feb 14, 2026
Merged

feat(search): RRF fusion, personalized ranking & date filters#122
jerry609 merged 7 commits into
masterfrom
feat/rrf-personalized-ranking-v1

Conversation

@jerry609

@jerry609 jerry609 commented Feb 14, 2026

Copy link
Copy Markdown
Owner

Summary

Implements a multi-source Reciprocal Rank Fusion (RRF) search pipeline with personalized ranking and date range filtering for the research feature.

Changes

RRF Fusion (#118)

  • Replace simple dedup ordering with weighted RRF scoring across all search sources
  • Merge duplicates by title_hash, accumulate source contributions
  • Surface retrieval metadata (rrf_score + contributing sources) to downstream ranking

Personalized Mode & Anchor Boosts (#119)

  • Add personalized/global toggle to search UI
  • Boost papers from anchor authors in personalized mode
  • Wire anchor scores into RRF pipeline

Feedback-Driven Source Weights (#120)

  • Learn per-source RRF weights from user feedback (save/like/dislike)
  • Beta-style smoothing to avoid extreme swings under sparse data
  • Auto-activate when personalized mode is on

Date Range Filters (#121)

  • Add year_from/year_to params to context API with validation
  • Compact number inputs in search toolbar
  • Filter applied in both online search and local DB fallback paths

Testing

  • test_context_engine_source_weights.py — unit tests for feedback weight learning
  • test_context_engine_date_filters.py — unit tests for year range filtering

Closes #118 #119 #120 #121

Summary by CodeRabbit

  • New Features

    • Year-range filters and a personalization toggle for research searches; personalization adds anchor-based boosts.
    • Multi-source fusion with deduplication and fused relevance scoring; papers include retrieval score and retrieval sources.
    • UI: year inputs in search box; like/dislike callbacks now receive the full paper object.
    • New endpoint fields to accept personalization and year_from/year_to.
  • Bug Fixes

    • Missing venue values normalized to an empty string; author sync can be opted out on save.
  • Tests

    • Unit tests for date filters, personalization, source-weight learning, and fusion behavior.

Learn per-source RRF weights from user feedback (save/like/dislike)
using Beta-style smoothing. Wires learned weights into search pipeline
when personalized mode is active.

Closes #120
Add year_from/year_to params to context API and wire through to
search pipeline. Frontend gets two compact number inputs in the
search toolbar.

Closes #121
Copilot AI review requested due to automatic review settings February 14, 2026 04:30
@vercel

vercel Bot commented Feb 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
paper-bot Ready Ready Preview, Comment Feb 14, 2026 6:58am

@coderabbitai

coderabbitai Bot commented Feb 14, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds RRF-based multi-source fusion with per-source weighting and learned weights, personalization controls (anchor boosts, personalize flag) and year-range filtering; propagates personalized, year_from, and year_to through API, ContextEngine, PaperSearchService, metrics, domain models, tests, and frontend UI.

Changes

Cohort / File(s) Summary
API Route & Request Validation
src/paperbot/api/routes/research.py
Added personalized, year_from, year_to to ContextRequest; validate year_from <= year_to; propagate fields into context build and metric payloads.
Context Engine
src/paperbot/context_engine/engine.py
Extended ContextEngineConfig with personalized, year_from, year_to; added anchor-service accessor, learned source-weight estimator, year-range filtering, anchor-based boosts, and routing metadata propagation.
Paper Search & Fusion
src/paperbot/application/services/paper_search_service.py
Implemented Reciprocal Rank Fusion (RRF) with configurable rrf_k and source_weights; added dedup helpers (_paper_key, _paper_quality, _fuse_with_rrf); produce fused, deduplicated results with retrieval provenance and per-paper retrieval scores.
Anchor Service
src/paperbot/application/services/anchor_service.py
Added get_followed_paper_anchor_scores(user_id, track_id, paper_ids) returning per-paper max personalized anchor scores via DB joins and aggregation.
Domain Model
src/paperbot/domain/paper.py
Added paper_id to PaperMeta; added retrieval_score and retrieval_sources to PaperCandidate and serialization paths.
Store behavior
src/paperbot/infrastructure/stores/paper_store.py
upsert_paper gains sync_authors flag (default True); venue defaults to "" when missing; author sync gated by sync_authors.
Backend Tests
tests/unit/...
test_context_engine_date_filters.py, test_context_engine_personalized_mode.py, test_context_engine_source_weights.py, test_paper_search_service_rrf.py
Added tests for year-range propagation, personalization with saved+anchor boosts, learning source weights, and RRF fusion + dedup behavior.
Frontend types & UI
web/src/components/research/PaperCard.tsx, ResearchPageNew.tsx, SearchBox.tsx, SearchResults.tsx
Extended Paper shape with retrieval metadata; added yearFrom/yearTo state and inputs; include personalized flag in requests; updated SearchResults callbacks to pass Paper object; wired new props through components.
Misc config
.mcp.json
Formatting-only changes to MCP server args arrays.

Sequence Diagrams

sequenceDiagram
    participant Frontend as Frontend (UI)
    participant API as ContextAPI
    participant Engine as ContextEngine
    participant Search as PaperSearchService
    participant AdapterA as Adapter(semscholar)
    participant AdapterB as Adapter(openalex)
    participant Anchor as AnchorService
    participant Feedback as FeedbackDB

    Frontend->>API: build_context_pack(personalized, year_from, year_to, query)
    API->>Engine: build_context(ContextRequest)
    Engine->>Feedback: query recent feedback rows
    Feedback-->>Engine: feedback rows
    Engine->>Engine: _learn_source_weights_from_feedback()
    Engine->>Search: search(query, selected_sources, source_weights, rrf_k, year_from, year_to)
    Search->>AdapterA: fetch results
    Search->>AdapterB: fetch results
    AdapterA-->>Search: results A
    AdapterB-->>Search: results B
    Search->>Search: _fuse_with_rrf(per_source_results)
    Search-->>Engine: fused, deduped papers (with retrieval_score, retrieval_sources)
    Engine->>Anchor: get_followed_paper_anchor_scores(user_id, paper_ids)
    Anchor-->>Engine: anchor scores
    Engine->>Engine: apply anchor boosts & filter by year range
    Engine-->>API: ContextResponse (routing metadata incl. learned weights, personalized, year_from/year_to)
    API-->>Frontend: context pack
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related issues

  • jerry609/PaperBot#120 — Learned source weights from feedback: this PR implements _learn_source_weights_from_feedback and integrates learned weights into search.
  • jerry609/PaperBot#119 — Personalization toggle & anchor boosts: this PR propagates personalized and applies anchor-author boosts in ContextEngine.
  • #118 — feat(search): RRF fusion for multi-source retrieval — this PR implements weighted RRF fusion, deduplication, and retrieval provenance matching the issue's objectives.

Possibly related PRs

Suggested reviewers

  • ThankUYou
  • wen-placeholder

Poem

🐰 I hopped through lists from far and near,

fused ranks until the best appeared.
Anchors nudged, the scores did climb,
years trimmed pages, tidy and prime.
A rabbit cheers — research refined! 🥕

🚥 Pre-merge checks | ✅ 5 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title clearly summarizes the main change: RRF fusion, personalized ranking, and date filters are the three core features added in this PR.
Linked Issues check ✅ Passed The PR successfully implements RRF fusion with deduplication by title_hash, accumulates contributing sources, surfaces retrieval_score and retrieval_sources metadata, and includes comprehensive unit tests (test_paper_search_service_rrf.py).
Out of Scope Changes check ✅ Passed All changes directly support the RRF fusion, personalized ranking, and date filtering objectives. Configuration updates, domain model extensions, and UI enhancements are all in scope.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into master

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/rrf-personalized-ranking-v1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello @jerry609, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the research search capabilities by introducing a sophisticated multi-source Reciprocal Rank Fusion (RRF) pipeline. It allows for personalized ranking based on user preferences and followed authors, dynamically adjusting source weights from feedback, and provides granular control over search results with new date range filters. These changes aim to deliver more relevant and tailored research content to users.

Highlights

  • Reciprocal Rank Fusion (RRF): Implemented RRF to combine search results from multiple sources, replacing simple deduplication. This includes weighted scoring, merging duplicates by title hash, and surfacing retrieval metadata like RRF score and contributing sources.
  • Personalized Ranking: Introduced a personalized mode that boosts papers from 'anchor authors' (followed authors) and learns per-source RRF weights based on user feedback (save/like/dislike actions). This feature can be toggled in the UI.
  • Date Range Filters: Added functionality to filter search results by publication year, allowing users to specify 'year_from' and 'year_to' parameters. This filtering is applied to both online search and local database fallbacks.
  • API and UI Integration: Extended the research API to accept new parameters for personalization and date filters, and updated the frontend UI components (SearchBox, ResearchPageNew) to expose these new controls and display relevant metadata.
  • Unit Tests: Added comprehensive unit tests for the new RRF fusion logic, personalized ranking, feedback-driven source weight learning, and date range filtering to ensure correctness and stability.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • src/paperbot/api/routes/research.py
    • Added 'personalized', 'year_from', and 'year_to' fields to the ContextRequest model.
    • Implemented validation to ensure 'year_from' is not greater than 'year_to'.
    • Passed new personalization and year filter parameters to the context engine.
  • src/paperbot/application/services/anchor_service.py
    • Added 'get_followed_paper_anchor_scores' method to retrieve personalized anchor scores for papers associated with followed authors.
  • src/paperbot/application/services/paper_search_service.py
    • Introduced constants for default RRF K value and source weights.
    • Modified the 'search' method to accept 'source_weights' and 'rrf_k' parameters.
    • Replaced simple deduplication with a new RRF fusion mechanism, including duplicate merging and provenance tracking.
    • Added static helper methods '_paper_key' for consistent hashing and '_paper_quality' for heuristic-based duplicate resolution.
    • Implemented the '_fuse_with_rrf' method to perform Reciprocal Rank Fusion.
  • src/paperbot/context_engine/engine.py
    • Integrated 'AnchorService' for personalized author boosts.
    • Added '_learn_source_weights_from_feedback' function to dynamically adjust RRF source weights based on user feedback.
    • Extended 'ContextEngineConfig' with 'personalized', 'year_from', and 'year_to' options.
    • Conditionalized existing paper boosts (saved/liked) based on the 'personalized' configuration.
    • Applied year filtering to local database fallback results.
    • Incorporated anchor boosts and learned source weights into the search process when personalized mode is active.
    • Included new personalization and filter metadata in the generated context pack.
  • src/paperbot/domain/paper.py
    • Added 'retrieval_score' and 'retrieval_sources' fields to the 'PaperCandidate' dataclass.
    • Updated the 'to_dict' method to serialize the new retrieval metadata.
  • tests/unit/test_context_engine_date_filters.py
    • Added unit tests to verify that date range filters are correctly passed to the search service.
  • tests/unit/test_context_engine_personalized_mode.py
    • Added unit tests to confirm that personalized mode correctly applies saved and anchor boosts, and that global mode disables them.
  • tests/unit/test_context_engine_source_weights.py
    • Added unit tests for the '_learn_source_weights_from_feedback' function, covering scenarios with insufficient samples and biased feedback.
  • tests/unit/test_paper_search_service_rrf.py
    • Added unit tests to validate RRF fusion behavior, including cross-source consensus and duplicate merging with source tracking.
  • web/src/components/research/PaperCard.tsx
    • Updated the 'Paper' type definition to include 'retrieval_sources', 'retrieval_score', and 'source'.
  • web/src/components/research/ResearchPageNew.tsx
    • Introduced state variables for 'yearFrom' and 'yearTo' to manage date filter inputs.
    • Modified the 'handleSearch' function to parse and send year filter and personalization parameters to the API.
    • Updated the 'useEffect' dependency array to trigger search refresh when personalization mode or year filters change.
    • Enhanced 'handleFeedback' to include retrieval metadata and anchor mode in user feedback.
    • Passed year filter states and their setters to the 'SearchBox' component.
    • Modified feedback callbacks ('onLike', 'onSave', 'onDislike') to pass the full paper object.
  • web/src/components/research/SearchBox.tsx
    • Imported the 'Input' component for numerical inputs.
    • Added props for 'yearFrom', 'yearTo', and their change handlers.
    • Rendered compact number input fields for 'From' and 'To' year filters in the search toolbar.
  • web/src/components/research/SearchResults.tsx
    • Adjusted the prop types for 'onLike', 'onSave', and 'onDislike' to accept the full 'Paper' object.
    • Updated the calls to feedback handlers to pass the complete paper data.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces significant enhancements to the research search functionality, including Reciprocal Rank Fusion (RRF) for merging results from multiple sources, personalized ranking based on user feedback and followed authors, and date range filters. While the implementation is generally well-executed across the backend and frontend, with robust RRF logic in PaperSearchService and an updated ContextEngine incorporating personalization and date filters, and comprehensive unit tests, a critical Insecure Direct Object Reference (IDOR) vulnerability has been identified. This vulnerability exists in the new personalized search and anchor score features because the user_id is provided in the request body without proper validation against the authenticated user's session. It is strongly recommended to source the user_id from the authentication context instead. Additionally, there is one minor suggestion for refactoring to improve code clarity.

UserAnchorActionModel,
and_(
UserAnchorActionModel.author_id == PaperAuthorModel.author_id,
UserAnchorActionModel.user_id == str(user_id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The new get_followed_paper_anchor_scores method uses the user_id parameter directly in a database query to filter UserAnchorActionModel records. Without verifying that the user_id belongs to the requester at the API entry point, an attacker can access personalized anchor scores for any user. This is an Insecure Direct Object Reference (IDOR) vulnerability.

Remediation: Ensure that the user_id passed to this service method has been validated against the authenticated user's session at the API level.

Comment on lines +712 to +716
feedback_rows = self.research_store.list_paper_feedback(
user_id=user_id,
track_id=int(routed_track["id"]),
limit=500,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The new personalization logic in build_context_pack uses the user_id provided in the request to fetch paper feedback from the database. Since there is no validation that the user_id matches the authenticated user, an attacker can provide any user's ID to retrieve their feedback data and influence search results. This is an Insecure Direct Object Reference (IDOR) vulnerability.

Remediation: The user_id should be obtained from the authenticated session or token rather than being accepted as a parameter in the request body.

Comment on lines +181 to +193
provenance: Dict[str, List[str]] = defaultdict(list)
source_contrib: Dict[str, Dict[str, float]] = defaultdict(dict)
best_by_key: Dict[str, PaperCandidate] = {}

for source, papers in results_by_source.items():
weight = float(source_weights.get(source, 0.5))
for rank, paper in enumerate(papers, start=1):
key = self._paper_key(paper)
contrib = weight / (rrf_k + rank)
scores[key] += contrib
source_contrib[key][source] = source_contrib[key].get(source, 0.0) + contrib
if source not in provenance[key]:
provenance[key].append(source)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For tracking unique sources in provenance, using a defaultdict(set) would be more idiomatic and efficient than a defaultdict(list) with a membership check. This simplifies adding sources and avoids the if source not in ... check.

Additionally, for deterministic output, I'd recommend sorting the sources when creating normalized_provenance on line 219:

# At line 219
normalized_provenance = {k: sorted(list(v)) for k, v in provenance.items()}
        provenance: Dict[str, set[str]] = defaultdict(set)
        source_contrib: Dict[str, Dict[str, float]] = defaultdict(dict)
        best_by_key: Dict[str, PaperCandidate] = {}

        for source, papers in results_by_source.items():
            weight = float(source_weights.get(source, 0.5))
            for rank, paper in enumerate(papers, start=1):
                key = self._paper_key(paper)
                contrib = weight / (rrf_k + rank)
                scores[key] += contrib
                source_contrib[key][source] = source_contrib[key].get(source, 0.0) + contrib
                provenance[key].add(source)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a comprehensive enhancement to the research search feature, introducing Reciprocal Rank Fusion (RRF) for multi-source search aggregation, personalized ranking with anchor author boosts, feedback-driven adaptive source weighting, and date range filtering. The implementation replaces simple deduplication with a weighted RRF scoring algorithm that values cross-source consensus, and introduces a personalized/global mode toggle that applies user-specific boosts only when enabled.

Changes:

  • Implemented RRF fusion algorithm to merge and rank papers from multiple sources based on weighted reciprocal rank scores
  • Added personalized/global mode toggle with anchor author boosts that leverage user follow actions
  • Introduced feedback-driven learning of per-source RRF weights using Beta-style smoothing to adapt to user preferences
  • Added year range filters (year_from/year_to) with validation in both API and UI layers

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
web/src/components/research/SearchResults.tsx Updated feedback callback signatures to pass full Paper object for metadata capture
web/src/components/research/SearchBox.tsx Added year range input controls with min/max constraints
web/src/components/research/ResearchPageNew.tsx Integrated year filters, personalization mode, and auto-refresh on filter changes
web/src/components/research/PaperCard.tsx Added optional retrieval_sources and retrieval_score fields to Paper type
tests/unit/test_paper_search_service_rrf.py Tests for RRF fusion logic including cross-source consensus and duplicate merging
tests/unit/test_context_engine_source_weights.py Tests for feedback-driven weight learning with minimum sample requirements
tests/unit/test_context_engine_personalized_mode.py Tests for personalized vs global mode behavior with anchor boosts
tests/unit/test_context_engine_date_filters.py Tests for year filter propagation to search service
src/paperbot/domain/paper.py Added retrieval_score and retrieval_sources fields to PaperCandidate dataclass
src/paperbot/context_engine/engine.py Implemented personalized mode, source weight learning, anchor boost application, and date filtering
src/paperbot/application/services/paper_search_service.py Implemented RRF fusion algorithm with duplicate merging and weighted scoring
src/paperbot/application/services/anchor_service.py Added get_followed_paper_anchor_scores method to retrieve personalized author scores
src/paperbot/api/routes/research.py Added personalized, year_from, and year_to parameters to context API with validation

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

else:
stats[source]["neg"] += abs(signal)

if sample_count < max(1, int(min_samples)):

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _learn_source_weights_from_feedback function returns None when sample_count is below min_samples, but it doesn't differentiate between "no feedback at all" and "some feedback but not enough". This makes it difficult to distinguish between a cold start scenario and one where the user has provided some feedback. Consider returning additional metadata (e.g., actual sample count) or logging the sample count for debugging purposes.

Suggested change
if sample_count < max(1, int(min_samples)):
if sample_count < max(1, int(min_samples)):
# Differentiate between true cold-start (no usable feedback) and
# scenarios where some feedback exists but is below the learning threshold.
try:
logger = Logger.get_logger(LogFiles.CONTEXT_ENGINE)
logger.debug(
"Insufficient feedback to learn source weights",
extra={
"sample_count": sample_count,
"min_samples": int(min_samples),
"has_feedback": sample_count > 0,
},
)
except Exception:
# Logging must not interfere with core behavior.
pass

Copilot uses AI. Check for mistakes.
Comment on lines +403 to +427
rows = session.execute(
select(
PaperAuthorModel.paper_id,
func.max(func.coalesce(UserAnchorScoreModel.personalized_anchor_score, 0.0)),
)
.join(
UserAnchorActionModel,
and_(
UserAnchorActionModel.author_id == PaperAuthorModel.author_id,
UserAnchorActionModel.user_id == str(user_id),
UserAnchorActionModel.track_id == int(track_id),
UserAnchorActionModel.action == "follow",
),
)
.outerjoin(
UserAnchorScoreModel,
and_(
UserAnchorScoreModel.user_id == UserAnchorActionModel.user_id,
UserAnchorScoreModel.track_id == UserAnchorActionModel.track_id,
UserAnchorScoreModel.author_id == UserAnchorActionModel.author_id,
),
)
.where(PaperAuthorModel.paper_id.in_(clean_ids))
.group_by(PaperAuthorModel.paper_id)
).all()

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SQL query uses an INNER JOIN with UserAnchorActionModel followed by an OUTER JOIN with UserAnchorScoreModel. This means papers will only be included in the result if they have at least one author with a "follow" action. However, if a paper has multiple authors and only some are followed, only the maximum score among followed authors is returned. This is correct behavior, but the query could potentially be expensive if clean_ids contains many papers. Consider adding an index on PaperAuthorModel.paper_id if one doesn't exist, and monitoring query performance when this is called with large paper_id lists.

Suggested change
rows = session.execute(
select(
PaperAuthorModel.paper_id,
func.max(func.coalesce(UserAnchorScoreModel.personalized_anchor_score, 0.0)),
)
.join(
UserAnchorActionModel,
and_(
UserAnchorActionModel.author_id == PaperAuthorModel.author_id,
UserAnchorActionModel.user_id == str(user_id),
UserAnchorActionModel.track_id == int(track_id),
UserAnchorActionModel.action == "follow",
),
)
.outerjoin(
UserAnchorScoreModel,
and_(
UserAnchorScoreModel.user_id == UserAnchorActionModel.user_id,
UserAnchorScoreModel.track_id == UserAnchorActionModel.track_id,
UserAnchorScoreModel.author_id == UserAnchorActionModel.author_id,
),
)
.where(PaperAuthorModel.paper_id.in_(clean_ids))
.group_by(PaperAuthorModel.paper_id)
).all()
# Batch queries to avoid very large IN clauses and improve performance
rows: list[tuple[int, float]] = []
chunk_size = 1000
for i in range(0, len(clean_ids), chunk_size):
id_chunk = clean_ids[i : i + chunk_size]
chunk_rows = session.execute(
select(
PaperAuthorModel.paper_id,
func.max(
func.coalesce(
UserAnchorScoreModel.personalized_anchor_score,
0.0,
)
),
)
.join(
UserAnchorActionModel,
and_(
UserAnchorActionModel.author_id
== PaperAuthorModel.author_id,
UserAnchorActionModel.user_id == str(user_id),
UserAnchorActionModel.track_id == int(track_id),
UserAnchorActionModel.action == "follow",
),
)
.outerjoin(
UserAnchorScoreModel,
and_(
UserAnchorScoreModel.user_id
== UserAnchorActionModel.user_id,
UserAnchorScoreModel.track_id
== UserAnchorActionModel.track_id,
UserAnchorScoreModel.author_id
== UserAnchorActionModel.author_id,
),
)
.where(PaperAuthorModel.paper_id.in_(id_chunk))
.group_by(PaperAuthorModel.paper_id)
).all()
rows.extend(chunk_rows)

Copilot uses AI. Check for mistakes.
Comment on lines +1387 to +1389
if req.year_from is not None and req.year_to is not None and req.year_from > req.year_to:
raise HTTPException(status_code=400, detail="year_from cannot be greater than year_to")

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API validates that year_from is not greater than year_to when both are provided, but it doesn't validate the case where only one of them is provided with an invalid value. For example, if year_from=2200 and year_to=None, the invalid year_from will be silently accepted even though it's beyond the max constraint of 2100 defined in the Pydantic Field. This is actually already validated by Pydantic Field constraints on lines 1313-1314, so this validation is redundant. However, the error message from Pydantic will be generic. Consider removing the manual validation on lines 1387-1388 or making it comprehensive to also check the individual range constraints for more user-friendly error messages.

Suggested change
if req.year_from is not None and req.year_to is not None and req.year_from > req.year_to:
raise HTTPException(status_code=400, detail="year_from cannot be greater than year_to")

Copilot uses AI. Check for mistakes.
Comment on lines +1387 to +1388
if req.year_from is not None and req.year_to is not None and req.year_from > req.year_to:
raise HTTPException(status_code=400, detail="year_from cannot be greater than year_to")

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no tests verifying the year_from > year_to validation added on lines 1387-1388. Consider adding a test case to verify that the API returns a 400 error with an appropriate message when year_from is greater than year_to.

Copilot uses AI. Check for mistakes.
assert result is not None
assert result["arxiv"] > result["semantic_scholar"]
assert 0.3 <= result["semantic_scholar"] <= 1.8
assert 0.3 <= result["arxiv"] <= 1.8

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test verifies that weights are clamped to the range [0.3, 1.8], but it doesn't test the boundary conditions where weights would exceed these limits before clamping. Consider adding a test case with extreme positive or negative feedback to verify the clamping behavior works correctly at the boundaries.

Suggested change
assert 0.3 <= result["arxiv"] <= 1.8
assert 0.3 <= result["arxiv"] <= 1.8
def test_learn_source_weights_clamps_to_boundaries() -> None:
rows: list[dict] = []
# Strongly positive feedback for "arxiv" to push its weight upward.
for _ in range(20):
rows.append({"action": "like", "metadata": {"retrieval_sources": ["arxiv"]}})
rows.append({"action": "save", "metadata": {"retrieval_sources": ["arxiv"]}})
# Strongly negative feedback for "semantic_scholar" to push its weight downward.
for _ in range(20):
rows.append(
{"action": "dislike", "metadata": {"retrieval_sources": ["semantic_scholar"]}}
)
rows.append(
{
"action": "not_relevant",
"metadata": {"retrieval_sources": ["semantic_scholar"]},
}
)
result = _learn_source_weights_from_feedback(
feedback_rows=rows,
selected_sources=["semantic_scholar", "arxiv"],
default_weights={"semantic_scholar": 1.0, "arxiv": 1.0},
min_samples=10,
)
assert result is not None
# With extreme feedback, learned weights should be clamped to the boundaries.
assert result["arxiv"] == 1.8
assert result["semantic_scholar"] == 0.3

Copilot uses AI. Check for mistakes.
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchSources])
}, [searchSources, anchorPersonalized, yearFrom, yearTo])

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auto-refresh effect now triggers on yearFrom and yearTo changes, which will fire a new search on every keystroke as the user types a year. This could lead to excessive API calls. Consider debouncing the year input changes or only triggering the search when the input loses focus.

Suggested change
}, [searchSources, anchorPersonalized, yearFrom, yearTo])
}, [searchSources, anchorPersonalized])

Copilot uses AI. Check for mistakes.
Comment on lines +205 to +207
paper.title_hash = key
paper.retrieval_score = score
paper.retrieval_sources = [name for name, _ in ranked_sources]

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _fuse_with_rrf method mutates the input PaperCandidate objects by setting title_hash, retrieval_score, and retrieval_sources (lines 205-207). This side effect is not obvious from the function signature and could lead to unexpected behavior if the same PaperCandidate objects are used elsewhere. Consider either documenting this mutation clearly, or creating new PaperCandidate objects with the updated fields instead of mutating the originals.

Copilot uses AI. Check for mistakes.
merged = result.papers[0]
assert merged.title == "Duplicate Title"
assert set(merged.retrieval_sources) == {"semantic_scholar", "arxiv"}
assert merged.retrieval_score > 0

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test verifies that duplicates are merged and sources are tracked, but it doesn't verify that the RRF score is correctly computed by summing contributions from both sources. Consider asserting that merged.retrieval_score is approximately equal to the expected RRF formula: weight_s2/(k+1) + weight_arxiv/(k+1), where k is the RRF_K constant.

Copilot uses AI. Check for mistakes.
Comment on lines +125 to +127
min={1900}
max={2100}
disabled={disabled || isSearching}

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The min and max attributes on HTML number inputs provide client-side validation only and do not prevent users from typing or pasting values outside this range. The validation in ResearchPageNew.tsx (lines 165-174) will handle out-of-range values by returning undefined, but this creates an inconsistency where the UI shows a value but the backend receives undefined. Consider adding onChange validation to prevent invalid values from being set, or add visual feedback when the value is out of range.

Copilot uses AI. Check for mistakes.
pack = await engine.build_context_pack(user_id="u1", query="transformer", track_id=1)
score = float(pack["paper_recommendation_scores"]["1"])

assert score > 0.44 # includes +0.25 saved boost and +0.20 anchor boost

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test assertion on line 92 uses a hard-coded threshold of 0.44 which assumes specific boost values (+0.25 saved, +0.20 anchor). However, looking at the implementation in engine.py lines 832-835, the anchor boost is actually min(0.35, 0.20 * anchor_score), which with anchor_score=1.0 gives 0.20. The base score calculation is not visible in this test, making the threshold value fragile. If the base scoring algorithm changes, this test could fail even though the personalized boost logic is working correctly. Consider testing the relative difference between personalized and global modes instead of absolute thresholds.

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/paperbot/context_engine/engine.py (1)

219-230: ⚠️ Potential issue | 🟡 Minor

Auto-refresh on yearFrom/yearTo change can fire excessive requests.

The 300ms debounce helps, but each keystroke in the year inputs changes yearFrom or yearTo, triggering this effect. Typing "2024" produces 4 intermediate searches ("2", "20", "202", "2024"). Consider either debouncing with a longer delay for year inputs, or only triggering the search on blur/Enter for the year fields.

🤖 Fix all issues with AI agents
In `@src/paperbot/application/services/paper_search_service.py`:
- Line 178: The expression "rrf_k or self.DEFAULT_RRF_K" treats 0.0 as falsy and
will incorrectly substitute the default; change the assignment so you only use
the default when rrf_k is None. Replace the current line with logic that checks
"if rrf_k is None: value = float(self.DEFAULT_RRF_K) else: value = float(rrf_k)"
and then apply "rrf_k = max(1.0, value)". Keep references to rrf_k and
DEFAULT_RRF_K and perform the float conversion after the None check.

In `@tests/unit/test_context_engine_personalized_mode.py`:
- Around line 11-35: The _FakeResearchStore used in tests is missing the
list_paper_feedback method so the personalized path that calls
research_store.list_paper_feedback(...) is skipped; add a method named
list_paper_feedback(self, *, user_id: str, track_id: int, limit: int) that
returns an empty list to mirror other tests and ensure the personalized learning
code path is executed (modify the _FakeResearchStore class where
track/get_active_track/list_* methods are defined).

In `@web/src/components/research/SearchBox.tsx`:
- Around line 119-138: The number inputs for year range in SearchBox.tsx (the
two Input components bound to yearFrom/onYearFromChange and
yearTo/onYearToChange) lack accessible labels; add aria-label attributes (e.g.,
aria-label="Year from" on the Input using yearFrom and aria-label="Year to" on
the Input using yearTo) to each Input so screen readers can announce their
purpose while preserving existing props (type, value, onChange, placeholder,
min, max, disabled).
🧹 Nitpick comments (8)
tests/unit/test_context_engine_date_filters.py (1)

71-94: Consider adding a negative test for year filter validation.

The route layer validates year_from > year_to with HTTP 400, but there's no test for the engine's behavior when year_from/year_to are None. A test confirming that omitting year filters still works (i.e., the keys are absent or None in the captured call) would strengthen coverage.

tests/unit/test_context_engine_personalized_mode.py (1)

76-92: Threshold assertion is coupled to internal scoring constants.

The score > 0.44 assertion depends on the exact boost values (+0.25 saved, +0.20 anchor) and the base paper score. If any of these internal constants change, this test will silently become incorrect or start failing. Consider adding a brief comment documenting the expected score breakdown or comparing personalized vs. global scores directly.

src/paperbot/application/services/paper_search_service.py (3)

41-47: Annotate mutable class attribute with ClassVar.

DEFAULT_SOURCE_WEIGHTS is a mutable dict on the class. Ruff RUF012 flags this correctly — without ClassVar, type checkers and dataclass-adjacent tooling may misinterpret it as an instance field.

🔧 Proposed fix
+from typing import ClassVar
 ...
-    DEFAULT_SOURCE_WEIGHTS: Dict[str, float] = {
+    DEFAULT_SOURCE_WEIGHTS: ClassVar[Dict[str, float]] = {
         "semantic_scholar": 1.0,
         "openalex": 0.9,
         "arxiv": 0.8,
         "papers_cool": 0.7,
         "hf_daily": 0.6,
     }

195-197: _paper_quality(best) is recomputed on every collision.

Each time a duplicate key is encountered, quality is recalculated for both the candidate and the current best. For typical result sizes this is acceptable, but caching the winning quality per key would make the intent clearer and avoid redundant work.

♻️ Optional improvement
         best_by_key: Dict[str, PaperCandidate] = {}
+        quality_by_key: Dict[str, Tuple[int, int, int, int]] = {}
 
         for source, papers in results_by_source.items():
             weight = float(source_weights.get(source, 0.5))
             for rank, paper in enumerate(papers, start=1):
                 key = self._paper_key(paper)
                 ...
                 best = best_by_key.get(key)
-                if best is None or self._paper_quality(paper) > self._paper_quality(best):
+                q = self._paper_quality(paper)
+                if best is None or q > quality_by_key[key]:
                     best_by_key[key] = paper
+                    quality_by_key[key] = q

148-153: Silent exception swallowing in close().

Ruff S110 flags try-except-pass. Consider logging at debug level so failures during cleanup aren't completely invisible.

🔧 Proposed fix
     async def close(self) -> None:
         for adapter in self._adapters.values():
             try:
                 await adapter.close()
-            except Exception:
-                pass
+            except Exception:
+                logger.debug("Error closing adapter %s", getattr(adapter, "source_name", "?"), exc_info=True)
src/paperbot/context_engine/engine.py (1)

31-40: Module-level singleton bypasses dependency injection.

_get_anchor_service() creates an AnchorService() with no arguments. The rest of the engine uses constructor injection (research_store, memory_store, etc.). This singleton won't receive the same store instances, which could lead to separate DB sessions or misconfiguration. Consider injecting the anchor service through ContextEngine.__init__ like the other dependencies.

web/src/components/research/ResearchPageNew.tsx (2)

165-174: parseYear is recreated on every handleSearch call.

This is a pure utility with no closure over component state. Hoisting it outside the component (or at least outside handleSearch) avoids the minor allocation churn and makes it reusable.


219-230: Auto-refresh fires for every year-input change — verify debounce is sufficient.

yearFrom and yearTo are in the dependency array, so each keystroke triggers the 300ms debounce timer. This is standard debounce behavior (the timer resets per keystroke), so in practice only the final value fires a search. Acceptable as-is, but worth verifying that the UX feels responsive since even valid partial values (e.g., "20") will trigger a full search if the user pauses.

Consider extending the debounce for year inputs specifically (e.g., 600–800ms), or only triggering the search on Enter/blur for those fields, to avoid intermediate searches on partial years.

source_weights: Dict[str, float],
rrf_k: float,
) -> Tuple[List[Tuple[float, PaperCandidate]], Dict[str, List[str]]]:
rrf_k = max(1.0, float(rrf_k or self.DEFAULT_RRF_K))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

rrf_k or self.DEFAULT_RRF_K mishandles falsy 0.0.

If rrf_k is explicitly passed as 0.0 (falsy in Python), the or expression silently substitutes the default. The max(1.0, ...) clamp would catch it in this case, but the intent is muddled. Use if rrf_k is None instead for clarity.

🔧 Proposed fix
-        rrf_k = max(1.0, float(rrf_k or self.DEFAULT_RRF_K))
+        rrf_k = max(1.0, float(rrf_k if rrf_k is not None else self.DEFAULT_RRF_K))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
rrf_k = max(1.0, float(rrf_k or self.DEFAULT_RRF_K))
rrf_k = max(1.0, float(rrf_k if rrf_k is not None else self.DEFAULT_RRF_K))
🤖 Prompt for AI Agents
In `@src/paperbot/application/services/paper_search_service.py` at line 178, The
expression "rrf_k or self.DEFAULT_RRF_K" treats 0.0 as falsy and will
incorrectly substitute the default; change the assignment so you only use the
default when rrf_k is None. Replace the current line with logic that checks "if
rrf_k is None: value = float(self.DEFAULT_RRF_K) else: value = float(rrf_k)" and
then apply "rrf_k = max(1.0, value)". Keep references to rrf_k and DEFAULT_RRF_K
and perform the float conversion after the None check.

Comment on lines +11 to +35
class _FakeResearchStore:
def __init__(self) -> None:
self.track = {"id": 1, "name": "NLP", "keywords": ["transformer"]}

def get_active_track(self, *, user_id: str):
return self.track

def get_track(self, *, user_id: str, track_id: int):
if int(track_id) == 1:
return self.track
return None

def list_tasks(self, *, user_id: str, track_id: int, limit: int):
return []

def list_milestones(self, *, user_id: str, track_id: int, limit: int):
return []

def list_paper_feedback_ids(self, *, user_id: str, track_id: int, action: str):
if action == "save":
return {"1"}
return set()

def create_context_run(self, **kwargs):
return {"id": 1}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n 'list_paper_feedback' --type=py -C3 -g '!tests/**'

Repository: jerry609/PaperBot

Length of output: 5744


🏁 Script executed:

sed -n '710,730p' src/paperbot/context_engine/engine.py

Repository: jerry609/PaperBot

Length of output: 1215


🏁 Script executed:

rg -A 30 'class _FakeResearchStore' tests/

Repository: jerry609/PaperBot

Length of output: 4848


🏁 Script executed:

rg -B 5 -A 15 'def test.*personalized' tests/unit/test_context_engine_personalized_mode.py

Repository: jerry609/PaperBot

Length of output: 817


Add list_paper_feedback method to _FakeResearchStore.

The engine's personalized path calls research_store.list_paper_feedback() at line 712 to compute learned source weights. Although the engine wraps this call in a try-except block that suppresses failures, the missing method means the personalized learning functionality is silently skipped during the test. Add the method (returning an empty list) to ensure the code path is properly exercised:

Suggested implementation
def list_paper_feedback(self, *, user_id: str, track_id: int, limit: int):
    return []

This matches the implementation in tests/unit/test_context_engine_date_filters.py.

🧰 Tools
🪛 Ruff (0.15.0)

[warning] 15-15: Unused method argument: user_id

(ARG002)


[warning] 18-18: Unused method argument: user_id

(ARG002)


[warning] 23-23: Unused method argument: user_id

(ARG002)


[warning] 23-23: Unused method argument: track_id

(ARG002)


[warning] 23-23: Unused method argument: limit

(ARG002)


[warning] 26-26: Unused method argument: user_id

(ARG002)


[warning] 26-26: Unused method argument: track_id

(ARG002)


[warning] 26-26: Unused method argument: limit

(ARG002)


[warning] 29-29: Unused method argument: user_id

(ARG002)


[warning] 29-29: Unused method argument: track_id

(ARG002)


[warning] 34-34: Unused method argument: kwargs

(ARG002)

🤖 Prompt for AI Agents
In `@tests/unit/test_context_engine_personalized_mode.py` around lines 11 - 35,
The _FakeResearchStore used in tests is missing the list_paper_feedback method
so the personalized path that calls research_store.list_paper_feedback(...) is
skipped; add a method named list_paper_feedback(self, *, user_id: str, track_id:
int, limit: int) that returns an empty list to mirror other tests and ensure the
personalized learning code path is executed (modify the _FakeResearchStore class
where track/get_active_track/list_* methods are defined).

Comment thread web/src/components/research/SearchBox.tsx Outdated
- Add post-search year range filtering in the dedup/filter loop so
  papers outside year_from..year_to are excluded regardless of adapter
- Default venue to empty string instead of None to satisfy NOT NULL
  constraint from migration 0003

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/paperbot/context_engine/engine.py`:
- Around line 808-814: The year-range checks currently only apply when year_val
is an int, letting strings like "2023" bypass the filter; change both places
where year_val = p.get("year") (and the local-fallback block around lines
782-787) to coerce year_val to an integer safely before comparing: attempt to
convert with int(year_val) inside a try/except catching ValueError and
TypeError, assign to a new variable (e.g., year_int), and if conversion fails
treat it as missing/skip the range checks (or continue), then compare year_int
against self.config.year_from and self.config.year_to as before; ensure you do
not alter the original p dict and keep behavior consistent when config bounds
are None.

In `@src/paperbot/infrastructure/stores/paper_store.py`:
- Line 244: The Venue default needs to be applied in _create_model to avoid
passing None to the DB: update the _create_model function (where it constructs
the model from a HarvestedPaper) to guard the venue field (HarvestedPaper.venue)
and pass an empty string when it's None (e.g. use paper.venue or ""), so
upsert_papers_batch never receives None for venue and the NOT NULL DB constraint
is respected.
🧹 Nitpick comments (2)
src/paperbot/context_engine/engine.py (2)

31-40: Duplicate _get_anchor_service singleton — also lives in research.py.

Per the relevant snippets, src/paperbot/api/routes/research.py (lines 138-145) contains an identical lazy-init pattern for AnchorService. Having two independent module-level singletons means two instances can coexist, and any future change (e.g. constructor args, lifecycle management) must be replicated in both places.

Consider extracting a shared accessor (e.g. in anchor_service.py itself or a small provider module) and importing it from both call sites.

#!/bin/bash
# Verify duplication of _get_anchor_service across the codebase
rg -n '_get_anchor_service|_anchor_service' --type=py -C2

826-830: Minor inconsistency: or 0 in the value expression vs or "" in the filter.

The filter uses str(p.get("paper_id") or "").isdigit() while the value uses str(p.get("paper_id") or 0). This works correctly because the filter prevents non-digit values from reaching int(...), but the mixed default (0 vs "") is a readability stumble. Consider using or "" in both places for consistency.

Suggested tweak
 numeric_ids = [
-    int(str(p.get("paper_id") or 0))
+    int(str(p.get("paper_id")))
     for p in filtered
     if str(p.get("paper_id") or "").isdigit()
 ]

Comment on lines +808 to +814
# Year range filter (post-search safety net)
year_val = p.get("year")
if isinstance(year_val, int):
if self.config.year_from is not None and year_val < self.config.year_from:
continue
if self.config.year_to is not None and year_val > self.config.year_to:
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Year filter silently skips papers whose year is a non-int type (e.g. str, None).

If any upstream source yields year as a string (e.g. "2023"), the isinstance(year_val, int) guard will let it through unfiltered. The same pattern appears in the local-fallback block (lines 782-787). Consider coercing to int with a safe fallback before comparing:

Suggested approach
-                    year_val = p.get("year")
-                    if isinstance(year_val, int):
-                        if self.config.year_from is not None and year_val < self.config.year_from:
-                            continue
-                        if self.config.year_to is not None and year_val > self.config.year_to:
-                            continue
+                    year_raw = p.get("year")
+                    try:
+                        year_val = int(year_raw) if year_raw is not None else None
+                    except (ValueError, TypeError):
+                        year_val = None
+                    if year_val is not None:
+                        if self.config.year_from is not None and year_val < self.config.year_from:
+                            continue
+                        if self.config.year_to is not None and year_val > self.config.year_to:
+                            continue
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Year range filter (post-search safety net)
year_val = p.get("year")
if isinstance(year_val, int):
if self.config.year_from is not None and year_val < self.config.year_from:
continue
if self.config.year_to is not None and year_val > self.config.year_to:
continue
# Year range filter (post-search safety net)
year_raw = p.get("year")
try:
year_val = int(year_raw) if year_raw is not None else None
except (ValueError, TypeError):
year_val = None
if year_val is not None:
if self.config.year_from is not None and year_val < self.config.year_from:
continue
if self.config.year_to is not None and year_val > self.config.year_to:
continue
🤖 Prompt for AI Agents
In `@src/paperbot/context_engine/engine.py` around lines 808 - 814, The year-range
checks currently only apply when year_val is an int, letting strings like "2023"
bypass the filter; change both places where year_val = p.get("year") (and the
local-fallback block around lines 782-787) to coerce year_val to an integer
safely before comparing: attempt to convert with int(year_val) inside a
try/except catching ValueError and TypeError, assign to a new variable (e.g.,
year_int), and if conversion fails treat it as missing/skip the range checks (or
continue), then compare year_int against self.config.year_from and
self.config.year_to as before; ensure you do not alter the original p dict and
keep behavior consistent when config bounds are None.

row.url = url or row.url or None
row.pdf_url = pdf_url or row.pdf_url or None
row.venue = venue or row.venue or None
row.venue = venue or row.venue or ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's view the context around line 244 in paper_store.py
wc -l src/paperbot/infrastructure/stores/paper_store.py

Repository: jerry609/PaperBot

Length of output: 115


🏁 Script executed:

# View around line 244
sed -n '240,250p' src/paperbot/infrastructure/stores/paper_store.py

Repository: jerry609/PaperBot

Length of output: 639


🏁 Script executed:

# View around line 567
sed -n '560,575p' src/paperbot/infrastructure/stores/paper_store.py

Repository: jerry609/PaperBot

Length of output: 865


🏁 Script executed:

# Search for HarvestedPaper definition
rg -n 'class HarvestedPaper' --type=py

Repository: jerry609/PaperBot

Length of output: 116


🏁 Script executed:

# Check for upsert_papers_batch and _create_model relationship
rg -n 'def upsert_papers_batch\|def _create_model' src/paperbot/infrastructure/stores/paper_store.py

Repository: jerry609/PaperBot

Length of output: 43


🏁 Script executed:

# View HarvestedPaper class definition
sed -n '31,80p' src/paperbot/domain/harvest.py

Repository: jerry609/PaperBot

Length of output: 1865


🏁 Script executed:

# Search for _create_model function (it might be a method)
rg -n '_create_model' src/paperbot/infrastructure/stores/paper_store.py -B2 -A8

Repository: jerry609/PaperBot

Length of output: 1052


🏁 Script executed:

# Search for upsert_papers_batch
rg -n 'upsert_papers_batch' src/paperbot/infrastructure/stores/paper_store.py -B2 -A15

Repository: jerry609/PaperBot

Length of output: 599


🏁 Script executed:

# Find PaperModel definition to check venue field constraint
rg -n 'class PaperModel' --type=py

Repository: jerry609/PaperBot

Length of output: 133


🏁 Script executed:

# Check the venue field in PaperModel
rg -n 'venue' src/paperbot/domain/models.py -B2 -A2

Repository: jerry609/PaperBot

Length of output: 130


🏁 Script executed:

# Check if there are database migrations mentioning venue NOT NULL
rg -n 'venue' --type=py -i 'null|constraint' -g '*migration*'

Repository: jerry609/PaperBot

Length of output: 116


🏁 Script executed:

# View PaperModel definition and venue field
sed -n '814,880p' src/paperbot/infrastructure/stores/models.py

Repository: jerry609/PaperBot

Length of output: 3164


🏁 Script executed:

# Search for migration files mentioning venue and NOT NULL
find . -name '*migration*' -o -name '*alembic*' | head -20

Repository: jerry609/PaperBot

Length of output: 84


🏁 Script executed:

# Check migrations directory
ls -la alembic/versions/ 2>/dev/null || ls -la migrations/ 2>/dev/null || find . -type d -name '*migrations*' -o -type d -name '*alembic*' | head -5

Repository: jerry609/PaperBot

Length of output: 1744


🏁 Script executed:

# Check migration 0003 for venue NOT NULL constraint
cat alembic/versions/0003_paper_registry.py

Repository: jerry609/PaperBot

Length of output: 3864


🏁 Script executed:

# Search all migrations after 0003 for ALTER operations on venue column
rg -n 'venue' alembic/versions/ --type=py | grep -i 'alter\|nullable'

Repository: jerry609/PaperBot

Length of output: 579


🏁 Script executed:

# Check other migrations that mention "papers" or "venue"
for file in alembic/versions/000[4-9]*.py alembic/versions/00[1-2][0-9]*.py; do
  if rg -l 'venue|papers' "$file" 2>/dev/null; then
    echo "=== $file ==="
    rg -n 'venue' "$file" -B2 -A2
  fi
done

Repository: jerry609/PaperBot

Length of output: 2718


🏁 Script executed:

# Check migration 0007 more thoroughly to understand schema reconciliation
sed -n '1,150p' alembic/versions/0007_paper_harvest_tables.py

Repository: jerry609/PaperBot

Length of output: 6050


🏁 Script executed:

# Check if _create_model creates entries in harvest tables or main papers table
sed -n '555,600p' src/paperbot/infrastructure/stores/paper_store.py

Repository: jerry609/PaperBot

Length of output: 2130


🏁 Script executed:

# Check the upsert_paper function to see which table it updates
sed -n '220,280p' src/paperbot/infrastructure/stores/paper_store.py

Repository: jerry609/PaperBot

Length of output: 2445


🏁 Script executed:

# Verify the actual current schema by checking what columns exist
# Check if there's any ALTER TABLE that changes venue constraint post-0007
rg -n 'ALTER TABLE.*venue\|DROP.*venue' alembic/versions/ --type=py

Repository: jerry609/PaperBot

Length of output: 43


🏁 Script executed:

# Double-check 0012_reconcile_papers_schema to see if it alters venue constraint
sed -n '1,200p' alembic/versions/0012_reconcile_papers_schema.py | grep -A20 -B5 'venue\|nullable'

Repository: jerry609/PaperBot

Length of output: 3227


Venue default needs consistency check: _create_model also requires empty-string fallback.

The fix at line 244 correctly defaults venue to "" to satisfy the NOT NULL constraint. However, _create_model (line 567) passes venue=paper.venue directly without the same guard. Since HarvestedPaper.venue is Optional[str], this can pass None to the database during upsert_papers_batch, violating the NOT NULL constraint.

Suggested fix
-            venue=paper.venue,
+            venue=paper.venue or "",
🤖 Prompt for AI Agents
In `@src/paperbot/infrastructure/stores/paper_store.py` at line 244, The Venue
default needs to be applied in _create_model to avoid passing None to the DB:
update the _create_model function (where it constructs the model from a
HarvestedPaper) to guard the venue field (HarvestedPaper.venue) and pass an
empty string when it's None (e.g. use paper.venue or ""), so upsert_papers_batch
never receives None for venue and the NOT NULL DB constraint is respected.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/paperbot/application/services/paper_search_service.py`:
- Around line 148-165: The RegistryPort interface is missing the sync_authors
parameter so callers (PaperSearchService.upsert flow) must catch a TypeError;
update the RegistryPort.upsert_paper signature to accept sync_authors: bool =
True (matching PaperStore.upsert_paper implementation) and propagate that
parameter through the port contract so PaperSearchService can call
self._registry.upsert_paper(..., sync_authors=...) without needing the TypeError
fallback; ensure the method docstring and any abstract/base definitions for
RegistryPort reference the sync_authors flag.
🧹 Nitpick comments (4)
src/paperbot/application/services/paper_search_service.py (3)

41-47: Annotate mutable class attribute with ClassVar.

DEFAULT_SOURCE_WEIGHTS is a mutable dict on the class. Without ClassVar, dataclass-style tooling and linters (Ruff RUF012) flag it, and—more importantly—any instance that mutates self.DEFAULT_SOURCE_WEIGHTS would silently affect all instances sharing the reference.

Proposed fix
+from typing import ClassVar
 ...
-    DEFAULT_SOURCE_WEIGHTS: Dict[str, float] = {
+    DEFAULT_SOURCE_WEIGHTS: ClassVar[Dict[str, float]] = {

78-101: return_exceptions=True is redundant with the internal catch-all.

_guarded_search already catches every exception and returns it as a value, so asyncio.gather(..., return_exceptions=True) will never trigger. Remove return_exceptions=True to avoid misleading readers into thinking exceptions can still propagate.

Also, on line 96, note that the function returns a bare TimeoutError object (not re-raising). This works because line 107 checks isinstance(result, BaseException), but it's worth a brief inline comment for clarity.


182-187: Silent except Exception: pass in close() swallows errors.

Ruff S110 flags this. At minimum, log at debug level so adapter teardown failures are visible during troubleshooting.

Proposed fix
             try:
                 await adapter.close()
-            except Exception:
-                pass
+            except Exception:
+                logger.debug("Error closing adapter %s", adapter, exc_info=True)
tests/unit/test_paper_search_service_rrf.py (1)

62-83: Solid duplicate-merge and provenance test.

Verifies dedup count, source tracking, and positive retrieval score. Consider also asserting which abstract was kept (the higher-quality candidate same_b with citation_count=10 should win), which would exercise _paper_quality more explicitly.

Comment on lines 148 to +165
if persist and self._registry:
for paper in unique:
try:
result_dict = self._registry.upsert_paper(
paper=paper.to_dict(),
source_hint=provenance.get(paper.title_hash, ["unknown"])[0],
)
source_hint = (paper.retrieval_sources or ["unknown"])[0]
upsert_kwargs = {
"paper": paper.to_dict(),
"source_hint": source_hint,
# Interactive search path: avoid blocking user requests on best-effort
# author-link syncing when SQLite is busy.
"sync_authors": False,
}
try:
result_dict = self._registry.upsert_paper(**upsert_kwargs)
except TypeError as exc:
if "sync_authors" not in str(exc):
raise
upsert_kwargs.pop("sync_authors", None)
result_dict = self._registry.upsert_paper(**upsert_kwargs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the paper_registry_port.py file
fd -e py "paper_registry_port" --type f

Repository: jerry609/PaperBot

Length of output: 114


🏁 Script executed:

# Read the paper_registry_port.py file to see the upsert_paper signature
cat -n src/paperbot/application/ports/paper_registry_port.py

Repository: jerry609/PaperBot

Length of output: 912


🏁 Script executed:

# Find implementations of RegistryPort
rg -t py "class.*RegistryPort|upsert_paper" --type py -A 3

Repository: jerry609/PaperBot

Length of output: 17334


🏁 Script executed:

# Get the full upsert_paper signature from paper_store.py
sed -n '1,100p' src/paperbot/infrastructure/stores/paper_store.py | cat -n

Repository: jerry609/PaperBot

Length of output: 3461


🏁 Script executed:

# Find the upsert_paper method in paper_store.py
rg -n "def upsert_paper" src/paperbot/infrastructure/stores/paper_store.py -A 20

Repository: jerry609/PaperBot

Length of output: 1691


Add sync_authors parameter to RegistryPort.upsert_paper signature.

The PaperStore implementation (line 112) supports sync_authors: bool = True, but the RegistryPort contract (lines 14–16 in paper_registry_port.py) does not declare it. The defensive TypeError fallback in lines 159–165 masks this mismatch. Update the port to include this parameter so the interface accurately reflects what implementations support.

🤖 Prompt for AI Agents
In `@src/paperbot/application/services/paper_search_service.py` around lines 148 -
165, The RegistryPort interface is missing the sync_authors parameter so callers
(PaperSearchService.upsert flow) must catch a TypeError; update the
RegistryPort.upsert_paper signature to accept sync_authors: bool = True
(matching PaperStore.upsert_paper implementation) and propagate that parameter
through the port contract so PaperSearchService can call
self._registry.upsert_paper(..., sync_authors=...) without needing the TypeError
fallback; ensure the method docstring and any abstract/base definitions for
RegistryPort reference the sync_authors flag.

@jerry609
jerry609 merged commit 8c22ea9 into master Feb 14, 2026
6 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Mar 3, 2026
@jerry609
jerry609 deleted the feat/rrf-personalized-ranking-v1 branch March 4, 2026 08:22
@coderabbitai coderabbitai Bot mentioned this pull request Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(search): RRF fusion for multi-source retrieval

2 participants