From 9573a792008508512c7d7211361d50d6393e9425 Mon Sep 17 00:00:00 2001 From: wdwd200 <1491135880@qq.com> Date: Sun, 23 Aug 2026 20:43:12 +0800 Subject: [PATCH] feat: add OA full-text discovery for paper search --- .ai-team/TASK.md | 86 +++++---- src/autoresearch/application.py | 24 +++ src/autoresearch/config.py | 5 + src/autoresearch/contracts.py | 18 ++ src/autoresearch/open_access_service.py | 200 ++++++++++++++++++++ src/autoresearch/search_service.py | 6 + tests/test_open_access_service.py | 239 ++++++++++++++++++++++++ 7 files changed, 546 insertions(+), 32 deletions(-) create mode 100644 src/autoresearch/open_access_service.py create mode 100644 tests/test_open_access_service.py diff --git a/.ai-team/TASK.md b/.ai-team/TASK.md index 8982ec2..32c416b 100644 --- a/.ai-team/TASK.md +++ b/.ai-team/TASK.md @@ -1,61 +1,83 @@ # Current Task -- ID: `AR-003` -- Title: `Simplify the assignment plan to module level` -- Status: `done` -- Owner: `zzg` -- Next owner: `user/team` +- ID: `AR-004` +- Title: `Add OA full-text discovery for M05` +- Status: `active` +- Owner: `wdwd200` +- Next owner: `maintainer/reviewer` ## Goal -根据用户反馈,把任务拆解入口从 122 个子任务收敛为模块级分配。保留此前详细版作为附录,但当前团队先按模块认领负责人、边界、主要产出、依赖和验收目标。 +在现有 M05 论文搜索链路中,为去重后的论文统一增加 Open Access(OA)全文发现能力。优先通过 Unpaywall 查询合法开放全文,未命中时使用 OpenAlex 作为 fallback;不绕过付费墙,不负责全文解析。 ## Acceptance scenarios -- [x] `docs/AutoResearch_任务拆解书.md` 只作为模块级分配入口,覆盖 M00–M17。 -- [x] 每个模块说明目标、边界、主要交付物、前置模块、负责人建议和当前状态。 -- [x] 文档提供模块负责人验收模板和第一批模块分配建议。 -- [x] 原 122 子任务版本保留为 `docs/AutoResearch_任务拆解书_详细版.md`,不丢失后续拆解素材。 -- [x] README、Project-to-Act 和 VibeCollab 任务状态保持一致,不改变五业务 Agent 和证据门禁边界。 +- [x] 对每篇去重后的 `PaperRecord` 执行统一 OA 查询,而不是只处理某个特定搜索源返回的论文。 +- [x] 新增独立的 OA 结果结构,不修改 `PaperRecord`;每篇论文只保留一个最新 OA 状态,并使用 `paper_id` 作为 OA 记录的唯一 `record_id`。 +- [x] Unpaywall 命中合法开放全文时,记录 `open_access`、合法全文 URL、可用的 license 和最终来源。 +- [x] Unpaywall 未命中时,使用 OpenAlex fallback;fallback 命中时同样返回统一 OA 结果。 +- [x] 已完成查询但未发现开放全文时返回 `no_open_fulltext_found`;无法可靠完成查询时返回 `unknown`,不得把“查询失败”误判为“无 OA”。 +- [x] OA 查询过程记录 `checked_sources` 和基础 diagnostics,便于排查外部服务失败。 +- [x] OA 记录使用现有 `RecordStore` 持久化;同一 `paper_id` 再次检查时更新最新状态,不产生第二条当前 OA 记录。 +- [x] 至少覆盖:Unpaywall 命中、OpenAlex fallback 命中、均未命中、外部服务失败、同一论文重复检查更新这 5 个离线测试场景。 ## Invariants -- 当前入口不要求团队一次性拆到函数、接口或工单;模块负责人后续再自行细化。 -- 详细版是参考材料,不代表所有子任务都已实现或已验收。 -- 真实论文试点、生产数据库、Web UI、灾备和性能仍保持原有“外部阻塞/待开发”状态。 -- 不提交 token、密钥、私有来源、完整论文、原始提示或虚构外部结果。 +- 不绕过付费墙、登录、订阅或访问控制。 +- OA 发现只负责“是否存在合法开放全文及其入口”,不负责 PDF 下载、全文解析、ReadingCard 或 Reader 侧逻辑。 +- 不把 `unknown` 当作 `no_open_fulltext_found`。 +- 不虚构 OA 状态、license、全文 URL、撤稿或更正信息。 +- 保持现有五业务 Agent 边界;OA 检查是 M05 内的确定性 service/resolver,不新增业务 Agent。 +- 本 PR 不引入 MCP;可参考开源 MCP/Skill 的实现思路,但不改变现有 `PaperSearchService` / connector 架构。 +- 不提交 token、密钥、私有来源、完整论文、原始提示或原始工具输出。 ## Decisions -- 使用 M00–M17 作为稳定模块 ID,先按模块分配负责人。 -- 保留详细拆解版,避免未来重新整理时丢失依赖和验收素材。 -- `.project-to-act` 继续作为长期项目事实源;`.ai-team/TASK.md` 只记录本次短周期交付。 +- OA enrichment 放在多源检索结果汇总、去重之后,确保 OpenAlex、Crossref、Semantic Scholar 等来源的最终论文都走同一套 OA 处理。 +- 使用独立 `OpenAccessRecord`(名称可在实现时按项目命名规范微调),不向公共 `PaperRecord` 追加 OA 字段。 +- 每篇论文只维护一个最新 OA 状态;OA 记录直接以 `paper_id` 作为 `RecordStore` 的 `record_id`。 +- 第一版状态收敛为: + - `open_access` + - `no_open_fulltext_found` + - `unknown` +- 第一版优先查询 Unpaywall,未命中再查询 OpenAlex。 +- `license` 仅记录外部来源明确返回的值;缺失时保持 `None`。 +- 撤稿、更正、复杂许可策略、严格检索质量评测不纳入本 PR。 ## Completed -- 将原 `docs/AutoResearch_任务拆解书.md` 移为 `docs/AutoResearch_任务拆解书_详细版.md`。 -- 新建模块级 `docs/AutoResearch_任务拆解书.md`,包含 18 个模块、执行阶段、负责人模板和首批分配建议。 -- 保留 README 任务书入口,并将 AR-003 的状态、范围和下一步写入协作账本。 +- 已完成 M05 现状审计,确认当前已有 OpenAlex、Crossref、Semantic Scholar 基础连接器、基础 DOI/标题去重、持久化和 Agent handoff。 +- 已确认当前代码没有统一 OA/full-text/license 查询流程。 +- 已确认 `PaperRecord` 只有 `full_text_path`,没有 OA 状态、合法全文 URL 或 license 字段。 +- 已确认 `RecordStore.put()` 支持通过新的 `kind` 保存 OA 记录,无需为本任务新增数据库表。 +- 已完成第一版 OA 数据模型、状态语义、查询顺序和最小测试范围设计。 +- 已实现 `OpenAccessProvider` 协议、顺序 `OpenAccessResolver` 和 `paper_open_access` 记录 upsert。 +- 已实现可选邮箱配置的 `UnpaywallProvider` 和 `OpenAlexOAProvider` fallback;未配置网络时不启用 OA enrichment。 +- 已将 OA enrichment 接入 `PaperSearchService`:去重后先持久化 `PaperRecord`,再调用 resolver。 +- 已新增 fake provider 离线测试和 PaperSearchService 集成测试,覆盖命中、fallback 命中、均未命中、服务失败、重复检查更新、去重持久化顺序和未配置 resolver。 ## Pending -- 用户/团队需要为模块负责人模板填写实际 owner、分支、目标日期和 evidence ID。 -- 模块负责人确认后,再选择需要展开的模块,不默认展开全部模块。 -- 真实论文试点仍等待 idea、领域、目标 venue、授权全文和实验资源边界。 +- 等待 maintainer/reviewer review 和 merge。 +- 根据 review 结果决定是否另行拆分撤稿/更正、筛选/排序或更多检索源 PR。 ## Next step -先认领 M01–M04、M05–M07、M08、M09–M10、M11–M12、M13–M15/M17、M16 这 7 个模块包;完成模块边界确认后,再只对当前瓶颈模块做子任务拆解。 +等待 maintainer/reviewer review 和 merge;本任务不扩展到 MCP、撤稿/更正、排序或 Reader 功能。 ## Verification -- [x] Project-to-Act `--check` — managed 配置有效,唯一账本保持不变。 -- [x] 模块文档结构检查 — M00–M17 全部存在,详细版文件可访问。 -- [x] `node .ai-team/check.mjs --base main` — AR-003 acceptance 5/5,任务范围和变更同步有效。 -- [x] Project-to-Act `--validate` — managed 配置 valid,issues 为空。 +- [ ] `.venv\Scripts\python.exe -m pytest -W error -q` — 25 tests passed, but command exited during pytest cache finalization because `.pytest_cache` was inaccessible (`PermissionError: [WinError 5]`) +- [x] `.venv\Scripts\autoresearch.exe doctor` +- [x] `node .ai-team/check.mjs --base origin/main` — `Result: valid`, functional progress 8/8 +- [x] `.venv\Scripts\python.exe -m pytest tests/test_open_access_service.py -q` — 7 passed +- [x] `.venv\Scripts\python.exe -m pytest -q` — 25 passed +- [x] `.venv\Scripts\python.exe -m ruff check src tests` — All checks passed +- [x] `.venv\Scripts\python.exe -m pytest -W error -q -p no:cacheprovider` — 25 passed +- [x] `git diff --check` — no whitespace errors; only Windows LF/CRLF warnings ## Handoff note -- From: `zzg` -- To: `user/team` -- Summary: 当前分配入口已经降为模块级;详细拆解保留在附录。先分模块负责人,后续只对真正需要的模块继续细化。 +- From: `user/team` +- To: `maintainer/reviewer` +- Summary: AR-004 已完成 OA model、Unpaywall/OpenAlex provider、顺序 resolver、RecordStore upsert、PaperSearchService 去重后 enrichment 和离线/集成测试;等待 review 和 merge。 diff --git a/src/autoresearch/application.py b/src/autoresearch/application.py index 1da0cfc..8e0dd4a 100644 --- a/src/autoresearch/application.py +++ b/src/autoresearch/application.py @@ -41,6 +41,11 @@ from autoresearch.handoffs import HandoffService from autoresearch.knowledge import KnowledgeService from autoresearch.llm import LLMService +from autoresearch.open_access_service import ( + OpenAccessResolver, + OpenAlexOAProvider, + UnpaywallProvider, +) from autoresearch.profile_service import UserProfileService from autoresearch.project_service import ProjectService from autoresearch.reader_service import PaperReaderService @@ -66,11 +71,30 @@ def __init__(self, settings: Settings | None = None): self.settings.template_dir, self.store, ) + oa_providers = [] + + if self.settings.unpaywall_email: + oa_providers.append( + UnpaywallProvider(self.settings.unpaywall_email) + ) + + oa_providers.append(OpenAlexOAProvider()) + + self.open_access = OpenAccessResolver( + self.store, + oa_providers, + ) + self.search = PaperSearchService( self.store, self.evidence, self.knowledge, network_enabled=self.settings.network_enabled, + open_access=( + self.open_access + if self.settings.network_enabled + else None + ), ) self.reader = PaperReaderService(self.store, self.evidence, self.knowledge) self.writing = WritingService( diff --git a/src/autoresearch/config.py b/src/autoresearch/config.py index a1590b0..0f0a18f 100644 --- a/src/autoresearch/config.py +++ b/src/autoresearch/config.py @@ -29,6 +29,10 @@ class Settings(BaseSettings): template_dir: Path = Field( default=Path("paper-projects/_template"), alias="AUTORESEARCH_TEMPLATE_DIR" ) + unpaywall_email: str | None = Field( + default=None, + alias="UNPAYWALL_EMAIL", + ) network_enabled: bool = Field(default=False, alias="AUTORESEARCH_NETWORK_ENABLED") @property @@ -55,6 +59,7 @@ def safe_summary(self) -> dict[str, object]: "network_enabled": self.network_enabled, "db_path": str(self.db_path), "checkpoint_path": str(self.checkpoint_path), + "unpaywall_email_configured": bool(self.unpaywall_email), } diff --git a/src/autoresearch/contracts.py b/src/autoresearch/contracts.py index 34158de..40f8b71 100644 --- a/src/autoresearch/contracts.py +++ b/src/autoresearch/contracts.py @@ -90,6 +90,12 @@ class KnowledgePartition(StrEnum): PROJECTS = "projects" +class OpenAccessStatus(StrEnum): + OPEN_ACCESS = "open_access" + NO_OPEN_FULLTEXT_FOUND = "no_open_fulltext_found" + UNKNOWN = "unknown" + + class ClaimStatus(StrEnum): UNKNOWN = "unknown" HYPOTHESIS = "hypothesis" @@ -203,6 +209,18 @@ class PaperRecord(BaseModel): retrieved_at: datetime = Field(default_factory=utc_now) +class OpenAccessRecord(BaseModel): + paper_id: str + project_id: str + status: OpenAccessStatus + fulltext_url: str | None = None + license: str | None = None + source: str | None = None + checked_sources: list[str] = Field(default_factory=list) + diagnostics: list[str] = Field(default_factory=list) + checked_at: datetime = Field(default_factory=utc_now) + + class ReadingCard(BaseModel): card_id: str = Field(default_factory=lambda: new_id("card")) project_id: str diff --git a/src/autoresearch/open_access_service.py b/src/autoresearch/open_access_service.py new file mode 100644 index 0000000..0c8e6e5 --- /dev/null +++ b/src/autoresearch/open_access_service.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Protocol + +import httpx + +from autoresearch.contracts import ( + OpenAccessRecord, + OpenAccessStatus, + PaperRecord, +) +from autoresearch.storage import RecordStore + +OPEN_ACCESS_RECORD_KIND = "paper_open_access" + + +@dataclass(frozen=True) +class OpenAccessLookupResult: + """A provider's report about a paper's legally available full text.""" + + fulltext_url: str | None = None + license: str | None = None + diagnostics: list[str] = field(default_factory=list) + + +class OpenAccessProvider(Protocol): + """Read-only OA lookup provider used by :class:`OpenAccessResolver`.""" + + name: str + + def lookup(self, paper: PaperRecord) -> OpenAccessLookupResult: ... + + +class UnpaywallProvider: + name = "unpaywall" + + def __init__(self, email: str, timeout: float = 30.0) -> None: + self.email = email + self.timeout = timeout + + def lookup(self, paper: PaperRecord) -> OpenAccessLookupResult: + if not paper.doi: + return OpenAccessLookupResult() + + response = httpx.get( + f"https://api.unpaywall.org/v2/{paper.doi}", + params={"email": self.email}, + timeout=self.timeout, + ) + + if response.status_code == 404: + return OpenAccessLookupResult( + diagnostics=["unpaywall: DOI not found"] + ) + + response.raise_for_status() + + payload = response.json() + + if not payload.get("is_oa"): + return OpenAccessLookupResult( + diagnostics=["unpaywall: no OA location found"] + ) + + location = payload.get("best_oa_location") or {} + + fulltext_url = ( + location.get("url_for_pdf") + or location.get("url_for_landing_page") + or location.get("url") + ) + + return OpenAccessLookupResult( + fulltext_url=fulltext_url, + license=location.get("license"), + ) + + +class OpenAlexOAProvider: + name = "openalex" + + def __init__(self, timeout: float = 30.0) -> None: + self.timeout = timeout + + def lookup(self, paper: PaperRecord) -> OpenAccessLookupResult: + if not paper.doi: + return OpenAccessLookupResult() + + response = httpx.get( + f"https://api.openalex.org/works/https://doi.org/{paper.doi}", + timeout=self.timeout, + ) + + if response.status_code == 404: + return OpenAccessLookupResult( + diagnostics=["openalex: DOI not found"] + ) + + response.raise_for_status() + + payload = response.json() + location = payload.get("best_oa_location") or {} + + fulltext_url = ( + location.get("pdf_url") + or location.get("landing_page_url") + or (payload.get("open_access") or {}).get("oa_url") + ) + + if not fulltext_url: + return OpenAccessLookupResult( + diagnostics=["openalex: no OA location found"] + ) + + return OpenAccessLookupResult( + fulltext_url=fulltext_url, + license=location.get("license"), + ) + + +class OpenAccessResolver: + """Resolve and persist the latest legal OA entry point for a paper.""" + + def __init__( + self, + store: RecordStore, + providers: Sequence[OpenAccessProvider], + ) -> None: + self.store = store + self.providers = tuple(providers) + + def resolve(self, paper: PaperRecord) -> OpenAccessRecord: + if not paper.doi: + record = OpenAccessRecord( + paper_id=paper.paper_id, + project_id=paper.project_id, + status=OpenAccessStatus.UNKNOWN, + diagnostics=["OA lookup skipped because paper has no DOI."], + ) + self._persist(record) + return record + + checked_sources: list[str] = [] + diagnostics: list[str] = [] + provider_failed = False + + for provider in self.providers: + provider_name = provider.name + checked_sources.append(provider_name) + + try: + result = provider.lookup(paper) + except Exception as exc: + provider_failed = True + diagnostics.append( + f"{provider_name} failed: {type(exc).__name__}" + ) + continue + + diagnostics.extend(result.diagnostics) + + if result.fulltext_url and result.fulltext_url.strip(): + record = OpenAccessRecord( + paper_id=paper.paper_id, + project_id=paper.project_id, + status=OpenAccessStatus.OPEN_ACCESS, + fulltext_url=result.fulltext_url, + license=result.license, + source=provider_name, + checked_sources=checked_sources, + diagnostics=diagnostics, + ) + self._persist(record) + return record + + status = ( + OpenAccessStatus.UNKNOWN + if provider_failed + else OpenAccessStatus.NO_OPEN_FULLTEXT_FOUND + ) + + record = OpenAccessRecord( + paper_id=paper.paper_id, + project_id=paper.project_id, + status=status, + checked_sources=checked_sources, + diagnostics=diagnostics, + ) + self._persist(record) + return record + + def _persist(self, record: OpenAccessRecord) -> None: + self.store.put( + OPEN_ACCESS_RECORD_KIND, + record.paper_id, + record, + project_id=record.project_id, + ) diff --git a/src/autoresearch/search_service.py b/src/autoresearch/search_service.py index 0af79c6..e9db9fd 100644 --- a/src/autoresearch/search_service.py +++ b/src/autoresearch/search_service.py @@ -16,6 +16,7 @@ ) from autoresearch.evidence import EvidenceService from autoresearch.knowledge import KnowledgeService +from autoresearch.open_access_service import OpenAccessResolver from autoresearch.storage import RecordStore @@ -139,6 +140,7 @@ def __init__( *, network_enabled: bool, connectors: list[ScholarlyConnector] | None = None, + open_access: OpenAccessResolver | None = None, ): self.store = store self.evidence = evidence @@ -149,6 +151,7 @@ def __init__( CrossrefConnector(), SemanticScholarConnector(), ] + self.open_access = open_access @staticmethod def _dedupe_key(paper: PaperRecord) -> str: @@ -233,6 +236,9 @@ def search( persisted, _ = self._persist(paper) outcome.papers.append(persisted) + if self.open_access is not None: + self.open_access.resolve(persisted) + if not outcome.papers: outcome.diagnostics.append( "No papers were found; downstream reading is blocked instead of inventing records." diff --git a/tests/test_open_access_service.py b/tests/test_open_access_service.py new file mode 100644 index 0000000..fcaa1b7 --- /dev/null +++ b/tests/test_open_access_service.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from autoresearch.contracts import ( + OpenAccessStatus, + PaperRecord, +) +from autoresearch.evidence import EvidenceService +from autoresearch.knowledge import KnowledgeService +from autoresearch.open_access_service import ( + OPEN_ACCESS_RECORD_KIND, + OpenAccessLookupResult, + OpenAccessResolver, +) +from autoresearch.search_service import PaperSearchService +from autoresearch.storage import RecordStore + + +@dataclass +class FakeProvider: + name: str + result: OpenAccessLookupResult | None = None + error: Exception | None = None + calls: int = 0 + received_paper_ids: list[str] = field(default_factory=list) + + def lookup(self, paper: PaperRecord) -> OpenAccessLookupResult: + self.calls += 1 + self.received_paper_ids.append(paper.paper_id) + if self.error is not None: + raise self.error + return self.result or OpenAccessLookupResult() + + +@dataclass +class FakeSearchConnector: + name: str + records: list[dict[str, Any]] + + def search(self, query: str, limit: int) -> list[dict[str, Any]]: + return self.records[:limit] + + +@dataclass +class RecordingResolver: + store: RecordStore + paper_ids: list[str] = field(default_factory=list) + persisted_at_call: list[bool] = field(default_factory=list) + + def resolve(self, paper: PaperRecord) -> None: + self.paper_ids.append(paper.paper_id) + self.persisted_at_call.append( + self.store.get("paper", paper.paper_id) is not None + ) + + +def _paper() -> PaperRecord: + return PaperRecord( + paper_id="paper_1", + project_id="project_1", + title="Offline OA test paper", + doi="10.1234/offline-oa-test", + source="test", + ) + + +def _resolver(tmp_path: Path, providers: list[FakeProvider]) -> OpenAccessResolver: + return OpenAccessResolver(RecordStore(tmp_path / "records.sqlite3"), providers) + + +def test_first_provider_finds_oa(tmp_path: Path): + first = FakeProvider( + "unpaywall", + OpenAccessLookupResult( + fulltext_url="https://repository.example/paper.pdf", + license="cc-by", + diagnostics=["matched repository full text"], + ), + ) + second = FakeProvider("openalex", OpenAccessLookupResult()) + resolver = _resolver(tmp_path, [first, second]) + + record = resolver.resolve(_paper()) + + assert record.status is OpenAccessStatus.OPEN_ACCESS + assert record.fulltext_url == "https://repository.example/paper.pdf" + assert record.license == "cc-by" + assert record.source == "unpaywall" + assert record.checked_sources == ["unpaywall"] + assert record.diagnostics == ["matched repository full text"] + assert first.calls == 1 + assert second.calls == 0 + + +def test_first_provider_misses_and_second_provider_finds_oa(tmp_path: Path): + first = FakeProvider("unpaywall", OpenAccessLookupResult()) + second = FakeProvider( + "openalex", + OpenAccessLookupResult(fulltext_url="https://openalex.example/paper.pdf"), + ) + resolver = _resolver(tmp_path, [first, second]) + + record = resolver.resolve(_paper()) + + assert record.status is OpenAccessStatus.OPEN_ACCESS + assert record.fulltext_url == "https://openalex.example/paper.pdf" + assert record.source == "openalex" + assert record.checked_sources == ["unpaywall", "openalex"] + + +def test_all_providers_successfully_miss(tmp_path: Path): + first = FakeProvider("unpaywall", OpenAccessLookupResult()) + second = FakeProvider("openalex", OpenAccessLookupResult()) + resolver = _resolver(tmp_path, [first, second]) + + record = resolver.resolve(_paper()) + + assert record.status is OpenAccessStatus.NO_OPEN_FULLTEXT_FOUND + assert record.fulltext_url is None + assert record.checked_sources == ["unpaywall", "openalex"] + assert record.diagnostics == [] + + +def test_provider_failure_returns_unknown(tmp_path: Path): + first = FakeProvider("unpaywall", error=TimeoutError()) + second = FakeProvider("openalex", error=ConnectionError()) + resolver = _resolver(tmp_path, [first, second]) + + record = resolver.resolve(_paper()) + + assert record.status is OpenAccessStatus.UNKNOWN + assert record.checked_sources == ["unpaywall", "openalex"] + assert record.diagnostics == [ + "unpaywall failed: TimeoutError", + "openalex failed: ConnectionError", + ] + + +def test_repeated_resolution_updates_one_current_record(tmp_path: Path): + provider = FakeProvider( + "unpaywall", + OpenAccessLookupResult(fulltext_url="https://repository.example/first.pdf"), + ) + resolver = _resolver(tmp_path, [provider]) + paper = _paper() + + first = resolver.resolve(paper) + provider.result = OpenAccessLookupResult( + fulltext_url="https://repository.example/second.pdf", + license="cc-by-nc", + ) + second = resolver.resolve(paper) + + stored = resolver.store.get(OPEN_ACCESS_RECORD_KIND, paper.paper_id) + current_records = resolver.store.list( + OPEN_ACCESS_RECORD_KIND, + project_id=paper.project_id, + ) + assert first.fulltext_url == "https://repository.example/first.pdf" + assert second.fulltext_url == "https://repository.example/second.pdf" + assert stored is not None + assert stored["fulltext_url"] == "https://repository.example/second.pdf" + assert len(current_records) == 1 + + +def test_paper_without_doi_returns_unknown_without_calling_provider(tmp_path: Path): + provider = FakeProvider( + "unpaywall", + OpenAccessLookupResult(fulltext_url="https://repository.example/paper.pdf"), + ) + resolver = _resolver(tmp_path, [provider]) + paper = _paper().model_copy(update={"doi": None}) + + record = resolver.resolve(paper) + + assert record.status is OpenAccessStatus.UNKNOWN + assert record.checked_sources == [] + assert any("OA lookup skipped" in diagnostic and "no DOI" in diagnostic + for diagnostic in record.diagnostics) + assert provider.calls == 0 + + +def test_search_resolves_deduplicated_persisted_paper_only_when_configured( + tmp_path: Path, +): + records = [ + { + "title": "Deduplicated OA paper", + "abstract": "Offline abstract", + "doi": "10.1234/integration-oa", + "url": "https://example.invalid/paper", + "source_record_id": "first", + }, + { + "title": "Duplicate OA paper", + "abstract": "Duplicate abstract", + "doi": "10.1234/integration-oa", + "url": "https://example.invalid/duplicate", + "source_record_id": "second", + }, + ] + store = RecordStore(tmp_path / "configured.sqlite3") + resolver = RecordingResolver(store) + service = PaperSearchService( + store, + EvidenceService(store), + KnowledgeService(store), + network_enabled=True, + connectors=[FakeSearchConnector("offline", records)], + open_access=resolver, + ) + + outcome = service.search("project_1", ["offline"]) + + assert len(outcome.papers) == 1 + paper = outcome.papers[0] + assert resolver.paper_ids == [paper.paper_id] + assert resolver.persisted_at_call == [True] + assert store.get("paper", paper.paper_id) is not None + + disabled_store = RecordStore(tmp_path / "disabled.sqlite3") + disabled_resolver = RecordingResolver(disabled_store) + disabled_service = PaperSearchService( + disabled_store, + EvidenceService(disabled_store), + KnowledgeService(disabled_store), + network_enabled=True, + connectors=[FakeSearchConnector("offline", records)], + open_access=None, + ) + + disabled_outcome = disabled_service.search("project_1", ["offline"]) + + assert len(disabled_outcome.papers) == 1 + assert disabled_resolver.paper_ids == [] + assert disabled_store.list(OPEN_ACCESS_RECORD_KIND) == []