feat(search): RRF fusion, personalized ranking & date filters#122
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds RRF-based multi-source fusion with per-source weighting and learned weights, personalization controls (anchor boosts, personalize flag) and year-range filtering; propagates Changes
Sequence DiagramssequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
Summary of ChangesHello @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
🧠 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
Using Gemini Code AssistThe 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
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 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
|
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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.
| feedback_rows = self.research_store.list_paper_feedback( | ||
| user_id=user_id, | ||
| track_id=int(routed_track["id"]), | ||
| limit=500, | ||
| ) |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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)There was a problem hiding this comment.
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)): |
There was a problem hiding this comment.
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.
| 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 |
| 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() |
There was a problem hiding this comment.
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.
| 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) |
| 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") | ||
|
|
There was a problem hiding this comment.
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.
| 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") |
| 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") |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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 |
| return () => clearTimeout(timer) | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [searchSources]) | ||
| }, [searchSources, anchorPersonalized, yearFrom, yearTo]) |
There was a problem hiding this comment.
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.
| }, [searchSources, anchorPersonalized, yearFrom, yearTo]) | |
| }, [searchSources, anchorPersonalized]) |
| paper.title_hash = key | ||
| paper.retrieval_score = score | ||
| paper.retrieval_sources = [name for name, _ in ranked_sources] |
There was a problem hiding this comment.
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.
| merged = result.papers[0] | ||
| assert merged.title == "Duplicate Title" | ||
| assert set(merged.retrieval_sources) == {"semantic_scholar", "arxiv"} | ||
| assert merged.retrieval_score > 0 |
There was a problem hiding this comment.
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.
| min={1900} | ||
| max={2100} | ||
| disabled={disabled || isSearching} |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 | 🟡 MinorAuto-refresh on
yearFrom/yearTochange can fire excessive requests.The 300ms debounce helps, but each keystroke in the year inputs changes
yearFromoryearTo, 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_towith HTTP 400, but there's no test for the engine's behavior whenyear_from/year_toareNone. A test confirming that omitting year filters still works (i.e., the keys are absent orNonein 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.44assertion 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 withClassVar.
DEFAULT_SOURCE_WEIGHTSis a mutabledicton the class. Ruff RUF012 flags this correctly — withoutClassVar, 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 inclose().Ruff S110 flags
try-except-pass. Consider logging atdebuglevel 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 anAnchorService()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 throughContextEngine.__init__like the other dependencies.web/src/components/research/ResearchPageNew.tsx (2)
165-174:parseYearis recreated on everyhandleSearchcall.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.
yearFromandyearToare 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)) |
There was a problem hiding this comment.
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.
| 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.
| 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} |
There was a problem hiding this comment.
🧩 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.pyRepository: 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.pyRepository: 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).
- 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
There was a problem hiding this comment.
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_servicesingleton — also lives inresearch.py.Per the relevant snippets,
src/paperbot/api/routes/research.py(lines 138-145) contains an identical lazy-init pattern forAnchorService. 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.pyitself 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 0in the value expression vsor ""in the filter.The filter uses
str(p.get("paper_id") or "").isdigit()while the value usesstr(p.get("paper_id") or 0). This works correctly because the filter prevents non-digit values from reachingint(...), but the mixed default (0vs"") is a readability stumble. Consider usingor ""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() ]
| # 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 |
There was a problem hiding this comment.
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.
| # 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 "" |
There was a problem hiding this comment.
🧩 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.pyRepository: jerry609/PaperBot
Length of output: 115
🏁 Script executed:
# View around line 244
sed -n '240,250p' src/paperbot/infrastructure/stores/paper_store.pyRepository: jerry609/PaperBot
Length of output: 639
🏁 Script executed:
# View around line 567
sed -n '560,575p' src/paperbot/infrastructure/stores/paper_store.pyRepository: jerry609/PaperBot
Length of output: 865
🏁 Script executed:
# Search for HarvestedPaper definition
rg -n 'class HarvestedPaper' --type=pyRepository: 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.pyRepository: jerry609/PaperBot
Length of output: 43
🏁 Script executed:
# View HarvestedPaper class definition
sed -n '31,80p' src/paperbot/domain/harvest.pyRepository: 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 -A8Repository: 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 -A15Repository: jerry609/PaperBot
Length of output: 599
🏁 Script executed:
# Find PaperModel definition to check venue field constraint
rg -n 'class PaperModel' --type=pyRepository: jerry609/PaperBot
Length of output: 133
🏁 Script executed:
# Check the venue field in PaperModel
rg -n 'venue' src/paperbot/domain/models.py -B2 -A2Repository: 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.pyRepository: jerry609/PaperBot
Length of output: 3164
🏁 Script executed:
# Search for migration files mentioning venue and NOT NULL
find . -name '*migration*' -o -name '*alembic*' | head -20Repository: 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 -5Repository: jerry609/PaperBot
Length of output: 1744
🏁 Script executed:
# Check migration 0003 for venue NOT NULL constraint
cat alembic/versions/0003_paper_registry.pyRepository: 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
doneRepository: 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.pyRepository: 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.pyRepository: 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.pyRepository: 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=pyRepository: 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.
There was a problem hiding this comment.
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 withClassVar.
DEFAULT_SOURCE_WEIGHTSis a mutabledicton the class. WithoutClassVar, dataclass-style tooling and linters (Ruff RUF012) flag it, and—more importantly—any instance that mutatesself.DEFAULT_SOURCE_WEIGHTSwould 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=Trueis redundant with the internal catch-all.
_guarded_searchalready catches every exception and returns it as a value, soasyncio.gather(..., return_exceptions=True)will never trigger. Removereturn_exceptions=Trueto avoid misleading readers into thinking exceptions can still propagate.Also, on line 96, note that the function returns a bare
TimeoutErrorobject (not re-raising). This works because line 107 checksisinstance(result, BaseException), but it's worth a brief inline comment for clarity.
182-187: Silentexcept Exception: passinclose()swallows errors.Ruff S110 flags this. At minimum, log at
debuglevel 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_bwithcitation_count=10should win), which would exercise_paper_qualitymore explicitly.
| 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) |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
# First, find the paper_registry_port.py file
fd -e py "paper_registry_port" --type fRepository: 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.pyRepository: jerry609/PaperBot
Length of output: 912
🏁 Script executed:
# Find implementations of RegistryPort
rg -t py "class.*RegistryPort|upsert_paper" --type py -A 3Repository: 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 -nRepository: 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 20Repository: 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.
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)
Personalized Mode & Anchor Boosts (#119)
Feedback-Driven Source Weights (#120)
Date Range Filters (#121)
Testing
test_context_engine_source_weights.py— unit tests for feedback weight learningtest_context_engine_date_filters.py— unit tests for year range filteringCloses #118 #119 #120 #121
Summary by CodeRabbit
New Features
Bug Fixes
Tests