Skip to content

Feat(core): DDD + Hexagonal Architecture — 身份统一 + 统一搜索 + Enrichment Pipeline#50

Merged
jerry609 merged 1 commit into
masterfrom
feat/ddd-hexagonal-refactor
Feb 12, 2026
Merged

Feat(core): DDD + Hexagonal Architecture — 身份统一 + 统一搜索 + Enrichment Pipeline#50
jerry609 merged 1 commit into
masterfrom
feat/ddd-hexagonal-refactor

Conversation

@jerry609

@jerry609 jerry609 commented Feb 12, 2026

Copy link
Copy Markdown
Owner

概述

按 DDD 上下文拆分 + Hexagonal Architecture 重构数据层与模块,统一 Research / DailyPaper / Papers Library 三个模块的数据管道。

改动内容

P0: 身份统一(数据层根基)

  • paper_identifiers (Alembic 0009):统一 (source, external_id) → papers.id 映射,替代原来 paper_feedback.paper_id 存外部 ID 的四路 OR JOIN
  • PaperIdentity 值对象 + IdentityStore CRUD + IdentityResolver 服务(从 _resolve_paper_ref_id 提取)
  • 双写paper_store.upsert_paper() 同时写 paper_identifiersresearch_store.add_paper_feedback() 同时写 canonical_paper_id
  • Feature flag PAPERBOT_USE_CANONICAL_FKget_user_library() 可切换到单路 FK JOIN
  • Backfill 脚本scripts/backfill_identifiers.py

P1: PaperSearchService 上线

  • SearchPort Protocol + 5 个 adapter(S2、arXiv、papers.cool、HF Daily、OpenAlex)
  • PaperSearchService 门面:并发扇出搜索 → 去重 → 可选持久化
  • ContextEngine 重构:优先使用 PaperSearchService,保留 SemanticScholarSearch 兜底
  • Port 接口RegistryPortFeedbackPortEnrichmentPort

P2: Enrichment 共享

  • EnrichmentPipeline(Chain of Responsibility):可插拔的 Judge / Summary / Repo 步骤

Domain 值对象

  • PaperCandidate(ACL 输出)、FeedbackActionJudgeScoreLLMSummaryResearchTrack

Bug Fix

  • 修复 _resolve_paper_ref_id 中引用不存在的 PaperModel.external_url

测试

  • pytest tests/unit/ -q324 passed, 0 failed
  • 所有 import 验证通过

迁移步骤

  1. alembic upgrade head
  2. python scripts/backfill_identifiers.py
  3. 验证后设置 PAPERBOT_USE_CANONICAL_FK=true 切换读路径

Summary by CodeRabbit

  • New Features

    • Unified paper search across multiple sources (Semantic Scholar, arXiv, OpenAlex, Hugging Face Daily, Papers.cool) with automatic deduplication and result aggregation.
    • External ID tracking and resolution for papers from various sources.
    • Enrichment pipeline for evaluating and scoring papers with AI-generated summaries.
    • Enhanced feedback system with improved tracking and canonical paper linking.
    • Research track management for organizing research interests.
  • Chores

    • Database schema migration to support paper identifiers and feedback canonicalization.
    • Data backfill utilities for identifier and feedback synchronization.

…ion, unified search, enrichment pipeline

P0 — Identity Unification:
- Add paper_identifiers table (Alembic 0009) + PaperIdentifierModel
- Add PaperIdentity value object, IdentityStore CRUD, IdentityResolver service
- Dual-write canonical_paper_id on paper_feedback + paper_identifiers on paper upsert
- Feature-flagged single-FK JOIN path for get_user_library (PAPERBOT_USE_CANONICAL_FK)
- Backfill script (scripts/backfill_identifiers.py)

P1 — Unified PaperSearchService:
- SearchPort protocol + 5 adapters (S2, arXiv, papers.cool, HF Daily, OpenAlex)
- PaperSearchService facade with concurrent fan-out, dedup, optional persistence
- ContextEngine refactored to prefer PaperSearchService with legacy fallback
- RegistryPort, FeedbackPort, EnrichmentPort protocol interfaces

P2 — Enrichment Pipeline:
- EnrichmentPipeline (Chain of Responsibility) for judge/summary/repo steps

Domain:
- PaperCandidate (normalized ACL output), FeedbackAction, JudgeScore, LLMSummary, ResearchTrack

Fix: remove PaperModel.external_url reference in _resolve_paper_ref_id (column doesn't exist)
Copilot AI review requested due to automatic review settings February 12, 2026 07:01
@coderabbitai

coderabbitai Bot commented Feb 12, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces a multi-source paper search system with unified identifier tracking. It adds domain models for papers, feedback, enrichment, and research tracks; creates Protocol-based port interfaces for search, registry, enrichment, and feedback operations; implements adapters for multiple paper search sources (Semantic Scholar, ArXiv, OpenAlex, Hugging Face Daily, Papers.Cool); and establishes an identity resolution system to map external IDs to canonical paper records. Database schema is extended with a new paper_identifiers table and canonical_paper_id field on feedback records, with a backfill script for migration support.

Changes

Cohort / File(s) Summary
Database Schema & Migration
alembic/versions/0009_paper_identifiers.py, src/paperbot/infrastructure/stores/models.py
Introduces paper_identifiers table with unique (source, external_id) constraint, adds canonical_paper_id column to paper_feedback with index, and creates relationships between PaperModel and PaperIdentifierModel with cascade delete.
Migration Backfill
scripts/backfill_identifiers.py
Adds backfill functions to populate paper_identifiers from existing paper records and update canonical_paper_id in feedback records from paper_ref_id.
Domain Models
src/paperbot/domain/paper.py, src/paperbot/domain/identity.py, src/paperbot/domain/feedback.py, src/paperbot/domain/enrichment.py, src/paperbot/domain/track.py
New immutable dataclasses for PaperCandidate (with title hashing and identity management), PaperIdentity (source/external_id normalization), PaperFeedback (with FeedbackAction enum), JudgeScore, LLMSummary, and ResearchTrack aggregate root with full typed field definitions.
Application Ports
src/paperbot/application/ports/paper_search_port.py, src/paperbot/application/ports/paper_registry_port.py, src/paperbot/application/ports/feedback_port.py, src/paperbot/application/ports/enrichment_port.py
New Protocol interfaces defining SearchPort (async search with source_name), RegistryPort (upsert operations), FeedbackPort (add and list operations), and EnrichmentPort (upsert judge scores) as contracts for implementation.
Application Services
src/paperbot/application/services/enrichment_pipeline.py, src/paperbot/application/services/identity_resolver.py, src/paperbot/application/services/paper_search_service.py
Introduces EnrichmentPipeline (chain-of-responsibility for multi-step enrichment), IdentityResolver (resolve external IDs to canonical paper_id with fallback matching), and PaperSearchService (fan-out search across adapters, deduplication, provenance tracking, optional persistence).
Search Adapters
src/paperbot/infrastructure/adapters/__init__.py, src/paperbot/infrastructure/adapters/s2_search_adapter.py, src/paperbot/infrastructure/adapters/arxiv_search_adapter.py, src/paperbot/infrastructure/adapters/hf_daily_adapter.py, src/paperbot/infrastructure/adapters/openalex_adapter.py, src/paperbot/infrastructure/adapters/paperscool_adapter.py
Implements SearchPort adapters wrapping existing harvesters (SemanticScholarHarvester, ArxivConnector, HFDailyPapersConnector, OpenAlexHarvester, PapersCoolConnector) and a registry builder function to instantiate all adapters.
Identity Store
src/paperbot/infrastructure/stores/identity_store.py
New IdentityStore class managing CRUD for PaperIdentifierModel with upsert, resolve, and list operations; supports dual-write of external identifiers during paper upserts.
Store Updates
src/paperbot/infrastructure/stores/paper_store.py, src/paperbot/infrastructure/stores/research_store.py
Paper store adds _sync_identifiers dual-write during batch upsert, introduces USE_CANONICAL_FK feature flag for canonical FK-based library queries, and adds _get_user_library_canonical method; research store writes canonical_paper_id alongside paper_ref_id in feedback creation.
Context Engine Integration
src/paperbot/context_engine/engine.py
Adds optional search_service parameter to ContextEngine; when provided, uses PaperSearchService for unified search instead of legacy SemanticScholarSearch path.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant SearchService as PaperSearchService
    participant Adapters as SearchPort<br/>Adapters
    participant Dedup as Deduplicator
    participant Registry as PaperRegistry
    participant IdentityResolver as IdentityResolver

    Client->>SearchService: search(query, sources, persist)
    activate SearchService
    
    SearchService->>SearchService: _select_adapters(sources)
    
    par Parallel Searches
        SearchService->>Adapters: search(query)
        Adapters-->>SearchService: List[PaperCandidate]
    end
    
    SearchService->>SearchService: Build provenance map
    SearchService->>Dedup: Deduplicate by title_hash
    
    alt persist=true and registry provided
        SearchService->>Registry: upsert_many(papers)
        activate Registry
        Registry-->>SearchService: {paper_ids, ...}
        deactivate Registry
        
        SearchService->>IdentityResolver: resolve() for identities
        activate IdentityResolver
        IdentityResolver-->>SearchService: canonical_id
        deactivate IdentityResolver
    end
    
    SearchService->>SearchService: Build SearchResult
    SearchService-->>Client: SearchResult{papers, provenance, totals}
    deactivate SearchService
Loading
sequenceDiagram
    participant App
    participant IdentityResolver as IdentityResolver
    participant IdentityStore as IdentityStore
    participant DB as PaperModel<br/>PaperIdentifierModel

    App->>IdentityResolver: resolve(external_id, hints)
    activate IdentityResolver
    
    IdentityResolver->>IdentityStore: resolve(source, external_id)
    activate IdentityStore
    IdentityStore->>DB: Query paper_identifiers
    DB-->>IdentityStore: Optional[paper_id]
    deactivate IdentityStore
    
    alt Found
        IdentityStore-->>IdentityResolver: paper_id
    else Not Found
        IdentityResolver->>IdentityResolver: Normalize ID (arXiv/DOI)
        IdentityResolver->>DB: Query papers by arxiv_id/doi
        DB-->>IdentityResolver: Candidates
        
        alt Still no match
            IdentityResolver->>IdentityResolver: URL & title matching
            IdentityResolver->>DB: Query by URLs/title
            DB-->>IdentityResolver: Fallback candidates
        end
    end
    
    IdentityResolver-->>App: Optional[canonical_paper_id]
    deactivate IdentityResolver
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • PR #27: Modifies the same storage and search/harvest code paths (models.py, paper_store.py, search adapters) with overlapping schema and identifier pipeline changes.
  • PR #43: Updates the same persistence layer (alembic revisions, models.py) and modifies store implementations (paper_store.py, research_store.py) for ORM model/column additions and upsert behavior.

Suggested reviewers

  • ThankUYou
  • wen-placeholder

Poem

🐰 A rabbit hops through search so wide,
Five sources unified with pride,
Identities tracked, papers deduped,
Enrichment loops, oh what a troupe!
From chaos springs a ordered space, 🌟

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.24% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is vague and overly broad, using technical jargon and abbreviations that don't clearly convey the main change to a teammate scanning history. Refactor the title to focus on the primary change. Consider: 'Add paper identity unification with hexagonal architecture refactor' or 'Introduce PaperSearchService and identity resolution layer'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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/ddd-hexagonal-refactor

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!

此拉取请求旨在通过采用领域驱动设计(DDD)和六边形架构(Hexagonal Architecture)来重构核心模块和数据层,从而统一 Research、DailyPaper 和 Papers Library 三个模块的数据管道。这些更改显著提升了系统处理论文身份、搜索和富集的能力,使其更具可扩展性和可维护性,为未来的功能扩展奠定了坚实基础。

Highlights

  • 身份统一 (Identity Unification): 引入 paper_identifiers 表以统一 (source, external_id) → papers.id 映射,替代了原有的多路 OR JOIN。新增 PaperIdentity 值对象、IdentityStore CRUD 操作和 IdentityResolver 服务。实现了 paper_store.upsert_paper()research_store.add_paper_feedback() 的双写机制,并增加了 PAPERBOT_USE_CANONICAL_FK 特性开关以切换读路径,同时提供了回填脚本。
  • PaperSearchService 上线: 定义了 SearchPort 协议及其 5 个适配器(S2、arXiv、papers.cool、HF Daily、OpenAlex)。创建了 PaperSearchService 门面,用于并发扇出搜索、去重和可选持久化。重构了 ContextEngine 以优先使用 PaperSearchService,并保留 SemanticScholarSearch 作为备用。新增了 RegistryPortFeedbackPortEnrichmentPort 接口。
  • Enrichment 共享: 引入了 EnrichmentPipeline,这是一个责任链模式的实现,支持可插拔的 Judge / Summary / Repo 步骤,用于论文的富集处理。
  • 领域值对象: 新增了 PaperCandidate(ACL 输出)、FeedbackActionJudgeScoreLLMSummaryResearchTrack 等领域值对象,以更好地支持 DDD 架构。
  • Bug 修复: 修复了 _resolve_paper_ref_id 中引用不存在的 PaperModel.external_url 列的问题。

🧠 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
  • alembic/versions/0009_paper_identifiers.py
    • 新增了 Alembic 迁移脚本,用于创建 paper_identifiers 表,并向 paper_feedback 表添加 canonical_paper_id 列。
  • scripts/backfill_identifiers.py
    • 新增了用于回填 paper_identifiers 表和 paper_feedback.canonical_paper_id 列的脚本。
  • src/paperbot/application/ports/enrichment_port.py
    • 新增了 EnrichmentPort 协议,定义了论文富集操作的抽象接口。
  • src/paperbot/application/ports/feedback_port.py
    • 新增了 FeedbackPort 协议,定义了论文反馈操作的抽象接口。
  • src/paperbot/application/ports/paper_registry_port.py
    • 新增了 RegistryPort 协议,定义了规范论文 CRUD 操作的抽象接口。
  • src/paperbot/application/ports/paper_search_port.py
    • 新增了 SearchPort 协议,定义了统一论文搜索适配器的抽象接口。
  • src/paperbot/application/services/enrichment_pipeline.py
    • 新增了 EnrichmentPipeline 服务,实现了责任链模式,用于论文富集处理。
  • src/paperbot/application/services/identity_resolver.py
    • 新增了 IdentityResolver 服务,用于将外部论文 ID 解析为规范的 papers.id
  • src/paperbot/application/services/paper_search_service.py
    • 新增了 PaperSearchService 服务,作为统一搜索门面,负责向多个适配器扇出查询、去重和可选持久化。
  • src/paperbot/context_engine/engine.py
    • 更新了 ContextEngine,使其能够选择性地使用 PaperSearchService 进行论文搜索。
    • ContextEngine 的构造函数中添加了 search_service 参数。
  • src/paperbot/domain/enrichment.py
    • 新增了 JudgeScoreLLMSummary 数据类,作为富集领域的领域值对象。
  • src/paperbot/domain/feedback.py
    • 新增了 FeedbackAction 枚举和 PaperFeedback 数据类,作为反馈领域的领域值对象。
  • src/paperbot/domain/identity.py
    • 新增了 PaperIdentity 数据类,用于统一外部 ID 跟踪。
  • src/paperbot/domain/paper.py
    • 扩展了 PaperMeta,新增了 PaperCandidate 数据类,包含 title_hashidentities 字段,用于规范化论文表示。
    • 添加了计算 title_hash 的辅助函数。
  • src/paperbot/domain/track.py
    • 新增了 ResearchTrack 数据类,作为研究方向的领域聚合根。
  • src/paperbot/infrastructure/adapters/init.py
    • 新增了 build_adapter_registry 函数,用于构建 SearchPort 适配器注册表。
  • src/paperbot/infrastructure/adapters/arxiv_search_adapter.py
    • 新增了 ArxivSearchAdapter,作为 arXiv 搜索的 SearchPort 实现。
  • src/paperbot/infrastructure/adapters/hf_daily_adapter.py
    • 新增了 HFDailyAdapter,作为 Hugging Face Daily Papers 搜索的 SearchPort 实现。
  • src/paperbot/infrastructure/adapters/openalex_adapter.py
    • 新增了 OpenAlexAdapter,作为 OpenAlex 搜索的 SearchPort 实现。
  • src/paperbot/infrastructure/adapters/paperscool_adapter.py
    • 新增了 PapersCoolAdapter,作为 papers.cool 搜索的 SearchPort 实现。
  • src/paperbot/infrastructure/adapters/s2_search_adapter.py
    • 新增了 S2SearchAdapter,作为 Semantic Scholar 搜索的 SearchPort 实现。
  • src/paperbot/infrastructure/stores/identity_store.py
    • 新增了 IdentityStore,用于对 paper_identifiers 表进行 CRUD 操作。
  • src/paperbot/infrastructure/stores/models.py
    • 新增了 PaperIdentifierModel,用于映射外部 ID 到规范论文 ID。
    • PaperFeedbackModel 添加了 canonical_paper_id 列。
    • PaperModelPaperIdentifierModel 建立了关系。
  • src/paperbot/infrastructure/stores/paper_store.py
    • upsert_paperupsert_papers_batch 方法实现了 paper_identifiers 的双写逻辑。
    • 引入了 PAPERBOT_USE_CANONICAL_FK 环境变量,以控制 get_user_library 是否使用规范外键。
    • 添加了 _get_user_library_canonical 方法,使用规范外键进行库查询。
  • src/paperbot/infrastructure/stores/research_store.py
    • 更新了 add_paper_feedback 方法,实现了向 canonical_paper_id 的双写。
    • _resolve_paper_ref_id 方法中移除了对 PaperModel.external_url 的引用,修复了潜在的 bug。
Activity
  • 所有导入验证通过。
  • 单元测试 pytest tests/unit/ -q 结果为 324 个通过,0 个失败。
  • 迁移步骤包括:首先执行 alembic upgrade head 进行数据库升级,然后运行 python scripts/backfill_identifiers.py 进行数据回填,最后验证无误后设置 PAPERBOT_USE_CANONICAL_FK=true 切换读路径。
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.

@jerry609
jerry609 merged commit 56d833e into master Feb 12, 2026
7 of 8 checks passed

@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 a significant and well-structured refactoring towards DDD and Hexagonal Architecture, unifying paper identity management, creating a new search service, and an enrichment pipeline. A critical security vulnerability was identified: the recurring pattern of using scalar_one_or_none() on database columns without unique constraints creates a Denial of Service risk when duplicate data is encountered. Switching to .first() is recommended to improve robustness. Additionally, a critical bug in the new IdentityResolver could cause a runtime error due to a reference to a non-existent model attribute, and several high-severity performance issues were found in the data backfill script and the PaperStore related to N+1 queries and inefficient batch updates. Specific suggestions are provided to address these issues, enhance performance, and improve maintainability.

select(PaperModel).where(
or_(
PaperModel.url.in_(url_candidates),
PaperModel.external_url.in_(url_candidates),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

The query references PaperModel.external_url, but this attribute does not exist on the PaperModel class defined in src/paperbot/infrastructure/stores/models.py. This will cause an AttributeError at runtime. The PR description mentions fixing this exact issue in research_store.py, so it seems this one was missed.

Comment on lines +41 to +74
with provider.session() as session:
papers = session.execute(select(PaperModel)).scalars().all()
for paper in papers:
pairs: list[tuple[str, str]] = []
if paper.semantic_scholar_id:
pairs.append(("semantic_scholar", paper.semantic_scholar_id))
if paper.arxiv_id:
pairs.append(("arxiv", paper.arxiv_id))
if paper.openalex_id:
pairs.append(("openalex", paper.openalex_id))
if paper.doi:
pairs.append(("doi", paper.doi))

for source, eid in pairs:
existing = session.execute(
select(PaperIdentifierModel).where(
PaperIdentifierModel.source == source,
PaperIdentifierModel.external_id == eid,
)
).scalar_one_or_none()
if existing is None:
session.add(
PaperIdentifierModel(
paper_id=paper.id,
source=source,
external_id=eid,
created_at=now,
)
)
created += 1
else:
skipped += 1

session.commit()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

This function has a performance issue due to N+1 queries. It fetches all papers and then, inside a loop, executes a query for each potential identifier to check for its existence. This can be very slow for a large number of papers.

Consider optimizing this by:

  1. Fetching all existing (source, external_id) pairs from paper_identifiers into a Python set before the loop.
  2. In the loop, checking for existence against this set in memory.
  3. Collecting all new PaperIdentifierModel objects in a list and using session.add_all() at the end for a bulk insert.

Comment on lines +82 to +97
with provider.session() as session:
rows = (
session.execute(
select(PaperFeedbackModel).where(
PaperFeedbackModel.canonical_paper_id.is_(None),
PaperFeedbackModel.paper_ref_id.is_not(None),
)
)
.scalars()
.all()
)
for row in rows:
row.canonical_paper_id = row.paper_ref_id
updated += 1
session.commit()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

This function loads all matching PaperFeedbackModel rows into memory using .all() before updating them one by one. For a large paper_feedback table, this can lead to high memory consumption. A more efficient and memory-friendly approach is to perform a bulk UPDATE statement directly in the database. You'll need to import update from sqlalchemy for this.

    with provider.session() as session:
        stmt = update(PaperFeedbackModel).where(
            PaperFeedbackModel.canonical_paper_id.is_(None),
            PaperFeedbackModel.paper_ref_id.is_not(None),
        ).values(canonical_paper_id=PaperFeedbackModel.paper_ref_id)
        result = session.execute(stmt)
        updated = result.rowcount
        session.commit()

Comment on lines +245 to +278
def _sync_identifiers(session, row: PaperModel) -> None:
"""Write known external IDs to paper_identifiers (idempotent)."""
pairs: list[tuple[str, str]] = []
if row.semantic_scholar_id:
pairs.append(("semantic_scholar", row.semantic_scholar_id))
if row.arxiv_id:
pairs.append(("arxiv", row.arxiv_id))
if row.openalex_id:
pairs.append(("openalex", row.openalex_id))
if row.doi:
pairs.append(("doi", row.doi))

for source, eid in pairs:
existing = session.execute(
select(PaperIdentifierModel).where(
PaperIdentifierModel.source == source,
PaperIdentifierModel.external_id == eid,
)
).scalar_one_or_none()
if existing is None:
session.add(
PaperIdentifierModel(
paper_id=row.id,
source=source,
external_id=eid,
created_at=_utcnow(),
)
)
elif existing.paper_id != row.id:
existing.paper_id = row.id
try:
session.flush()
except Exception:
session.rollback()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The _sync_identifiers method has a performance issue due to N+1 queries. Inside the loop, it executes a SELECT query for each identifier type. For a paper with multiple identifiers, this is inefficient.

Additionally, this logic duplicates functionality from IdentityStore.upsert_identity. It would be better to refactor this to either reuse IdentityStore or optimize the queries by fetching all existing identifiers for the paper in a single query before the loop.

Comment on lines +456 to +461
# Dual-write identifiers for all papers in this batch
for paper in papers:
existing = self._find_existing(session, paper)
if existing:
self._sync_identifiers(session, existing)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

This block iterates over all papers in the batch a second time just to sync their identifiers. This is inefficient as it requires another database lookup (_find_existing) for each paper. The identifier synchronization should be performed within the main loop (lines 437-451) for both new and updated papers, right after they have been assigned an ID (for new papers) or retrieved (for updated papers). This would avoid the second loop and the repeated _find_existing calls.

Comment on lines +87 to +91
row = session.execute(
select(PaperIdentifierModel).where(
PaperIdentifierModel.external_id == external_id,
)
).scalar_one_or_none()

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-medium medium

The use of scalar_one_or_none() on the external_id column is potentially dangerous because external_id is not unique across different sources (it is only unique when combined with source). If the same external_id exists for multiple sources (e.g., a numeric ID that happens to be used by both arXiv and Semantic Scholar), this call will raise a MultipleResultsFound exception, leading to a 500 Internal Server Error. This can be exploited as a Denial of Service (DoS) vector by an attacker who can cause duplicate external IDs to be registered in the system.

Recommendation: Use .scalars().first() instead to safely retrieve the first matching record if multiple exist.

Suggested change
row = session.execute(
select(PaperIdentifierModel).where(
PaperIdentifierModel.external_id == external_id,
)
).scalar_one_or_none()
row = session.execute(
select(PaperIdentifierModel).where(
PaperIdentifierModel.external_id == external_id,
).limit(1)
).scalars().first()

Comment on lines +119 to +123
row = session.execute(
select(PaperModel).where(
func.lower(PaperModel.title) == title.lower()
)
).scalar_one_or_none()

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-medium medium

The use of scalar_one_or_none() when querying by paper title is risky because titles are not guaranteed to be unique in the papers table. If multiple papers with the same title (but different identifiers) are harvested, this call will raise a MultipleResultsFound exception, causing the request to fail with a 500 error. This presents a Denial of Service (DoS) risk if an attacker can trigger the ingestion of papers with duplicate titles.

Recommendation: Use .scalars().first() and include a .limit(1) in the query to ensure the application handles potential duplicates gracefully.

Suggested change
row = session.execute(
select(PaperModel).where(
func.lower(PaperModel.title) == title.lower()
)
).scalar_one_or_none()
row = session.execute(
select(PaperModel).where(
func.lower(PaperModel.title) == title.lower()
).limit(1)
).scalars().first()

Comment on lines +23 to +26
try:
return bool(context.is_offline_mode())
except Exception:
return False

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

Using a broad except Exception: can mask unexpected errors and make debugging more difficult. If the goal is to handle cases where the script runs outside an Alembic context and context is not defined, it's better to catch a more specific exception like NameError.

    except NameError:
        return False

Comment on lines +37 to +44
deduplicator=None,
registry=None,
identity_store=None,
):
self._adapters = adapters
self._deduplicator = deduplicator
self._registry = registry
self._identity_store = identity_store

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

The __init__ method accepts deduplicator and identity_store as arguments, but they are assigned to instance variables that are never used within the class. This can be confusing for future maintenance. If they are intended for future use, a comment explaining this would be helpful. Otherwise, they should be removed to clean up the interface.

Suggested change
deduplicator=None,
registry=None,
identity_store=None,
):
self._adapters = adapters
self._deduplicator = deduplicator
self._registry = registry
self._identity_store = identity_store
deduplicator=None, # TODO: Unused parameter, for future implementation?
registry=None,
identity_store=None, # TODO: Unused parameter, for future implementation?
):
self._adapters = adapters
self._deduplicator = deduplicator
self._registry = registry
self._identity_store = identity_store

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 restructures the core data/search pipeline toward DDD + Hexagonal Architecture, introducing a unified paper identity layer (canonical papers.id) and a new multi-source search facade intended to serve Research / DailyPaper / Library flows consistently.

Changes:

  • Adds paper_identifiers mapping + dual-write path for paper_feedback.canonical_paper_id, plus a backfill script and Alembic migration.
  • Introduces PaperSearchService + SearchPort and multiple source adapters to unify paper search and optional persistence.
  • Adds new domain value objects and an EnrichmentPipeline scaffold for shared enrichment steps.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 17 comments.

Show a summary per file
File Description
src/paperbot/infrastructure/stores/research_store.py Dual-write canonical_paper_id; removes nonexistent external_url reference in resolver path.
src/paperbot/infrastructure/stores/paper_store.py Dual-write identifiers; feature-flagged canonical library join.
src/paperbot/infrastructure/stores/models.py Adds canonical_paper_id column + paper_identifiers ORM model/relationship.
src/paperbot/infrastructure/stores/identity_store.py CRUD store for paper_identifiers.
src/paperbot/infrastructure/adapters/s2_search_adapter.py S2 SearchPort adapter.
src/paperbot/infrastructure/adapters/paperscool_adapter.py papers.cool SearchPort adapter.
src/paperbot/infrastructure/adapters/openalex_adapter.py OpenAlex SearchPort adapter.
src/paperbot/infrastructure/adapters/hf_daily_adapter.py HF Daily Papers SearchPort adapter.
src/paperbot/infrastructure/adapters/arxiv_search_adapter.py arXiv SearchPort adapter.
src/paperbot/infrastructure/adapters/init.py Adapter registry builder.
src/paperbot/domain/track.py Extracts ResearchTrack aggregate.
src/paperbot/domain/paper.py Adds PaperCandidate ACL output + title hashing.
src/paperbot/domain/identity.py Adds PaperIdentity value object.
src/paperbot/domain/feedback.py Adds feedback value objects/enums.
src/paperbot/domain/enrichment.py Adds enrichment value objects.
src/paperbot/context_engine/engine.py Prefers injected PaperSearchService-style search over legacy searcher.
src/paperbot/application/services/paper_search_service.py Unified async fan-out search + dedup + optional persistence.
src/paperbot/application/services/identity_resolver.py New canonical-id resolution service (identifiers table + fallbacks).
src/paperbot/application/services/enrichment_pipeline.py Chain-of-responsibility enrichment pipeline scaffold.
src/paperbot/application/ports/paper_search_port.py SearchPort protocol.
src/paperbot/application/ports/paper_registry_port.py RegistryPort protocol.
src/paperbot/application/ports/feedback_port.py FeedbackPort protocol.
src/paperbot/application/ports/enrichment_port.py EnrichmentPort protocol.
scripts/backfill_identifiers.py Backfill script for identifiers + canonical FK column.
alembic/versions/0009_paper_identifiers.py Migration creating paper_identifiers + adding canonical_paper_id column/index.

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

Comment on lines +100 to +117
# 3. Persist if requested
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],
)
paper.canonical_id = result_dict.get("id")
except Exception as e:
logger.warning(f"Failed to persist paper '{paper.title[:50]}': {e}")

return SearchResult(
papers=unique[:max_results],
provenance=provenance,
total_raw=total_raw,
duplicates_removed=duplicates_removed,
)

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

PaperSearchService.search() persists every deduplicated paper in unique when persist=True, but returns only unique[:max_results]. For multi-source fan-out, unique can be much larger than max_results, leading to unexpected extra DB writes and slower requests. Consider persisting only the papers you will return (or add a separate persist_limit/persist_all option).

Copilot uses AI. Check for mistakes.
from sqlalchemy import Integer, String, cast, desc, func, or_, select

from paperbot.domain.harvest import HarvestedPaper, HarvestSource
from paperbot.domain.identity import PaperIdentity

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

PaperIdentity is imported but not used in this module; removing it will avoid confusion and keep the dependency surface small.

Suggested change
from paperbot.domain.identity import PaperIdentity

Copilot uses AI. Check for mistakes.

# Canonical FK (dual-write migration — will replace paper_id + paper_ref_id)
canonical_paper_id: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, index=True

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

PaperFeedbackModel.canonical_paper_id is described as a “Canonical FK”, but it’s declared as a plain Integer without a ForeignKey("papers.id"). Without the FK constraint you lose referential integrity and may get worse query planning/index usage. If this is intended to become the canonical join key, it should be declared with an explicit FK (even if nullable during migration).

Suggested change
Integer, nullable=True, index=True
Integer, ForeignKey("papers.id"), nullable=True, index=True

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +22
# Optional: PaperSearchService for unified search
try:
from paperbot.application.services.paper_search_service import PaperSearchService
except ImportError: # pragma: no cover
PaperSearchService = None # type: ignore

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

PaperSearchService is imported behind a try/except but never used in this module (the code checks self.search_service directly). This dead import block makes it harder to understand the actual dependency path; consider removing it or using it to type-check/instantiate a default PaperSearchService when search_service is not provided.

Suggested change
# Optional: PaperSearchService for unified search
try:
from paperbot.application.services.paper_search_service import PaperSearchService
except ImportError: # pragma: no cover
PaperSearchService = None # type: ignore

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +94
# --- paper_feedback.canonical_paper_id (nullable FK for dual-write) ---
if not _is_offline() and "canonical_paper_id" in _get_columns("paper_feedback"):
pass # column already exists
else:
op.add_column(
"paper_feedback",
sa.Column("canonical_paper_id", sa.Integer(), nullable=True),
)
_create_index(
"ix_paper_feedback_canonical_paper_id",
"paper_feedback",
["canonical_paper_id"],
)

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

Migration docstring says paper_feedback.canonical_paper_id is a “nullable FK column”, but the migration only adds a plain integer column + index (no FK constraint to papers.id). If you intend FK semantics, add the foreign key constraint in this revision (or clarify in the migration/docstring that it’s intentionally not enforced during the expand phase).

Copilot uses AI. Check for mistakes.

from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, Optional

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

Import of 'Dict' is not used.
Import of 'Any' is not used.

Suggested change
from typing import Any, Dict, Optional
from typing import Optional

Copilot uses AI. Check for mistakes.

from __future__ import annotations

from typing import Any, Dict, List, Optional, Protocol, runtime_checkable

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

Import of 'List' is not used.
Import of 'Optional' is not used.

Suggested change
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
from typing import Any, Dict, Protocol, runtime_checkable

Copilot uses AI. Check for mistakes.

from __future__ import annotations

from typing import Any, Dict, List, Optional

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

Import of 'Dict' is not used.
Import of 'Any' is not used.

Suggested change
from typing import Any, Dict, List, Optional
from typing import List, Optional

Copilot uses AI. Check for mistakes.
def close(self) -> None:
try:
self._provider.engine.dispose()
except Exception:

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

'except' clause does nothing but pass and there is no explanatory comment.

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +133
except Exception:
pass

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

'except' clause does nothing but pass and there is no explanatory comment.

Suggested change
except Exception:
pass
except Exception as e:
logger.warning("Failed to close adapter %r: %s", adapter, e)

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: 10

Caution

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

⚠️ Outside diff range comments (1)
src/paperbot/infrastructure/stores/research_store.py (1)

1376-1396: ⚠️ Potential issue | 🟠 Major

scalar_one_or_none() will raise MultipleResultsFound at runtime for duplicate URLs or titles.

The TODO on line 1376 correctly identifies this. Both the URL-based lookup (line 1386) and the title-based lookup (line 1394) are susceptible. Title collisions are especially likely in practice. Consider switching to .first() now to avoid unhandled exceptions bubbling up during feedback recording or paper resolution.

Proposed fix
-            row = session.execute(
-                select(PaperModel).where(
-                    or_(
-                        PaperModel.url.in_(url_candidates),
-                        PaperModel.pdf_url.in_(url_candidates),
-                    )
-                )
-            ).scalar_one_or_none()
+            row = session.execute(
+                select(PaperModel).where(
+                    or_(
+                        PaperModel.url.in_(url_candidates),
+                        PaperModel.pdf_url.in_(url_candidates),
+                    )
+                ).limit(1)
+            ).scalar_one_or_none()
             if row is not None:
                 return int(row.id)
 
         title = str(metadata.get("title") or "").strip()
         if title:
-            row = session.execute(
-                select(PaperModel).where(func.lower(PaperModel.title) == title.lower())
-            ).scalar_one_or_none()
+            row = session.execute(
+                select(PaperModel).where(func.lower(PaperModel.title) == title.lower()).limit(1)
+            ).scalar_one_or_none()
             if row is not None:
                 return int(row.id)
🤖 Fix all issues with AI agents
In `@alembic/versions/0009_paper_identifiers.py`:
- Around line 97-100: The downgrade() currently drops an index, a column, and a
table without existence checks which makes re-running a partially-applied
downgrade fail; update downgrade() to be idempotent by guarding each operation
(the op.drop_index("ix_paper_feedback_canonical_paper_id", ...),
op.drop_column("paper_feedback", "canonical_paper_id"), and
op.drop_table("paper_identifiers")) — either query the DB via the migration
bind/Inspector to check for the index/column/table before calling each op.* or
wrap each call in a narrow try/except that ignores "not found" errors so the
downgrade can be safely re-run.

In `@src/paperbot/application/services/identity_resolver.py`:
- Around line 102-114: The OR condition in identity resolution references a
removed column PaperModel.external_url causing AttributeError; update the URL
match in the function that queries PaperModel (the block using
session.execute(select(PaperModel).where(or_( ... ))).first()) to remove the
PaperModel.external_url operand and only check PaperModel.url and
PaperModel.pdf_url (i.e., drop the external_url reference from the or_ call) so
the select uses existing columns only.

In `@src/paperbot/application/services/paper_search_service.py`:
- Around line 100-117: The synchronous call to self._registry.upsert_paper
inside the async method blocks the event loop; change the persistence call to
run in a thread by awaiting asyncio.to_thread(self._registry.upsert_paper,
paper=paper.to_dict(), source_hint=provenance.get(paper.title_hash,
["unknown"])[0]) and assign paper.canonical_id from the returned dict, keeping
the existing exception handling but catching exceptions from the awaited
to_thread call; update imports to include asyncio if needed and ensure this
change is applied where upsert_paper is called in the same async method that
returns SearchResult (references: self._registry.upsert_paper, paper.to_dict(),
provenance, paper.canonical_id, SearchResult).

In `@src/paperbot/context_engine/engine.py`:
- Around line 522-545: Add unit tests covering the new search_service path: mock
an instance of search_service (and its search method) so when Engine.search flow
hits the branch where search_service is not None it calls
search_service.search(merged_query, sources=["semantic_scholar"],
max_results=fetch_limit, persist=True); return a mock search_result with a
.papers list of objects that expose .to_dict(), .canonical_id and
.get_identity("semantic_scholar") to exercise both canonical_id and fallback
identity branches, then assert the code injects paper_id into each returned dict
(verifying values produced from p_obj.canonical_id and p_obj.get_identity), and
assert Logger/info or method call count if relevant; place tests targeting the
Engine class/method that uses merged_query and fetch_limit so the
PaperSearchService path and the paper_id injection loop (p_obj.canonical_id,
p_obj.get_identity) are covered.

In `@src/paperbot/infrastructure/adapters/arxiv_search_adapter.py`:
- Around line 23-34: The search method currently ignores year_from/year_to
because _connector.search doesn't accept them; modify Paperbot's
ArxivSearchAdapter.search to post-filter the records returned by
self._connector.search by checking each record's published date year (use a
helper like _year_in_range(record) that extracts the year from the record's
published ISO date string and validates it against year_from/year_to) and only
convert remaining records with self._to_candidate; ensure the filter handles
missing/invalid published values gracefully (treat as out-of-range or skip) and
preserve max_results by applying the year filter before slicing to max_results.

In `@src/paperbot/infrastructure/stores/identity_store.py`:
- Around line 84-92: The current resolve_any method uses scalar_one_or_none()
which raises MultipleResultsFound when the same external_id exists across
different sources; change the query to fetch the first matching row instead (use
the session.execute(...).scalars().first() pattern) and return int(row.paper_id)
if row else None; optionally add an explicit order_by on
PaperIdentifierModel.source to make which source wins deterministic. Ensure
references to resolve_any and PaperIdentifierModel are updated accordingly.

In `@src/paperbot/infrastructure/stores/models.py`:
- Around line 469-472: The canonical_paper_id column is missing a ForeignKey
constraint, so either add ForeignKey("papers.id") to the mapped_column for
canonical_paper_id (mirroring paper_ref_id) to enforce referential integrity, or
if the omission is intentional during the dual-write migration, annotate the
field with a clear TODO comment (mentioning adding ForeignKey("papers.id") after
backfill and referencing the PAPERBOT_USE_CANONICAL_FK flag) so future readers
know this is deliberate; update the canonical_paper_id Mapped definition
accordingly.

In `@src/paperbot/infrastructure/stores/paper_store.py`:
- Around line 824-884: _get_user_library_canonical currently only sorts by
"saved_at", "citation_count" or default and therefore drops support for "title"
and "year" sorting; update the sorting branch in _get_user_library_canonical to
handle sort_by == "title" (sort by paper.title, using a case-insensitive
fallback like "" for None) and sort_by == "year" (sort by paper.year with a
numeric fallback like 0), honoring sort_order ("asc"/"desc") just like the other
branches; operate on unique_results (tuples of (PaperModel, PaperFeedbackModel))
and use x[0].title / x[0].year and the existing min_ts logic for saved_at so
behavior matches get_user_library.
- Around line 454-461: After session.flush(), avoid re-querying each paper with
_find_existing (extra N round-trips) and the risk that _sync_identifiers()
rollback will undo the whole batch; instead use the ORM model instances you
already have (the items in papers or the objects returned by the upsert/insert
logic) and pass those directly into _sync_identifiers to dual-write identifiers,
and wrap each per-paper identifier sync in a DB savepoint/independent
transaction (e.g., session.begin_nested() or equivalent) or catch and handle
exceptions locally so a failing _sync_identifiers does not rollback the entire
batch. Ensure you remove the call sites to _find_existing in the loop and
reference the existing paper instances when calling _sync_identifiers.
- Around line 238-278: _sync_identifiers currently flushes identifier rows but
never commits, and its broad except does session.rollback() which can undo
unrelated work in upsert_papers_batch; fix by making the identifier write atomic
and not able to rollback the whole outer transaction: inside _sync_identifiers
use a nested transaction/savepoint (session.begin_nested()) when inserting
PaperIdentifierModel rows, flush/commit that nested transaction (or let it
close) and on exception rollback only the nested transaction and re-raise the
error (do not call session.rollback() on the outer session); alternatively, call
session.commit() after the identifier sync when called from single-upsert paths
like upsert_paper, but for batch paths prefer the begin_nested() approach and
remove the unconditional session.rollback() so upsert_papers_batch is not
invalidated.
🧹 Nitpick comments (11)
src/paperbot/application/ports/enrichment_port.py (1)

8-14: Consider whether LLMSummary persistence should also be part of this port.

The domain layer defines both JudgeScore and LLMSummary as enrichment value objects, but this port only covers judge scores. If summary persistence is planned, adding it here now would keep the port aligned with the enrichment domain boundary. Fine to defer if summaries aren't persisted yet.

src/paperbot/application/services/enrichment_pipeline.py (1)

41-54: Pipeline error handling is appropriate but silently swallows failures.

The catch-all Exception is the right pattern here for pipeline resilience. However, run() returns None with no indication of which papers/steps failed. Consider returning a summary (e.g., list of failures) or accumulating errors in EnrichmentContext.extra so callers can act on partial failures.

src/paperbot/infrastructure/adapters/__init__.py (1)

15-22: Use source_name property as registry key to avoid key/name drift.

The hardcoded string keys duplicate each adapter's source_name property. If a source_name changes in the adapter but not here, lookups will silently break.

Proposed fix
 def build_adapter_registry() -> Dict[str, SearchPort]:
-    return {
-        "semantic_scholar": S2SearchAdapter(),
-        "arxiv": ArxivSearchAdapter(),
-        "papers_cool": PapersCoolAdapter(),
-        "hf_daily": HFDailyAdapter(),
-        "openalex": OpenAlexAdapter(),
-    }
+    adapters = [
+        S2SearchAdapter(),
+        ArxivSearchAdapter(),
+        PapersCoolAdapter(),
+        HFDailyAdapter(),
+        OpenAlexAdapter(),
+    ]
+    return {a.source_name: a for a in adapters}

Also note: if any adapter's constructor fails (e.g., missing config), the entire registry build will fail. Consider wrapping each instantiation in a try/except with a warning log so remaining adapters stay available.

scripts/backfill_identifiers.py (3)

35-76: Performance: N+1 queries and full table load — will be slow on large datasets.

backfill_identifiers loads every PaperModel into memory (line 42) and then issues a SELECT per (source, external_id) pair per paper. For a database with, say, 100k papers averaging 2 identifiers each, that's ~200k individual queries in a single transaction.

Consider:

  1. Chunking the paper load with yield_per() or LIMIT/OFFSET.
  2. Pre-loading existing identifiers into a set to avoid per-row lookups.
  3. Using INSERT … ON CONFLICT DO NOTHING for bulk inserts instead of check-then-insert.
Sketch using bulk insert with conflict handling
 def backfill_identifiers(provider: SessionProvider) -> dict:
     """Populate paper_identifiers from papers columns."""
     now = _utcnow()
     created = 0
     skipped = 0
 
     with provider.session() as session:
-        papers = session.execute(select(PaperModel)).scalars().all()
+        # Process in chunks to limit memory usage
+        query = select(PaperModel)
+        papers = session.execute(query).scalars()
         for paper in papers:
             pairs: list[tuple[str, str]] = []
             if paper.semantic_scholar_id:
                 pairs.append(("semantic_scholar", paper.semantic_scholar_id))
             if paper.arxiv_id:
                 pairs.append(("arxiv", paper.arxiv_id))
             if paper.openalex_id:
                 pairs.append(("openalex", paper.openalex_id))
             if paper.doi:
                 pairs.append(("doi", paper.doi))
 
             for source, eid in pairs:
-                existing = session.execute(
-                    select(PaperIdentifierModel).where(
-                        PaperIdentifierModel.source == source,
-                        PaperIdentifierModel.external_id == eid,
-                    )
-                ).scalar_one_or_none()
-                if existing is None:
-                    session.add(
-                        PaperIdentifierModel(
-                            paper_id=paper.id,
-                            source=source,
-                            external_id=eid,
-                            created_at=now,
-                        )
-                    )
-                    created += 1
-                else:
-                    skipped += 1
+                try:
+                    session.add(
+                        PaperIdentifierModel(
+                            paper_id=paper.id,
+                            source=source,
+                            external_id=eid,
+                            created_at=now,
+                        )
+                    )
+                    session.flush()
+                    created += 1
+                except IntegrityError:
+                    session.rollback()
+                    skipped += 1
 
         session.commit()
 
     return {"identifiers_created": created, "identifiers_skipped": skipped}

79-98: Use a single bulk UPDATE instead of loading all rows into Python.

backfill_canonical_paper_id loads all matching rows into memory and updates them one by one in Python. This can be replaced with a single SQL UPDATE statement, which is significantly faster and uses negligible memory.

Bulk UPDATE
 def backfill_canonical_paper_id(provider: SessionProvider) -> dict:
     """Populate paper_feedback.canonical_paper_id from paper_ref_id."""
-    updated = 0
     with provider.session() as session:
-        rows = (
-            session.execute(
-                select(PaperFeedbackModel).where(
-                    PaperFeedbackModel.canonical_paper_id.is_(None),
-                    PaperFeedbackModel.paper_ref_id.is_not(None),
-                )
-            )
-            .scalars()
-            .all()
-        )
-        for row in rows:
-            row.canonical_paper_id = row.paper_ref_id
-            updated += 1
+        from sqlalchemy import update
+        result = session.execute(
+            update(PaperFeedbackModel)
+            .where(
+                PaperFeedbackModel.canonical_paper_id.is_(None),
+                PaperFeedbackModel.paper_ref_id.is_not(None),
+            )
+            .values(canonical_paper_id=PaperFeedbackModel.paper_ref_id)
+        )
+        updated = result.rowcount
         session.commit()
 
     return {"feedback_rows_updated": updated}

21-28: Remove unused noqa directives.

Ruff reports that the # noqa: E402 directives are unnecessary since the E402 rule is not enabled. Remove them for cleanliness.

Fix
-from sqlalchemy import select  # noqa: E402
-from paperbot.infrastructure.stores.models import (  # noqa: E402
+from sqlalchemy import select
+from paperbot.infrastructure.stores.models import (
     Base,
     PaperFeedbackModel,
     PaperIdentifierModel,
     PaperModel,
 )
-from paperbot.infrastructure.stores.sqlalchemy_db import SessionProvider, get_db_url  # noqa: E402
+from paperbot.infrastructure.stores.sqlalchemy_db import SessionProvider, get_db_url
src/paperbot/application/services/identity_resolver.py (1)

24-31: IdentityResolver always creates its own SessionProvider, even when an IdentityStore is injected.

Line 31 creates a new SessionProvider(self._db_url) unconditionally. If the caller already injects an IdentityStore (which has its own engine), this results in two separate DB engines for the same database. Consider reusing the provider from the injected store or accepting a SessionProvider directly.

Reuse the store's provider
     def __init__(
         self,
         identity_store: Optional[IdentityStore] = None,
         db_url: Optional[str] = None,
+        provider: Optional[SessionProvider] = None,
     ):
         self._identity_store = identity_store or IdentityStore(db_url=db_url)
         self._db_url = db_url or get_db_url()
-        self._provider = SessionProvider(self._db_url)
+        self._provider = provider or getattr(self._identity_store, '_provider', None) or SessionProvider(self._db_url)
src/paperbot/domain/paper.py (1)

182-186: Title hash logic is duplicated with HarvestedPaper.compute_title_hash.

_compute_title_hash here is identical to HarvestedPaper.compute_title_hash() in domain/harvest.py. Both normalize the same way and produce SHA256 hashes. Consider extracting a shared utility to avoid drift if one is updated without the other.

src/paperbot/infrastructure/adapters/paperscool_adapter.py (1)

23-38: year_from / year_to are silently ignored — consistent with other adapters, but worth documenting.

The papers.cool connector doesn't support year-range filtering, so these parameters are present only for SearchPort conformance. This matches the pattern in ArxivSearchAdapter. A brief inline comment would prevent future confusion.

Add a brief note
     async def search(
         self,
         query: str,
         *,
         max_results: int = 30,
-        year_from: Optional[int] = None,
-        year_to: Optional[int] = None,
+        year_from: Optional[int] = None,  # Not supported by papers.cool
+        year_to: Optional[int] = None,    # Not supported by papers.cool
     ) -> List[PaperCandidate]:
src/paperbot/application/services/paper_search_service.py (1)

128-133: Silently swallowing exceptions in close() hinders debugging.

Per static analysis (S110), try-except-pass loses all diagnostic information.

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", adapter.source_name, exc_info=True)
src/paperbot/infrastructure/stores/identity_store.py (1)

107-111: Consider logging in close() instead of silently passing.

Same pattern as other stores — a logger.debug(...) call would help diagnose teardown issues.

Comment on lines +97 to +100
def downgrade() -> None:
op.drop_index("ix_paper_feedback_canonical_paper_id", table_name="paper_feedback")
op.drop_column("paper_feedback", "canonical_paper_id")
op.drop_table("paper_identifiers")

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

Downgrade lacks idempotency guards — minor risk for partial rollback scenarios.

If the downgrade fails midway (e.g., after dropping the index but before dropping the column), re-running it will fail on drop_index. Since the upgrade is carefully guarded, consider mirroring that pattern in the downgrade, or at least noting this is by design.

This is low-priority since downgrades are rarely re-run in practice.

Suggested defensive downgrade
 def downgrade() -> None:
-    op.drop_index("ix_paper_feedback_canonical_paper_id", table_name="paper_feedback")
-    op.drop_column("paper_feedback", "canonical_paper_id")
-    op.drop_table("paper_identifiers")
+    if not _is_offline():
+        if "ix_paper_feedback_canonical_paper_id" in _get_indexes("paper_feedback"):
+            op.drop_index("ix_paper_feedback_canonical_paper_id", table_name="paper_feedback")
+        if "canonical_paper_id" in _get_columns("paper_feedback"):
+            op.drop_column("paper_feedback", "canonical_paper_id")
+        if _has_table("paper_identifiers"):
+            op.drop_table("paper_identifiers")
+    else:
+        op.drop_index("ix_paper_feedback_canonical_paper_id", table_name="paper_feedback")
+        op.drop_column("paper_feedback", "canonical_paper_id")
+        op.drop_table("paper_identifiers")
🤖 Prompt for AI Agents
In `@alembic/versions/0009_paper_identifiers.py` around lines 97 - 100, The
downgrade() currently drops an index, a column, and a table without existence
checks which makes re-running a partially-applied downgrade fail; update
downgrade() to be idempotent by guarding each operation (the
op.drop_index("ix_paper_feedback_canonical_paper_id", ...),
op.drop_column("paper_feedback", "canonical_paper_id"), and
op.drop_table("paper_identifiers")) — either query the DB via the migration
bind/Inspector to check for the index/column/table before calling each op.* or
wrap each call in a narrow try/except that ignores "not found" errors so the
downgrade can be safely re-run.

Comment on lines +102 to +114
# 3. URL match
if url_candidates:
row = session.execute(
select(PaperModel).where(
or_(
PaperModel.url.in_(url_candidates),
PaperModel.external_url.in_(url_candidates),
PaperModel.pdf_url.in_(url_candidates),
)
)
).first()
if row:
return int(row[0].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.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find and examine PaperModel definition
find . -name "models.py" -type f | grep -E "(models|model)" | head -20

Repository: jerry609/PaperBot

Length of output: 157


🏁 Script executed:

# Search for PaperModel class definition and external_url references
rg -n "class PaperModel" --type=py

Repository: jerry609/PaperBot

Length of output: 133


🏁 Script executed:

# Check for external_url in the codebase
rg -n "external_url" --type=py -B 2 -A 2

Repository: jerry609/PaperBot

Length of output: 20000


🏁 Script executed:

# Read PaperModel class definition
sed -n '693,750p' src/paperbot/infrastructure/stores/models.py

Repository: jerry609/PaperBot

Length of output: 2686


🏁 Script executed:

# Search for external_url field definition in PaperModel
rg -n "external_url" src/paperbot/infrastructure/stores/models.py -B 2 -A 2

Repository: jerry609/PaperBot

Length of output: 43


🏁 Script executed:

# Check if there's a migration that drops the external_url column
rg -n "external_url" alembic/versions/ -A 2 -B 2

Repository: jerry609/PaperBot

Length of output: 734


🏁 Script executed:

# Check for all migrations in order
ls -la alembic/versions/ | grep ".py"

Repository: jerry609/PaperBot

Length of output: 754


🏁 Script executed:

# Check the latest migration files for any drops or alterations to external_url
for file in alembic/versions/000[4-9]*.py; do
  echo "=== $file ==="
  rg -n "external_url\|drop_column\|alter" "$file" -B 1 -A 1 || echo "No matches"
done

Repository: jerry609/PaperBot

Length of output: 446


🏁 Script executed:

# Look at the migration 0007 which mentions "paper_harvest_tables"
cat alembic/versions/0007_paper_harvest_tables.py

Repository: jerry609/PaperBot

Length of output: 5822


🏁 Script executed:

# Verify there's no external_url field in the current models.py PaperModel
grep -n "external_url" src/paperbot/infrastructure/stores/models.py

Repository: jerry609/PaperBot

Length of output: 43


Remove reference to non-existent PaperModel.external_url column.

Line 108 references PaperModel.external_url, but the current PaperModel schema only has url and pdf_url columns. The external_url column was removed from the database schema in migration 0007. This will raise an AttributeError at runtime whenever url_candidates is non-empty.

🐛 Fix: remove the non-existent column reference
             # 3. URL match
             if url_candidates:
                 row = session.execute(
                     select(PaperModel).where(
                         or_(
                             PaperModel.url.in_(url_candidates),
-                            PaperModel.external_url.in_(url_candidates),
                             PaperModel.pdf_url.in_(url_candidates),
                         )
                     )
                 ).first()
                 if row:
                     return int(row[0].id)
🤖 Prompt for AI Agents
In `@src/paperbot/application/services/identity_resolver.py` around lines 102 -
114, The OR condition in identity resolution references a removed column
PaperModel.external_url causing AttributeError; update the URL match in the
function that queries PaperModel (the block using
session.execute(select(PaperModel).where(or_( ... ))).first()) to remove the
PaperModel.external_url operand and only check PaperModel.url and
PaperModel.pdf_url (i.e., drop the external_url reference from the or_ call) so
the select uses existing columns only.

Comment on lines +100 to +117
# 3. Persist if requested
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],
)
paper.canonical_id = result_dict.get("id")
except Exception as e:
logger.warning(f"Failed to persist paper '{paper.title[:50]}': {e}")

return SearchResult(
papers=unique[:max_results],
provenance=provenance,
total_raw=total_raw,
duplicates_removed=duplicates_removed,
)

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 | 🟠 Major

Synchronous upsert_paper blocks the event loop.

self._registry.upsert_paper(...) (backed by PaperStore) is a synchronous DB call executed inside an async method without asyncio.to_thread(). When persisting many papers, this blocks the event loop for the duration of all writes.

Proposed fix — offload to thread
         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],
+                    result_dict = await asyncio.to_thread(
+                        self._registry.upsert_paper,
+                        paper=paper.to_dict(),
+                        source_hint=provenance.get(paper.title_hash, ["unknown"])[0],
                     )
                     paper.canonical_id = result_dict.get("id")
                 except Exception as e:
                     logger.warning(f"Failed to persist paper '{paper.title[:50]}': {e}")
🧰 Tools
🪛 Ruff (0.15.0)

[warning] 109-109: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In `@src/paperbot/application/services/paper_search_service.py` around lines 100 -
117, The synchronous call to self._registry.upsert_paper inside the async method
blocks the event loop; change the persistence call to run in a thread by
awaiting asyncio.to_thread(self._registry.upsert_paper, paper=paper.to_dict(),
source_hint=provenance.get(paper.title_hash, ["unknown"])[0]) and assign
paper.canonical_id from the returned dict, keeping the existing exception
handling but catching exceptions from the awaited to_thread call; update imports
to include asyncio if needed and ensure this change is applied where
upsert_paper is called in the same async method that returns SearchResult
(references: self._registry.upsert_paper, paper.to_dict(), provenance,
paper.canonical_id, SearchResult).

Comment on lines +522 to +545

# Prefer PaperSearchService if available
if self.search_service is not None:
Logger.info(
f"Using PaperSearchService for query='{merged_query}'",
file=LogFiles.HARVEST,
)
search_result = await self.search_service.search(
merged_query,
sources=["semantic_scholar"],
max_results=fetch_limit,
persist=True,
)
raw = [p.to_dict() for p in search_result.papers]
# Inject paper_id from canonical_id or first identity
for p_dict, p_obj in zip(raw, search_result.papers):
pid = str(p_obj.canonical_id or "")
if not pid:
pid = p_obj.get_identity("semantic_scholar") or ""
p_dict["paper_id"] = pid
Logger.info(
f"PaperSearchService returned {len(raw)} papers",
file=LogFiles.HARVEST,
)

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's read the actual file at the specified lines
head -545 src/paperbot/context_engine/engine.py | tail -30

Repository: jerry609/PaperBot

Length of output: 1496


🏁 Script executed:

# Search for test files that might cover search_service integration
rg -n "search_service" --type=py -g '*test*' -A 3 -B 3

Repository: jerry609/PaperBot

Length of output: 43


🏁 Script executed:

# Check for TODO comments around the search_service code
rg -n "TODO.*search|search.*TODO|sources.*semantic_scholar" src/paperbot/context_engine/engine.py

Repository: jerry609/PaperBot

Length of output: 118


🏁 Script executed:

# Check if there are any config files or parameters for sources
rg -n "sources.*=" --type=py src/paperbot/context_engine/engine.py -B 2 -A 2

Repository: jerry609/PaperBot

Length of output: 329


🏁 Script executed:

# Find test files related to ContextEngine
fd -e py -path '*test*' | xargs grep -l "ContextEngine\|context_engine" 2>/dev/null

Repository: jerry609/PaperBot

Length of output: 231


🏁 Script executed:

# Check if there are any tests in the repository at all for this module
find . -name '*test*.py' -o -name 'test_*.py' | head -20

Repository: jerry609/PaperBot

Length of output: 934


🏁 Script executed:

# Search for the search_service attribute definition to understand its nature
rg -n "self.search_service\s*=" --type=py src/paperbot/context_engine/engine.py

Repository: jerry609/PaperBot

Length of output: 109


🏁 Script executed:

# Check if there's a broader pattern of how sources are handled in search
rg -n "sources\s*=" --type=py src/paperbot/ -B 2 -A 2 | head -50

Repository: jerry609/PaperBot

Length of output: 3693


🏁 Script executed:

# Check test_harvest_pipeline.py for search_service coverage
rg -n "search_service\|PaperSearchService" tests/integration/test_harvest_pipeline.py -B 2 -A 2

Repository: jerry609/PaperBot

Length of output: 43


🏁 Script executed:

# Search for test files that might mock or test ContextEngine.search method
rg -n "ContextEngine\|search_service" tests/ --type py -l

Repository: jerry609/PaperBot

Length of output: 43


🏁 Script executed:

# Check if the search_service integration is new (look for comments or recent changes)
rg -n "Prefer PaperSearchService\|search_service is not None" src/paperbot/context_engine/engine.py -B 5 -A 5

Repository: jerry609/PaperBot

Length of output: 43


🏁 Script executed:

# Get more context around the search_service conditional to understand the fallback
sed -n '515,560p' src/paperbot/context_engine/engine.py

Repository: jerry609/PaperBot

Length of output: 2351


🏁 Script executed:

# Check for SemanticScholarSearch mentioned in the review
rg -n "SemanticScholarSearch" src/paperbot/context_engine/engine.py -B 3 -A 3

Repository: jerry609/PaperBot

Length of output: 723


🏁 Script executed:

# Look for all search-related method calls in the engine to understand the flow
rg -n "\.search\(" src/paperbot/context_engine/engine.py -B 2 -A 2

Repository: jerry609/PaperBot

Length of output: 311


Add test coverage for the new search_service code path.

The hardcoded sources=["semantic_scholar"] at line 531 is confirmed, which appears intentional for a safe rollout. However, per coding guidelines, behavior changes in src/**/*.py must include test coverage. The new search_service integration path has no corresponding tests. Add tests to cover the search_service flow, including the paper_id injection logic (lines 535-540).

As an optional enhancement, consider adding a configuration option to enable additional sources beyond semantic_scholar for future multi-source fan-out.

🤖 Prompt for AI Agents
In `@src/paperbot/context_engine/engine.py` around lines 522 - 545, Add unit tests
covering the new search_service path: mock an instance of search_service (and
its search method) so when Engine.search flow hits the branch where
search_service is not None it calls search_service.search(merged_query,
sources=["semantic_scholar"], max_results=fetch_limit, persist=True); return a
mock search_result with a .papers list of objects that expose .to_dict(),
.canonical_id and .get_identity("semantic_scholar") to exercise both
canonical_id and fallback identity branches, then assert the code injects
paper_id into each returned dict (verifying values produced from
p_obj.canonical_id and p_obj.get_identity), and assert Logger/info or method
call count if relevant; place tests targeting the Engine class/method that uses
merged_query and fetch_limit so the PaperSearchService path and the paper_id
injection loop (p_obj.canonical_id, p_obj.get_identity) are covered.

Comment on lines +23 to +34
async def search(
self,
query: str,
*,
max_results: int = 30,
year_from: Optional[int] = None,
year_to: Optional[int] = None,
) -> List[PaperCandidate]:
records = await asyncio.to_thread(
self._connector.search, query=query, max_results=max_results
)
return [self._to_candidate(r) for r in records]

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_from and year_to are silently ignored — results may include out-of-range papers.

The SearchPort protocol exposes year_from/year_to, and PaperSearchService forwards them, but ArxivConnector.search doesn't accept these parameters, so they're dropped. Callers relying on year filtering will get unfiltered results from this adapter.

Consider adding client-side post-filtering on the published date:

Proposed fix
     async def search(
         self,
         query: str,
         *,
         max_results: int = 30,
         year_from: Optional[int] = None,
         year_to: Optional[int] = None,
     ) -> List[PaperCandidate]:
         records = await asyncio.to_thread(
             self._connector.search, query=query, max_results=max_results
         )
-        return [self._to_candidate(r) for r in records]
+        candidates = [self._to_candidate(r) for r in records]
+        if year_from is not None or year_to is not None:
+            candidates = [
+                c for c in candidates
+                if _year_in_range(c.publication_date, year_from, year_to)
+            ]
+        return candidates

Where _year_in_range extracts the year from the ISO date string and checks bounds.

🧰 Tools
🪛 Ruff (0.15.0)

[warning] 28-28: Unused method argument: year_from

(ARG002)


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

(ARG002)

🤖 Prompt for AI Agents
In `@src/paperbot/infrastructure/adapters/arxiv_search_adapter.py` around lines 23
- 34, The search method currently ignores year_from/year_to because
_connector.search doesn't accept them; modify Paperbot's
ArxivSearchAdapter.search to post-filter the records returned by
self._connector.search by checking each record's published date year (use a
helper like _year_in_range(record) that extracts the year from the record's
published ISO date string and validates it against year_from/year_to) and only
convert remaining records with self._to_candidate; ensure the filter handles
missing/invalid published values gracefully (treat as out-of-range or skip) and
preserve max_results by applying the year filter before slicing to max_results.

Comment on lines +84 to +92
def resolve_any(self, external_id: str) -> Optional[int]:
"""Resolve an external_id across all sources."""
with self._provider.session() as session:
row = session.execute(
select(PaperIdentifierModel).where(
PaperIdentifierModel.external_id == external_id,
)
).scalar_one_or_none()
return int(row.paper_id) if row else None

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 | 🔴 Critical

resolve_any will raise MultipleResultsFound if external_id is not unique across sources.

The unique constraint is on (source, external_id), not external_id alone. If the same external_id string exists for different sources, scalar_one_or_none() throws sqlalchemy.exc.MultipleResultsFound.

Proposed fix
     def resolve_any(self, external_id: str) -> Optional[int]:
         """Resolve an external_id across all sources."""
         with self._provider.session() as session:
             row = session.execute(
                 select(PaperIdentifierModel).where(
                     PaperIdentifierModel.external_id == external_id,
                 )
-            ).scalar_one_or_none()
+            ).scalars().first()
             return int(row.paper_id) if row else None
📝 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
def resolve_any(self, external_id: str) -> Optional[int]:
"""Resolve an external_id across all sources."""
with self._provider.session() as session:
row = session.execute(
select(PaperIdentifierModel).where(
PaperIdentifierModel.external_id == external_id,
)
).scalar_one_or_none()
return int(row.paper_id) if row else None
def resolve_any(self, external_id: str) -> Optional[int]:
"""Resolve an external_id across all sources."""
with self._provider.session() as session:
row = session.execute(
select(PaperIdentifierModel).where(
PaperIdentifierModel.external_id == external_id,
)
).scalars().first()
return int(row.paper_id) if row else None
🤖 Prompt for AI Agents
In `@src/paperbot/infrastructure/stores/identity_store.py` around lines 84 - 92,
The current resolve_any method uses scalar_one_or_none() which raises
MultipleResultsFound when the same external_id exists across different sources;
change the query to fetch the first matching row instead (use the
session.execute(...).scalars().first() pattern) and return int(row.paper_id) if
row else None; optionally add an explicit order_by on
PaperIdentifierModel.source to make which source wins deterministic. Ensure
references to resolve_any and PaperIdentifierModel are updated accordingly.

Comment on lines +469 to +472
# Canonical FK (dual-write migration — will replace paper_id + paper_ref_id)
canonical_paper_id: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, index=True
)

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 | 🟠 Major

canonical_paper_id lacks a ForeignKey constraint.

Unlike paper_ref_id (Line 464 which has ForeignKey("papers.id")), canonical_paper_id has no FK constraint. This means the DB won't enforce referential integrity — stale or invalid IDs can accumulate silently.

If the omission is intentional for the migration phase (to allow writing IDs before the referenced paper exists), consider adding the FK once the backfill is verified and the flag is enabled. A TODO comment documenting this intent would help.

Suggested improvement
     # Canonical FK (dual-write migration — will replace paper_id + paper_ref_id)
     canonical_paper_id: Mapped[Optional[int]] = mapped_column(
-        Integer, nullable=True, index=True
+        Integer, ForeignKey("papers.id"), nullable=True, index=True
     )

Or, if intentionally deferred:

     # Canonical FK (dual-write migration — will replace paper_id + paper_ref_id)
     # TODO: Add ForeignKey("papers.id") after backfill is verified and PAPERBOT_USE_CANONICAL_FK is stable
     canonical_paper_id: Mapped[Optional[int]] = mapped_column(
         Integer, nullable=True, index=True
     )
🤖 Prompt for AI Agents
In `@src/paperbot/infrastructure/stores/models.py` around lines 469 - 472, The
canonical_paper_id column is missing a ForeignKey constraint, so either add
ForeignKey("papers.id") to the mapped_column for canonical_paper_id (mirroring
paper_ref_id) to enforce referential integrity, or if the omission is
intentional during the dual-write migration, annotate the field with a clear
TODO comment (mentioning adding ForeignKey("papers.id") after backfill and
referencing the PAPERBOT_USE_CANONICAL_FK flag) so future readers know this is
deliberate; update the canonical_paper_id Mapped definition accordingly.

Comment on lines +238 to +278

# Dual-write: also populate paper_identifiers
self._sync_identifiers(session, row)

return payload

@staticmethod
def _sync_identifiers(session, row: PaperModel) -> None:
"""Write known external IDs to paper_identifiers (idempotent)."""
pairs: list[tuple[str, str]] = []
if row.semantic_scholar_id:
pairs.append(("semantic_scholar", row.semantic_scholar_id))
if row.arxiv_id:
pairs.append(("arxiv", row.arxiv_id))
if row.openalex_id:
pairs.append(("openalex", row.openalex_id))
if row.doi:
pairs.append(("doi", row.doi))

for source, eid in pairs:
existing = session.execute(
select(PaperIdentifierModel).where(
PaperIdentifierModel.source == source,
PaperIdentifierModel.external_id == eid,
)
).scalar_one_or_none()
if existing is None:
session.add(
PaperIdentifierModel(
paper_id=row.id,
source=source,
external_id=eid,
created_at=_utcnow(),
)
)
elif existing.paper_id != row.id:
existing.paper_id = row.id
try:
session.flush()
except Exception:
session.rollback()

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 | 🔴 Critical

_sync_identifiers flushes but never commits — identifier rows may be silently lost.

In upsert_paper, session.commit() runs at Line 233. After that, _sync_identifiers (Line 240) adds rows and calls session.flush() (Line 276). These flushed rows live in a new implicit transaction. When the with block exits at return payload (Line 242), if the session context manager doesn't auto-commit, this transaction is rolled back and the identifiers are lost.

Additionally, the except Exception: session.rollback() at Lines 277-278 is dangerous in the upsert_papers_batch path (Line 460), where multiple papers share a single session — a rollback here undoes the entire batch's flushed state.

Proposed fix — commit after sync, and isolate rollback in batch
             # Dual-write: also populate paper_identifiers
             self._sync_identifiers(session, row)
+            session.commit()

             return payload

     `@staticmethod`
     def _sync_identifiers(session, row: PaperModel) -> None:
         """Write known external IDs to paper_identifiers (idempotent)."""
         pairs: list[tuple[str, str]] = []
         if row.semantic_scholar_id:
             pairs.append(("semantic_scholar", row.semantic_scholar_id))
         if row.arxiv_id:
             pairs.append(("arxiv", row.arxiv_id))
         if row.openalex_id:
             pairs.append(("openalex", row.openalex_id))
         if row.doi:
             pairs.append(("doi", row.doi))

         for source, eid in pairs:
             existing = session.execute(
                 select(PaperIdentifierModel).where(
                     PaperIdentifierModel.source == source,
                     PaperIdentifierModel.external_id == eid,
                 )
             ).scalar_one_or_none()
             if existing is None:
                 session.add(
                     PaperIdentifierModel(
                         paper_id=row.id,
                         source=source,
                         external_id=eid,
                         created_at=_utcnow(),
                     )
                 )
             elif existing.paper_id != row.id:
                 existing.paper_id = row.id
         try:
             session.flush()
-        except Exception:
-            session.rollback()
+        except Exception as exc:
+            logger.warning("Failed to sync identifiers for paper %s: %s", row.id, exc)
+            session.rollback()
🧰 Tools
🪛 Ruff (0.15.0)

[warning] 277-277: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
In `@src/paperbot/infrastructure/stores/paper_store.py` around lines 238 - 278,
_sync_identifiers currently flushes identifier rows but never commits, and its
broad except does session.rollback() which can undo unrelated work in
upsert_papers_batch; fix by making the identifier write atomic and not able to
rollback the whole outer transaction: inside _sync_identifiers use a nested
transaction/savepoint (session.begin_nested()) when inserting
PaperIdentifierModel rows, flush/commit that nested transaction (or let it
close) and on exception rollback only the nested transaction and re-raise the
error (do not call session.rollback() on the outer session); alternatively, call
session.commit() after the identifier sync when called from single-upsert paths
like upsert_paper, but for batch paths prefer the begin_nested() approach and
remove the unconditional session.rollback() so upsert_papers_batch is not
invalidated.

Comment on lines +454 to +461
session.flush()

# Dual-write identifiers for all papers in this batch
for paper in papers:
existing = self._find_existing(session, paper)
if existing:
self._sync_identifiers(session, existing)

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 | 🟠 Major

Redundant _find_existing queries and rollback risk in batch dual-write.

After session.flush() at Line 454, each paper is re-queried via _find_existing (Line 458) to locate the row for _sync_identifiers. This is an extra N DB round-trips when the models are already in the session's identity map.

More critically, if _sync_identifiers triggers a rollback (Line 278), it undoes the entire batch's flushed state — including all paper inserts/updates from lines 437-451.

Consider tracking the models during the loop and passing them directly:

Proposed fix sketch
+            touched: list[PaperModel] = []
             for paper in papers:
                 ...
                 if existing:
                     self._update_paper(existing, paper, now)
+                    touched.append(existing)
                     updated_count += 1
                 else:
                     model = self._create_model(paper, now)
                     session.add(model)
+                    touched.append(model)
                     new_count += 1

             session.flush()

-            for paper in papers:
-                existing = self._find_existing(session, paper)
-                if existing:
-                    self._sync_identifiers(session, existing)
+            for model in touched:
+                self._sync_identifiers(session, model)

             session.commit()
🤖 Prompt for AI Agents
In `@src/paperbot/infrastructure/stores/paper_store.py` around lines 454 - 461,
After session.flush(), avoid re-querying each paper with _find_existing (extra N
round-trips) and the risk that _sync_identifiers() rollback will undo the whole
batch; instead use the ORM model instances you already have (the items in papers
or the objects returned by the upsert/insert logic) and pass those directly into
_sync_identifiers to dual-write identifiers, and wrap each per-paper identifier
sync in a DB savepoint/independent transaction (e.g., session.begin_nested() or
equivalent) or catch and handle exceptions locally so a failing
_sync_identifiers does not rollback the entire batch. Ensure you remove the call
sites to _find_existing in the loop and reference the existing paper instances
when calling _sync_identifiers.

Comment on lines +824 to +884
def _get_user_library_canonical(
self,
user_id: str,
*,
session_provider,
track_id: Optional[int] = None,
actions: List[str],
sort_by: str,
sort_order: str,
limit: int,
offset: int,
) -> Tuple[List[LibraryPaper], int]:
"""Single FK JOIN path using paper_feedback.canonical_paper_id."""
with session_provider.session() as session:
stmt = (
select(PaperModel, PaperFeedbackModel)
.join(
PaperFeedbackModel,
PaperModel.id == PaperFeedbackModel.canonical_paper_id,
)
.where(
PaperFeedbackModel.user_id == user_id,
PaperFeedbackModel.action.in_(actions),
PaperFeedbackModel.canonical_paper_id.is_not(None),
PaperModel.deleted_at.is_(None),
)
)
if track_id is not None:
stmt = stmt.where(PaperFeedbackModel.track_id == track_id)

all_results = session.execute(stmt).all()

paper_map: Dict[int, Tuple[PaperModel, PaperFeedbackModel]] = {}
for row in all_results:
paper, feedback = row[0], row[1]
if paper.id not in paper_map or feedback.ts > paper_map[paper.id][1].ts:
paper_map[paper.id] = (paper, feedback)

unique_results = list(paper_map.values())
min_ts = datetime.min.replace(tzinfo=timezone.utc)
if sort_by == "saved_at":
unique_results.sort(
key=lambda x: x[1].ts or min_ts, reverse=(sort_order.lower() == "desc")
)
elif sort_by == "citation_count":
unique_results.sort(
key=lambda x: x[0].citation_count or 0, reverse=(sort_order.lower() == "desc")
)
else:
unique_results.sort(
key=lambda x: x[1].ts or min_ts, reverse=(sort_order.lower() == "desc")
)

total = len(unique_results)
paginated = unique_results[offset : offset + limit]
return [
LibraryPaper(
paper=row[0], saved_at=row[1].ts, track_id=row[1].track_id, action=row[1].action
)
for row in paginated
], total

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

_get_user_library_canonical is missing "title" and "year" sort options.

The non-canonical get_user_library (Lines 791-802) supports sorting by "title" and "year", but _get_user_library_canonical only handles "saved_at", "citation_count", and a default fallback. Users toggling PAPERBOT_USE_CANONICAL_FK=true will silently lose sort-by-title and sort-by-year behavior.

Proposed fix
             if sort_by == "saved_at":
                 unique_results.sort(
                     key=lambda x: x[1].ts or min_ts, reverse=(sort_order.lower() == "desc")
                 )
+            elif sort_by == "title":
+                unique_results.sort(
+                    key=lambda x: x[0].title or "", reverse=(sort_order.lower() == "desc")
+                )
+            elif sort_by == "year":
+                unique_results.sort(
+                    key=lambda x: x[0].year or 0, reverse=(sort_order.lower() == "desc")
+                )
             elif sort_by == "citation_count":
                 unique_results.sort(
                     key=lambda x: x[0].citation_count or 0, reverse=(sort_order.lower() == "desc")
                 )
             else:
🤖 Prompt for AI Agents
In `@src/paperbot/infrastructure/stores/paper_store.py` around lines 824 - 884,
_get_user_library_canonical currently only sorts by "saved_at", "citation_count"
or default and therefore drops support for "title" and "year" sorting; update
the sorting branch in _get_user_library_canonical to handle sort_by == "title"
(sort by paper.title, using a case-insensitive fallback like "" for None) and
sort_by == "year" (sort by paper.year with a numeric fallback like 0), honoring
sort_order ("asc"/"desc") just like the other branches; operate on
unique_results (tuples of (PaperModel, PaperFeedbackModel)) and use x[0].title /
x[0].year and the existing min_ts logic for saved_at so behavior matches
get_user_library.

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.

2 participants