diff --git a/config/config.yaml b/config/config.yaml index 99de1f17..ff06e2cc 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -115,4 +115,14 @@ apis: anthropic: model: "claude-3-5-sonnet-20241022" max_tokens: 4096 - temperature: 0.0 \ No newline at end of file + temperature: 0.0 + +# Obsidian 集成(可选) +obsidian: + enabled: false + vault_path: "" + root_dir: "PaperBot" + paper_template_path: null + auto_export_on_save: true + auto_sync_tracks: true + export_limit: 200 diff --git a/config/settings.py b/config/settings.py index 8a385858..cc224911 100644 --- a/config/settings.py +++ b/config/settings.py @@ -129,6 +129,18 @@ class ReportEngineConf(BaseModel): model_tiers: Dict[str, str] = Field(default_factory=dict) +class ObsidianConfig(BaseModel): + model_config = ConfigDict(extra="ignore") + + enabled: bool = False + vault_path: str = "" + root_dir: str = "PaperBot" + paper_template_path: Optional[str] = None + auto_export_on_save: bool = True + auto_sync_tracks: bool = True + export_limit: int = 200 + + class CollabHostConfig(BaseModel): model_config = ConfigDict(extra="ignore") @@ -150,6 +162,7 @@ class Settings(BaseModel): logging: LoggingConfig = Field(default_factory=LoggingConfig) api: APIConfig = Field(default_factory=APIConfig) report_engine: ReportEngineConf = Field(default_factory=ReportEngineConf) + obsidian: ObsidianConfig = Field(default_factory=ObsidianConfig) collab: Dict[str, Any] = Field( default_factory=lambda: { "enabled": False, @@ -213,6 +226,7 @@ def from_dict(cls, config_data: Dict[str, Any]) -> Settings: "output", "logging", "report_engine", + "obsidian", "mode", "offline", ): @@ -310,6 +324,13 @@ def load_environment_variables(self) -> None: re_max = os.getenv("PAPERBOT_RE_MAX_WORDS") re_scenario = os.getenv("PAPERBOT_RE_SCENARIO") re_tiers = os.getenv("PAPERBOT_RE_MODEL_TIERS") + obsidian_enabled = os.getenv("PAPERBOT_OBSIDIAN_ENABLED") + obsidian_vault = os.getenv("PAPERBOT_OBSIDIAN_VAULT_PATH") + obsidian_root = os.getenv("PAPERBOT_OBSIDIAN_ROOT_DIR") + obsidian_template = os.getenv("PAPERBOT_OBSIDIAN_PAPER_TEMPLATE") + obsidian_auto_export = os.getenv("PAPERBOT_OBSIDIAN_AUTO_EXPORT") + obsidian_auto_sync_tracks = os.getenv("PAPERBOT_OBSIDIAN_AUTO_SYNC_TRACKS") + obsidian_export_limit = os.getenv("PAPERBOT_OBSIDIAN_EXPORT_LIMIT") if re_enabled is not None: self.report_engine.enabled = re_enabled.lower() in ("1", "true", "yes", "on") if re_api: @@ -339,6 +360,34 @@ def load_environment_variables(self) -> None: tiers[k.strip()] = v.strip() self.report_engine.model_tiers = tiers + if obsidian_enabled is not None: + self.obsidian.enabled = obsidian_enabled.lower() in ("1", "true", "yes", "on") + if obsidian_vault: + self.obsidian.vault_path = obsidian_vault + if obsidian_root: + self.obsidian.root_dir = obsidian_root + if obsidian_template: + self.obsidian.paper_template_path = obsidian_template + if obsidian_auto_export is not None: + self.obsidian.auto_export_on_save = obsidian_auto_export.lower() in ( + "1", + "true", + "yes", + "on", + ) + if obsidian_auto_sync_tracks is not None: + self.obsidian.auto_sync_tracks = obsidian_auto_sync_tracks.lower() in ( + "1", + "true", + "yes", + "on", + ) + if obsidian_export_limit: + try: + self.obsidian.export_limit = max(1, int(obsidian_export_limit)) + except ValueError: + pass + # Collab host LLM host_api = os.getenv("PAPERBOT_HOST_API_KEY") host_model = os.getenv("PAPERBOT_HOST_MODEL") @@ -366,6 +415,7 @@ def to_dict(self) -> Dict[str, Any]: "logging": self.logging.model_dump(), "api": self.api.model_dump(), "report_engine": self.report_engine.model_dump(), + "obsidian": self.obsidian.model_dump(), "collab": self.collab, "conferences": {name: conf.model_dump() for name, conf in self.conferences.items()}, } diff --git a/env.example b/env.example index 976fa028..4bb4055e 100644 --- a/env.example +++ b/env.example @@ -61,6 +61,17 @@ PAPERBOT_INTELLIGENCE_REDDIT_SUBREDDITS=MachineLearning,LocalLLaMA,OpenAI # PAPERBOT_DB_URL=postgresql+psycopg://postgres.:@aws-0-.pooler.supabase.com:6543/postgres?sslmode=require PAPERBOT_DB_URL= +# ---------------------------- +# Obsidian export (optional) +# ---------------------------- +PAPERBOT_OBSIDIAN_ENABLED=false +PAPERBOT_OBSIDIAN_VAULT_PATH= +PAPERBOT_OBSIDIAN_ROOT_DIR=PaperBot +PAPERBOT_OBSIDIAN_PAPER_TEMPLATE= +PAPERBOT_OBSIDIAN_AUTO_EXPORT=true +PAPERBOT_OBSIDIAN_AUTO_SYNC_TRACKS=true +PAPERBOT_OBSIDIAN_EXPORT_LIMIT=200 + # ---------------------------- # Report Engine (optional) # ---------------------------- diff --git a/src/paperbot/api/routes/research.py b/src/paperbot/api/routes/research.py index d553fe6e..a692805d 100644 --- a/src/paperbot/api/routes/research.py +++ b/src/paperbot/api/routes/research.py @@ -16,6 +16,10 @@ from paperbot.context_engine.track_router import TrackRouter from paperbot.domain.paper_identity import normalize_arxiv_id, normalize_doi from paperbot.infrastructure.api_clients.semantic_scholar import SemanticScholarClient +from paperbot.infrastructure.exporters.obsidian_sync import ( + export_track_snapshot, + obsidian_auto_export_enabled, +) from paperbot.infrastructure.stores.memory_store import SqlAlchemyMemoryStore from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore from paperbot.infrastructure.stores.workflow_metric_store import WorkflowMetricStore @@ -59,6 +63,26 @@ def _get_track_router() -> TrackRouter: memory_store=_get_memory_store(), ) return _track_router + + +def _schedule_obsidian_export( + background_tasks: BackgroundTasks, + *, + user_id: str, + track_id: int, + for_tracks: bool = False, +) -> None: + if track_id <= 0: + return + if not obsidian_auto_export_enabled(for_tracks=for_tracks): + return + background_tasks.add_task( + export_track_snapshot, + user_id=user_id, + track_id=track_id, + ) + + ENABLE_ANCHOR_AUTHORS = os.getenv("PAPERBOT_ENABLE_ANCHOR_AUTHORS", "true").lower() == "true" _DISCOVERY_STOPWORDS: Set[str] = { @@ -304,6 +328,12 @@ def create_track(req: TrackCreateRequest, background_tasks: BackgroundTasks): _schedule_embedding_precompute( background_tasks, user_id=req.user_id, track_ids=[int(track.get("id") or 0)] ) + _schedule_obsidian_export( + background_tasks, + user_id=req.user_id, + track_id=int(track.get("id") or 0), + for_tracks=True, + ) return TrackResponse(track=track) @@ -449,6 +479,12 @@ def update_track( if not track: raise HTTPException(status_code=404, detail="Track not found") _schedule_embedding_precompute(background_tasks, user_id=user_id, track_ids=[track_id]) + _schedule_obsidian_export( + background_tasks, + user_id=user_id, + track_id=track_id, + for_tracks=True, + ) return TrackResponse(track=track) @@ -982,7 +1018,7 @@ class PaperFeedbackResponse(BaseModel): @router.post("/research/papers/feedback", response_model=PaperFeedbackResponse) -def add_paper_feedback(req: PaperFeedbackRequest): +def add_paper_feedback(req: PaperFeedbackRequest, background_tasks: BackgroundTasks): set_trace_id() # Initialize trace_id for this request Logger.info(f"Received paper feedback request, action={req.action}", file=LogFiles.HARVEST) research_store = _get_research_store() @@ -1068,6 +1104,13 @@ def add_paper_feedback(req: PaperFeedbackRequest): Logger.info("Paper feedback recorded successfully", file=LogFiles.HARVEST) normalized_action = research_store._normalize_feedback_action(req.action) current_action = research_store._effective_feedback_action(normalized_action) + if normalized_action in {"save", "unsave"}: + _schedule_obsidian_export( + background_tasks, + user_id=req.user_id, + track_id=int(track_id), + for_tracks=False, + ) return PaperFeedbackResponse( feedback=fb, library_paper_id=library_paper_id, diff --git a/src/paperbot/application/ports/vault_exporter_port.py b/src/paperbot/application/ports/vault_exporter_port.py index 9fa561c0..c2808fe4 100644 --- a/src/paperbot/application/ports/vault_exporter_port.py +++ b/src/paperbot/application/ports/vault_exporter_port.py @@ -17,4 +17,5 @@ def export_library_snapshot( saved_items: List[Dict[str, Any]], track: Optional[Dict[str, Any]] = None, root_dir: str = "PaperBot", + paper_template_path: Optional[Path] = None, ) -> Dict[str, Any]: ... diff --git a/src/paperbot/infrastructure/exporters/__init__.py b/src/paperbot/infrastructure/exporters/__init__.py index 7affd729..4aa276a8 100644 --- a/src/paperbot/infrastructure/exporters/__init__.py +++ b/src/paperbot/infrastructure/exporters/__init__.py @@ -1,5 +1,11 @@ """Filesystem exporters for external knowledge tools.""" from .obsidian_exporter import ObsidianFilesystemExporter +from .obsidian_sync import export_track_snapshot, get_obsidian_config, obsidian_auto_export_enabled -__all__ = ["ObsidianFilesystemExporter"] +__all__ = [ + "ObsidianFilesystemExporter", + "export_track_snapshot", + "get_obsidian_config", + "obsidian_auto_export_enabled", +] diff --git a/src/paperbot/infrastructure/exporters/obsidian_exporter.py b/src/paperbot/infrastructure/exporters/obsidian_exporter.py index 69cdc8b2..115bbbf8 100644 --- a/src/paperbot/infrastructure/exporters/obsidian_exporter.py +++ b/src/paperbot/infrastructure/exporters/obsidian_exporter.py @@ -5,10 +5,43 @@ from typing import Any, Dict, List, Optional import yaml +from jinja2 import Environment, FileSystemLoader from paperbot.application.ports.vault_exporter_port import VaultExporterPort +DEFAULT_PAPER_TEMPLATE = """{{ frontmatter }} +# {{ title }} + +## Summary +{{ abstract }} + +## Metadata +{% for row in metadata_rows -%} +- {{ row }} +{% endfor %} +{% if track_link %} + +## Tracks +- {{ track_link }} +{% endif %} +{% if related_links %} + +## Related Papers +{% for link in related_links -%} +- {{ link }} +{% endfor %} +{% endif %} +{% if external_links %} + +## Links +{% for link in external_links -%} +- {{ link }} +{% endfor %} +{% endif %} +""" + + def _slugify(value: str) -> str: normalized = unicodedata.normalize("NFKD", value or "").encode("ascii", "ignore").decode("ascii") normalized = normalized.lower().strip() @@ -52,6 +85,9 @@ def _yaml_frontmatter(payload: Dict[str, Any]) -> str: class ObsidianFilesystemExporter(VaultExporterPort): """Write PaperBot artifacts directly into an Obsidian-compatible vault.""" + def __init__(self, *, paper_template_path: Optional[Path] = None): + self._paper_template_path = Path(paper_template_path).expanduser() if paper_template_path else None + def export_library_snapshot( self, *, @@ -59,10 +95,12 @@ def export_library_snapshot( saved_items: List[Dict[str, Any]], track: Optional[Dict[str, Any]] = None, root_dir: str = "PaperBot", + paper_template_path: Optional[Path] = None, ) -> Dict[str, Any]: vault_dir = Path(vault_path).expanduser().resolve() if not vault_dir.exists() or not vault_dir.is_dir(): raise ValueError("vault_path must be an existing directory") + template_path = self._resolve_paper_template_path(paper_template_path) root_path = vault_dir / root_dir papers_dir = root_path / "Papers" @@ -82,6 +120,7 @@ def export_library_snapshot( paper=paper, track=track, saved_at=item.get("saved_at"), + template_path=template_path, ) ) @@ -117,6 +156,7 @@ def _write_paper_note( paper: Dict[str, Any], track: Optional[Dict[str, Any]], saved_at: Optional[str], + template_path: Optional[Path], ) -> Dict[str, str]: note_stem = self._paper_note_stem(paper) note_path = papers_dir / f"{note_stem}.md" @@ -137,6 +177,8 @@ def _write_paper_note( if track is not None else None ) + related_links = self._paper_related_links(paper=paper, root_dir=root_dir) + related_titles = self._paper_related_titles(paper) frontmatter = _yaml_frontmatter( { @@ -154,31 +196,30 @@ def _write_paper_note( "saved_at": saved_at, "track": track.get("name") if track else None, "tags": self._paper_tags(paper, track), + "related_papers": related_titles, } ) - lines = [ - frontmatter, - f"# {paper.get('title') or 'Untitled Paper'}", - "", - "## Summary", - str(paper.get("abstract") or "_No abstract available._"), - "", - "## Metadata", - f"- Authors: {', '.join(paper.get('authors') or []) or 'Unknown'}", - f"- Venue: {paper.get('venue') or 'Unknown'}", - f"- Year: {paper.get('year') or 'Unknown'}", - f"- Citations: {int(paper.get('citation_count') or 0)}", - ] - - if track_link: - lines.extend(["", "## Tracks", f"- {track_link}"]) - - links = self._paper_links(paper) - if links: - lines.extend(["", "## Links", *[f"- {link}" for link in links]]) + body = self._render_paper_note( + template_path=template_path, + frontmatter=frontmatter, + title=note_title, + abstract=str(paper.get("abstract") or "_No abstract available._"), + metadata_rows=[ + f"Authors: {', '.join(paper.get('authors') or []) or 'Unknown'}", + f"Venue: {paper.get('venue') or 'Unknown'}", + f"Year: {paper.get('year') or 'Unknown'}", + f"Citations: {int(paper.get('citation_count') or 0)}", + ], + track_link=track_link, + external_links=self._paper_links(paper), + related_links=related_links, + paper=paper, + track=track, + related_titles=related_titles, + ) - note_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + note_path.write_text(body.rstrip() + "\n", encoding="utf-8") return { "title": note_title, "path": str(note_path), @@ -314,6 +355,125 @@ def _paper_links(paper: Dict[str, Any]) -> List[str]: links.append(f"[arXiv](https://arxiv.org/abs/{arxiv_id})") return links + def _resolve_paper_template_path(self, paper_template_path: Optional[Path]) -> Optional[Path]: + if paper_template_path is not None: + return Path(paper_template_path).expanduser() + return self._paper_template_path + + def _render_paper_note( + self, + *, + template_path: Optional[Path], + frontmatter: str, + title: str, + abstract: str, + metadata_rows: List[str], + track_link: Optional[str], + external_links: List[str], + related_links: List[str], + paper: Dict[str, Any], + track: Optional[Dict[str, Any]], + related_titles: List[str], + ) -> str: + if template_path: + resolved = template_path.expanduser().resolve() + environment = Environment( + loader=FileSystemLoader(str(resolved.parent)), + autoescape=False, + keep_trailing_newline=True, + trim_blocks=False, + lstrip_blocks=False, + ) + template = environment.get_template(resolved.name) + return template.render( + frontmatter=frontmatter, + title=title, + abstract=abstract, + metadata_rows=metadata_rows, + track_link=track_link, + external_links=external_links, + related_links=related_links, + paper=paper, + track=track, + related_titles=related_titles, + ) + + return Environment(autoescape=False).from_string(DEFAULT_PAPER_TEMPLATE).render( + frontmatter=frontmatter, + title=title, + abstract=abstract, + metadata_rows=metadata_rows, + track_link=track_link, + external_links=external_links, + related_links=related_links, + paper=paper, + track=track, + related_titles=related_titles, + ) + + def _paper_related_links(self, *, paper: Dict[str, Any], root_dir: str) -> List[str]: + links: List[str] = [] + seen: set[str] = set() + for entry in self._paper_related_entries(paper): + title = str(entry.get("title") or "").strip() + if not title or title.casefold() in seen: + continue + seen.add(title.casefold()) + note_stem = self._paper_note_stem(entry) + links.append( + self._wikilink( + root_dir=root_dir, + section="Papers", + note_stem=note_stem, + label=title, + ) + ) + return links + + def _paper_related_titles(self, paper: Dict[str, Any]) -> List[str]: + titles: List[str] = [] + seen: set[str] = set() + for entry in self._paper_related_entries(paper): + title = str(entry.get("title") or "").strip() + if not title or title.casefold() in seen: + continue + seen.add(title.casefold()) + titles.append(title) + return titles + + @staticmethod + def _paper_related_entries(paper: Dict[str, Any]) -> List[Dict[str, Any]]: + related: List[Dict[str, Any]] = [] + candidates = [ + paper.get("related_papers"), + paper.get("related_titles"), + paper.get("references"), + (paper.get("metadata") or {}).get("related_papers") if isinstance(paper.get("metadata"), dict) else None, + (paper.get("metadata") or {}).get("references") if isinstance(paper.get("metadata"), dict) else None, + ] + for bucket in candidates: + if not isinstance(bucket, list): + continue + for item in bucket: + if isinstance(item, dict): + title = str(item.get("title") or item.get("name") or "").strip() + if title: + related.append( + { + "title": title, + "year": item.get("year"), + "arxiv_id": item.get("arxiv_id"), + "doi": item.get("doi"), + "semantic_scholar_id": item.get("semantic_scholar_id"), + "id": item.get("id"), + } + ) + else: + title = str(item or "").strip() + if title: + related.append({"title": title}) + return related + @staticmethod def _wikilink( *, diff --git a/src/paperbot/infrastructure/exporters/obsidian_sync.py b/src/paperbot/infrastructure/exporters/obsidian_sync.py new file mode 100644 index 00000000..62d513c2 --- /dev/null +++ b/src/paperbot/infrastructure/exporters/obsidian_sync.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Optional + +from config.settings import ObsidianConfig, create_settings +from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore +from paperbot.utils.logging_config import LogFiles, Logger + +from .obsidian_exporter import ObsidianFilesystemExporter + + +def get_obsidian_config() -> ObsidianConfig: + return create_settings().obsidian + + +def obsidian_auto_export_enabled(*, for_tracks: bool = False) -> bool: + config = get_obsidian_config() + if not config.enabled: + return False + if not str(config.vault_path or "").strip(): + return False + return config.auto_sync_tracks if for_tracks else config.auto_export_on_save + + +def export_track_snapshot( + *, + user_id: str, + track_id: int, + store: Optional[SqlAlchemyResearchStore] = None, +) -> Optional[Dict[str, Any]]: + config = get_obsidian_config() + vault_path = Path(config.vault_path).expanduser() + if not config.enabled or not str(config.vault_path or "").strip(): + return None + if not vault_path.exists() or not vault_path.is_dir(): + Logger.warning( + f"Skipping Obsidian export because vault path is unavailable: {vault_path}", + file=LogFiles.HARVEST, + ) + return None + + own_store = store is None + current_store = store or SqlAlchemyResearchStore() + try: + track = current_store.get_track(user_id=user_id, track_id=track_id) + if track is None: + Logger.warning( + f"Skipping Obsidian export because track {track_id} was not found for {user_id}", + file=LogFiles.HARVEST, + ) + return None + + saved_items = current_store.list_saved_papers( + user_id=user_id, + track_id=track_id, + limit=max(1, int(config.export_limit)), + ) + if config.paper_template_path: + exporter = ObsidianFilesystemExporter( + paper_template_path=Path(config.paper_template_path).expanduser() + ) + else: + exporter = ObsidianFilesystemExporter() + result = exporter.export_library_snapshot( + vault_path=vault_path, + saved_items=saved_items, + track=track, + root_dir=config.root_dir, + ) + Logger.info( + f"Exported track {track_id} snapshot to Obsidian vault {vault_path}", + file=LogFiles.HARVEST, + ) + return result + except Exception as exc: + Logger.warning( + f"Obsidian export failed for track {track_id}: {exc}", + file=LogFiles.HARVEST, + ) + return None + finally: + if own_store and hasattr(current_store, "close"): + current_store.close() diff --git a/src/paperbot/presentation/cli/main.py b/src/paperbot/presentation/cli/main.py index 056481e5..291d2be4 100644 --- a/src/paperbot/presentation/cli/main.py +++ b/src/paperbot/presentation/cli/main.py @@ -16,6 +16,7 @@ from dotenv import find_dotenv, load_dotenv +from config.settings import create_settings from paperbot.application.workflows.dailypaper import ( DailyPaperReporter, apply_judge_scores_to_report, @@ -218,8 +219,8 @@ def create_parser() -> argparse.ArgumentParser: ) obsidian_parser.add_argument( "--vault", - required=True, - help="Obsidian vault 目录(必须已存在)", + default=None, + help="Obsidian vault 目录(默认读取 obsidian.vault_path)", ) obsidian_scope = obsidian_parser.add_mutually_exclusive_group() obsidian_scope.add_argument("--track-id", type=int, default=None, help="按 track ID 导出") @@ -232,8 +233,13 @@ def create_parser() -> argparse.ArgumentParser: obsidian_parser.add_argument("--limit", type=int, default=200, help="最多导出多少篇论文") obsidian_parser.add_argument( "--root-dir", - default="PaperBot", - help="vault 内的输出根目录", + default=None, + help="vault 内的输出根目录(默认读取 obsidian.root_dir)", + ) + obsidian_parser.add_argument( + "--paper-template", + default=None, + help="自定义论文笔记 Jinja2 模板路径(默认读取 obsidian.paper_template_path)", ) obsidian_parser.add_argument("--json", action="store_true", help="输出 JSON 摘要") @@ -582,6 +588,18 @@ def _run_obsidian_export(parsed: argparse.Namespace) -> int: from paperbot.infrastructure.exporters import ObsidianFilesystemExporter from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore + settings = create_settings() + obsidian_config = settings.obsidian + vault_value = parsed.vault or obsidian_config.vault_path + if not str(vault_value or "").strip(): + print( + "Error: vault path is required. Pass --vault or configure obsidian.vault_path.", + file=sys.stderr, + ) + return 1 + root_dir = parsed.root_dir or obsidian_config.root_dir or "PaperBot" + paper_template_path = parsed.paper_template or obsidian_config.paper_template_path + store = SqlAlchemyResearchStore() try: track = None @@ -609,12 +627,17 @@ def _run_obsidian_export(parsed: argparse.Namespace) -> int: print(f"Error: no saved papers found{scope}", file=sys.stderr) return 1 - exporter = ObsidianFilesystemExporter() + if paper_template_path: + exporter = ObsidianFilesystemExporter( + paper_template_path=Path(paper_template_path).expanduser() + ) + else: + exporter = ObsidianFilesystemExporter() result = exporter.export_library_snapshot( - vault_path=Path(parsed.vault), + vault_path=Path(vault_value), saved_items=saved_items, track=track, - root_dir=parsed.root_dir, + root_dir=root_dir, ) if parsed.json: diff --git a/tests/integration/test_research_track_routes.py b/tests/integration/test_research_track_routes.py index e058d241..b1b206ab 100644 --- a/tests/integration/test_research_track_routes.py +++ b/tests/integration/test_research_track_routes.py @@ -182,3 +182,62 @@ def test_patch_track_default_user(client_with_store): assert response.status_code == 200 assert response.json()["track"]["name"] == "Updated Name" + + +def test_create_track_schedules_obsidian_export(client_with_store, monkeypatch): + """Track creation should trigger the Obsidian export hook for MOC/note bootstrap.""" + client, _ = client_with_store + + import paperbot.api.routes.research as research_module + + captured: list[dict[str, object]] = [] + + monkeypatch.setattr(research_module, "_schedule_embedding_precompute", lambda *args, **kwargs: None) + monkeypatch.setattr( + research_module, + "_schedule_obsidian_export", + lambda background_tasks, *, user_id, track_id, for_tracks=False: captured.append( + {"user_id": user_id, "track_id": track_id, "for_tracks": for_tracks} + ), + ) + + response = client.post( + "/api/research/tracks", + json={ + "user_id": "test", + "name": "Obsidian Sync Track", + "keywords": ["obsidian", "knowledge-base"], + "activate": False, + }, + ) + + assert response.status_code == 200 + track_id = int(response.json()["track"]["id"]) + assert captured == [{"user_id": "test", "track_id": track_id, "for_tracks": True}] + + +def test_patch_track_schedules_obsidian_export(client_with_store, monkeypatch): + """Track updates should refresh the exported track snapshot.""" + client, store = client_with_store + + import paperbot.api.routes.research as research_module + + track = store.create_track(user_id="test", name="Original", activate=False) + captured: list[dict[str, object]] = [] + + monkeypatch.setattr(research_module, "_schedule_embedding_precompute", lambda *args, **kwargs: None) + monkeypatch.setattr( + research_module, + "_schedule_obsidian_export", + lambda background_tasks, *, user_id, track_id, for_tracks=False: captured.append( + {"user_id": user_id, "track_id": track_id, "for_tracks": for_tracks} + ), + ) + + response = client.patch( + f"/api/research/tracks/{track['id']}?user_id=test", + json={"keywords": ["obsidian", "moc"]}, + ) + + assert response.status_code == 200 + assert captured == [{"user_id": "test", "track_id": int(track["id"]), "for_tracks": True}] diff --git a/tests/unit/test_obsidian_cli.py b/tests/unit/test_obsidian_cli.py index e22d76bb..a27cf048 100644 --- a/tests/unit/test_obsidian_cli.py +++ b/tests/unit/test_obsidian_cli.py @@ -2,6 +2,7 @@ import json from pathlib import Path +from types import SimpleNamespace from paperbot.presentation.cli import main as cli_main @@ -133,3 +134,41 @@ def test_cli_obsidian_export_json_output(monkeypatch, capsys): assert payload["paper_count"] == 1 assert payload["track_note"].endswith("icl-compression.md") assert payload["moc_note"].endswith("MOC.md") + + +def test_cli_obsidian_export_uses_settings_defaults(monkeypatch, capsys): + import paperbot.infrastructure.exporters as exporters_pkg + import paperbot.infrastructure.exporters.obsidian_exporter as exporter_module + import paperbot.infrastructure.stores.research_store as research_store_module + + monkeypatch.setattr(research_store_module, "SqlAlchemyResearchStore", _FakeResearchStore) + monkeypatch.setattr(exporters_pkg, "ObsidianFilesystemExporter", _FakeExporter) + monkeypatch.setattr(exporter_module, "ObsidianFilesystemExporter", _FakeExporter) + monkeypatch.setattr( + cli_main, + "create_settings", + lambda: SimpleNamespace( + obsidian=SimpleNamespace( + vault_path="/tmp/my-vault", + root_dir="PaperBot", + paper_template_path=None, + ) + ), + ) + + exit_code = cli_main.run_cli( + [ + "export", + "obsidian", + "--track-name", + "ICL Compression", + "--limit", + "5", + "--json", + ] + ) + captured = capsys.readouterr() + + assert exit_code == 0 + payload = json.loads(captured.out) + assert payload["vault_path"] == "/tmp/my-vault" diff --git a/tests/unit/test_obsidian_exporter.py b/tests/unit/test_obsidian_exporter.py index 0b342c09..d0022329 100644 --- a/tests/unit/test_obsidian_exporter.py +++ b/tests/unit/test_obsidian_exporter.py @@ -71,6 +71,61 @@ def test_export_library_snapshot_writes_paper_track_and_moc_notes(tmp_path: Path assert "[[PaperBot/Papers/2026-uniicl-2601-12345|UniICL]]" in moc_body +def test_export_library_snapshot_supports_custom_template_and_related_links(tmp_path: Path): + vault = tmp_path / "vault" + vault.mkdir() + template_path = tmp_path / "paper_note.md.j2" + template_path.write_text( + ( + "{{ frontmatter }}\n" + "# {{ title }}\n" + "Track Link: {{ track_link }}\n" + "{% for link in related_links %}- {{ link }}\n{% endfor %}" + ), + encoding="utf-8", + ) + + exporter = ObsidianFilesystemExporter(paper_template_path=template_path) + result = exporter.export_library_snapshot( + vault_path=vault, + saved_items=[ + { + "saved_at": "2026-03-11T11:00:00+00:00", + "paper": { + "id": 1, + "title": "UniICL", + "authors": ["Alice Smith"], + "abstract": "Compresses in-context examples.", + "year": 2026, + "venue": "ICLR", + "semantic_scholar_id": "S2-UNIICL", + "citation_count": 12, + "related_papers": [ + {"title": "Prompt Compression Survey", "year": 2025}, + "Context Distillation for LLMs", + ], + }, + } + ], + track={ + "id": 7, + "user_id": "default", + "name": "ICL Compression", + "keywords": ["ICL"], + "methods": [], + "venues": [], + "is_active": True, + }, + ) + + paper_note = Path(result["paper_notes"][0]).read_text(encoding="utf-8") + assert "related_papers:" in paper_note + assert "Prompt Compression Survey" in paper_note + assert "[[PaperBot/Papers/2025-prompt-compression-survey|Prompt Compression Survey]]" in paper_note + assert "[[PaperBot/Papers/context-distillation-for-llms|Context Distillation for LLMs]]" in paper_note + assert "[[PaperBot/Tracks/icl-compression|ICL Compression]]" in paper_note + + def test_export_library_snapshot_requires_existing_vault_directory(tmp_path: Path): exporter = ObsidianFilesystemExporter() missing_vault = tmp_path / "missing-vault" diff --git a/tests/unit/test_obsidian_sync.py b/tests/unit/test_obsidian_sync.py new file mode 100644 index 00000000..9aea337b --- /dev/null +++ b/tests/unit/test_obsidian_sync.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from paperbot.infrastructure.exporters import obsidian_sync + + +class _FakeResearchStore: + def __init__(self) -> None: + self.closed = False + + def get_track(self, *, user_id: str, track_id: int): + assert user_id == "default" + assert track_id == 7 + return {"id": 7, "user_id": "default", "name": "ICL Compression"} + + def list_saved_papers(self, *, user_id: str, track_id: int, limit: int): + assert user_id == "default" + assert track_id == 7 + assert limit == 25 + return [{"paper": {"id": 1, "title": "UniICL"}}] + + def close(self) -> None: + self.closed = True + + +def test_export_track_snapshot_uses_obsidian_settings(monkeypatch, tmp_path: Path): + captured: dict[str, object] = {} + vault_dir = tmp_path / "vault" + vault_dir.mkdir() + + class _FakeExporter: + def __init__(self, *, paper_template_path=None): + captured["template_path"] = paper_template_path + + def export_library_snapshot(self, *, vault_path, saved_items, track, root_dir): + captured["vault_path"] = Path(vault_path) + captured["saved_items"] = saved_items + captured["track"] = track + captured["root_dir"] = root_dir + return {"paper_count": len(saved_items)} + + monkeypatch.setattr( + obsidian_sync, + "create_settings", + lambda: SimpleNamespace( + obsidian=SimpleNamespace( + enabled=True, + vault_path=str(vault_dir), + root_dir="PaperBot Notes", + paper_template_path=str(tmp_path / "paper.md.j2"), + export_limit=25, + auto_export_on_save=True, + auto_sync_tracks=True, + ) + ), + ) + monkeypatch.setattr(obsidian_sync, "SqlAlchemyResearchStore", _FakeResearchStore) + monkeypatch.setattr(obsidian_sync, "ObsidianFilesystemExporter", _FakeExporter) + + result = obsidian_sync.export_track_snapshot(user_id="default", track_id=7) + + assert result == {"paper_count": 1} + assert captured["vault_path"] == vault_dir + assert captured["root_dir"] == "PaperBot Notes" + assert captured["track"] == {"id": 7, "user_id": "default", "name": "ICL Compression"} + assert captured["saved_items"] == [{"paper": {"id": 1, "title": "UniICL"}}] + assert captured["template_path"] == (tmp_path / "paper.md.j2") + + +def test_obsidian_auto_export_enabled_requires_vault_path(monkeypatch): + monkeypatch.setattr( + obsidian_sync, + "create_settings", + lambda: SimpleNamespace( + obsidian=SimpleNamespace( + enabled=True, + vault_path="", + root_dir="PaperBot", + paper_template_path=None, + export_limit=10, + auto_export_on_save=True, + auto_sync_tracks=True, + ) + ), + ) + + assert obsidian_sync.obsidian_auto_export_enabled() is False + assert obsidian_sync.obsidian_auto_export_enabled(for_tracks=True) is False diff --git a/tests/unit/test_research_feedback_state.py b/tests/unit/test_research_feedback_state.py index 13180b40..c2f5fcd7 100644 --- a/tests/unit/test_research_feedback_state.py +++ b/tests/unit/test_research_feedback_state.py @@ -126,3 +126,44 @@ def test_unsave_clears_feed_saved_flags(tmp_path: Path): assert item["latest_feedback_action"] is None assert item["is_saved"] is False + + +def test_feedback_route_schedules_obsidian_export_for_save_and_unsave(tmp_path: Path, monkeypatch): + store, paper, track = _prepare_feedback_state_db(tmp_path) + monkeypatch.setattr(research_route, "_research_store", store) + + captured: list[dict[str, object]] = [] + monkeypatch.setattr( + research_route, + "_schedule_obsidian_export", + lambda background_tasks, *, user_id, track_id, for_tracks=False: captured.append( + {"user_id": user_id, "track_id": track_id, "for_tracks": for_tracks} + ), + ) + + with TestClient(api_main.app) as client: + saved = client.post( + "/api/research/papers/feedback", + json={ + "user_id": "u-feedback", + "track_id": int(track["id"]), + "paper_id": str(paper["id"]), + "action": "save", + }, + ) + unsaved = client.post( + "/api/research/papers/feedback", + json={ + "user_id": "u-feedback", + "track_id": int(track["id"]), + "paper_id": str(paper["id"]), + "action": "unsave", + }, + ) + + assert saved.status_code == 200 + assert unsaved.status_code == 200 + assert captured == [ + {"user_id": "u-feedback", "track_id": int(track["id"]), "for_tracks": False}, + {"user_id": "u-feedback", "track_id": int(track["id"]), "for_tracks": False}, + ] diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 0eae0d61..d901ab9d 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -38,6 +38,24 @@ def test_from_dict_preserves_defaults_for_partial_dict_sections() -> None: assert settings.collab["host"]["temperature"] == 0.3 +def test_from_dict_loads_obsidian_section() -> None: + settings = Settings.from_dict( + { + "obsidian": { + "enabled": True, + "vault_path": "/tmp/vault", + "root_dir": "Research Notes", + "paper_template_path": "/tmp/paper.md.j2", + } + } + ) + + assert settings.obsidian.enabled is True + assert settings.obsidian.vault_path == "/tmp/vault" + assert settings.obsidian.root_dir == "Research Notes" + assert settings.obsidian.paper_template_path == "/tmp/paper.md.j2" + + def test_load_from_file_merges_partial_nested_dicts(tmp_path: Path) -> None: config_path = tmp_path / "settings.yaml" config_path.write_text( @@ -82,3 +100,24 @@ def test_from_dict_raises_validation_error_for_invalid_nested_section_types( Settings.from_dict(payload) assert field_name in str(excinfo.value) + + +def test_load_environment_variables_overrides_obsidian(monkeypatch: pytest.MonkeyPatch) -> None: + settings = Settings() + monkeypatch.setenv("PAPERBOT_OBSIDIAN_ENABLED", "true") + monkeypatch.setenv("PAPERBOT_OBSIDIAN_VAULT_PATH", "/tmp/obsidian-vault") + monkeypatch.setenv("PAPERBOT_OBSIDIAN_ROOT_DIR", "PaperBot Notes") + monkeypatch.setenv("PAPERBOT_OBSIDIAN_PAPER_TEMPLATE", "/tmp/paper.md.j2") + monkeypatch.setenv("PAPERBOT_OBSIDIAN_AUTO_EXPORT", "false") + monkeypatch.setenv("PAPERBOT_OBSIDIAN_AUTO_SYNC_TRACKS", "false") + monkeypatch.setenv("PAPERBOT_OBSIDIAN_EXPORT_LIMIT", "42") + + settings.load_environment_variables() + + assert settings.obsidian.enabled is True + assert settings.obsidian.vault_path == "/tmp/obsidian-vault" + assert settings.obsidian.root_dir == "PaperBot Notes" + assert settings.obsidian.paper_template_path == "/tmp/paper.md.j2" + assert settings.obsidian.auto_export_on_save is False + assert settings.obsidian.auto_sync_tracks is False + assert settings.obsidian.export_limit == 42