diff --git a/conflens/app.py b/conflens/app.py index 994e9bc..1e63adc 100644 --- a/conflens/app.py +++ b/conflens/app.py @@ -16,7 +16,7 @@ from .llm import DEFAULT_MODELS, MODEL_SUGGESTIONS, PROVIDERS, env_key_for from .models import AnalysisResult from .pipeline import AnalysisConfig, Progress, run_analysis -from .sources import SOURCES +from .sources import SOURCES, auth_fields from .view import TOPIC_COLORS # shared with the Gradio front-end # Sober, professional palette ------------------------------------------------ @@ -56,6 +56,9 @@ def __init__(self) -> None: self.author_select: Optional[ui.select] = None self.conf_view: Optional[ui.slider] = None self.conf_view_label: Optional[ui.label] = None + # Source-credential inputs, keyed by env-var name (populated per source). + self.auth_container: Optional[ui.column] = None + self.auth_inputs: dict = {} # ------------------------------------------------------------------ # # Layout @@ -115,6 +118,10 @@ def _build_config(self) -> None: value=SOURCES["aclanthology"]["target"], ).props("outlined dense").style("flex:1 1 200px;") self.source.on_value_change(lambda e: self._on_source_change(e.value)) + # Source-credential fields — rendered only for sources that need them + # (e.g. OpenReview); public sources (ACL, EMNLP, IJCAI, …) show nothing. + self.auth_container = ui.column().classes("w-full").style("gap:8px;") + self._render_auth_fields("aclanthology") with ui.row().classes("w-full").style("gap:16px; flex-wrap:wrap;"): self.theme = ui.input("Theme", value="Agentic AI").props( "outlined dense" @@ -222,6 +229,35 @@ def _on_source_change(self, source: str) -> None: self.base_url.props(f'label="{cfg["base_label"]}"') self.event.set_value(cfg["target"]) self.event.props(f'label="{cfg["target_label"]}"') + self._render_auth_fields(source) + + def _render_auth_fields(self, source: str) -> None: + """(Re)build the credential inputs — only for sources that need them.""" + fields = auth_fields(source) + self.auth_container.clear() + self.auth_inputs = {} + if not fields: + self.auth_container.style("display:none;") + return + self.auth_container.style("display:flex;") + label = SOURCES.get(source, {}).get("label", source) + with self.auth_container: + ui.label(f"{label} — authentication required").style( + f"font-weight:600; color:{INK}; font-size:.85rem;" + ) + with ui.row().classes("w-full items-center").style("gap:16px; flex-wrap:wrap;"): + for f in fields: + props = "outlined dense clearable" + if f.get("secret"): + props += " type=password" + inp = ui.input(f["label"]).props(props).style("flex:1 1 240px;") + if f.get("help"): + inp.tooltip(f["help"]) + self.auth_inputs[f["env"]] = inp + ui.label( + "Leave a field blank to use its environment variable if set " + f"({', '.join(f['env'] for f in fields)})." + ).classes("ca-muted").style("font-size:.76rem;") def _on_provider_change(self, provider: str) -> None: """Update the default model, endpoint relevance and key hint per provider.""" @@ -305,6 +341,11 @@ async def start(self) -> None: self.elapsed_label.set_text("0:00") self.log_area.clear() + source_auth = { + env: (inp.value or "").strip() + for env, inp in self.auth_inputs.items() + if (inp.value or "").strip() + } cfg = AnalysisConfig( source=self.source.value, base_url=self.base_url.value.strip(), @@ -320,6 +361,7 @@ async def start(self) -> None: min_confidence=float(self.min_conf.value), topic_backend=self.backend.value, refresh=bool(self.refresh.value), + source_auth=source_auth, ) self.timer = ui.timer(0.25, self._tick) diff --git a/conflens/pipeline.py b/conflens/pipeline.py index 450141d..017a35f 100644 --- a/conflens/pipeline.py +++ b/conflens/pipeline.py @@ -55,6 +55,7 @@ class AnalysisConfig: min_confidence: float = 0.5 topic_backend: str = "llm" # "llm" | "bertopic" refresh: bool = False # bypass the scrape cache and refetch from source + source_auth: dict = field(default_factory=dict) # per-source creds (OpenReview) def run_analysis( @@ -63,7 +64,9 @@ def run_analysis( cache_dir: Optional[str] = None, ) -> AnalysisResult: """Execute the full pipeline. Designed to run in a worker thread.""" - scraper = make_source(cfg.source, base_url=cfg.base_url, cache_dir=cache_dir) + scraper = make_source( + cfg.source, base_url=cfg.base_url, cache_dir=cache_dir, auth=cfg.source_auth + ) event_url = scraper.resolve_url(cfg.event) result = AnalysisResult( theme=cfg.theme, event_url=event_url, min_confidence=cfg.min_confidence diff --git a/conflens/sources.py b/conflens/sources.py index 2aa3201..ff87d83 100644 --- a/conflens/sources.py +++ b/conflens/sources.py @@ -240,6 +240,7 @@ def __init__( base_url: str = "https://api2.openreview.net", cache_dir: Optional[str] = None, timeout: int = 120, + auth: Optional[dict] = None, ) -> None: self.base_url = (base_url or "https://api2.openreview.net").rstrip("/") self.timeout = timeout @@ -247,21 +248,32 @@ def __init__( os.path.expanduser("~"), ".cache", "conflens" ) os.makedirs(self.cache_dir, exist_ok=True) + # Credentials passed from the GUI (keyed by env-var name); empty values + # dropped so we fall back to the process environment. + self._auth = {k: v.strip() for k, v in (auth or {}).items() if v and v.strip()} self._tok: Optional[str] = None # None = not yet resolved; "" = anonymous # -- authentication (optional) ----------------------------------------- # Anonymous access to recent (API v2) venues is challenged from some IPs; # a bearer token from OPENREVIEW_TOKEN, or a login with # OPENREVIEW_USERNAME/OPENREVIEW_PASSWORD, bypasses that. + def _cred(self, *names: str) -> str: + """First non-empty credential across the GUI-supplied auth then the env.""" + for n in names: + v = self._auth.get(n) or os.environ.get(n) + if v and v.strip(): + return v.strip() + return "" + def _token(self) -> Optional[str]: if self._tok is not None: return self._tok or None - direct = os.environ.get("OPENREVIEW_TOKEN") + direct = self._cred("OPENREVIEW_TOKEN") if direct: - self._tok = direct.strip() + self._tok = direct return self._tok - user = os.environ.get("OPENREVIEW_USERNAME") or os.environ.get("OPENREVIEW_EMAIL") - pw = os.environ.get("OPENREVIEW_PASSWORD") + user = self._cred("OPENREVIEW_USERNAME", "OPENREVIEW_EMAIL") + pw = self._cred("OPENREVIEW_PASSWORD") self._tok = (self._login(user, pw) or "") if (user and pw) else "" return self._tok or None @@ -760,6 +772,17 @@ def enrich_abstracts( "target": "ICLR.cc/2024/Conference", "base_label": "OpenReview API base", "target_label": "Venue ID (e.g. ICLR.cc/2024/Conference, NeurIPS.cc/2024/Conference)", + # OpenReview now challenges anonymous note queries, so this source needs + # credentials. Surfaced in the GUI (keyed by env-var name); a bearer + # token OR username + password. Empty fields fall back to the environment. + "auth_fields": [ + {"env": "OPENREVIEW_TOKEN", "label": "OpenReview token", "secret": True, + "help": "Bearer token (alternative to username + password)."}, + {"env": "OPENREVIEW_USERNAME", "label": "OpenReview username / email", + "secret": False, "help": "Your OpenReview login (with the password below)."}, + {"env": "OPENREVIEW_PASSWORD", "label": "OpenReview password", "secret": True, + "help": "Used with the username to log in."}, + ], }, "pscc": { "label": "PSCC (Power Systems Computation Conf.)", @@ -778,8 +801,27 @@ def enrich_abstracts( } -def make_source(source: str, base_url: str, cache_dir: Optional[str] = None): - """Return a source adapter for ``source`` (raises on unknown keys).""" +def auth_fields(source: str) -> list[dict]: + """Credential fields a source needs (empty for public sources). + + Used by the GUI to render authentication inputs only for sources that + require them (e.g. OpenReview); public sources (ACL, EMNLP, IJCAI, …) get + none. Each field is ``{env, label, secret, help?}`` keyed by env-var name. + """ + return list(SOURCES.get(source, {}).get("auth_fields", [])) + + +def make_source( + source: str, + base_url: str, + cache_dir: Optional[str] = None, + auth: Optional[dict] = None, +): + """Return a source adapter for ``source`` (raises on unknown keys). + + ``auth`` carries optional per-source credentials (keyed by env-var name); + only sources that need them (OpenReview) use it — others ignore it. + """ if source in ("aclanthology", "emnlp", "naacl"): # EMNLP and NAACL proceedings live on the ACL Anthology; same adapter, # different default event. Any Anthology event slug works for any key. @@ -787,7 +829,7 @@ def make_source(source: str, base_url: str, cache_dir: Optional[str] = None): if source == "ijcai": return IJCAISource(base_url=base_url, cache_dir=cache_dir) if source == "openreview": - return OpenReviewSource(base_url=base_url, cache_dir=cache_dir) + return OpenReviewSource(base_url=base_url, cache_dir=cache_dir, auth=auth) if source == "pscc": return PSCCSource(base_url=base_url, cache_dir=cache_dir) if source in ("isgteurope", "dblp"): diff --git a/tests/test_openreview_source.py b/tests/test_openreview_source.py index eb0ad85..c731da2 100644 --- a/tests/test_openreview_source.py +++ b/tests/test_openreview_source.py @@ -1,4 +1,4 @@ -from conflens.sources import OpenReviewSource, _cv +from conflens.sources import OpenReviewSource, _cv, auth_fields, make_source # API v2 note: content values are wrapped in {"value": …}; PDF is a relative path. NOTE_V2 = { @@ -78,3 +78,48 @@ def test_api_roots_fallback(tmp_path): roots = src._api_roots() assert roots[0] == "https://api2.openreview.net" assert "https://api.openreview.net" in roots # v1 fallback present + + +# -- auth wiring (GUI-supplied credentials) --------------------------------- # +def test_auth_token_from_constructor_sets_bearer(tmp_path): + src = OpenReviewSource(cache_dir=str(tmp_path), auth={"OPENREVIEW_TOKEN": "tok-123"}) + assert src._headers()["Authorization"] == "Bearer tok-123" + + +def test_no_auth_no_env_is_anonymous(tmp_path, monkeypatch): + for v in ("OPENREVIEW_TOKEN", "OPENREVIEW_USERNAME", "OPENREVIEW_EMAIL", "OPENREVIEW_PASSWORD"): + monkeypatch.delenv(v, raising=False) + src = OpenReviewSource(cache_dir=str(tmp_path)) + assert "Authorization" not in src._headers() + + +def test_constructor_auth_overrides_env(tmp_path, monkeypatch): + monkeypatch.setenv("OPENREVIEW_TOKEN", "env-tok") + src = OpenReviewSource(cache_dir=str(tmp_path), auth={"OPENREVIEW_TOKEN": "gui-tok"}) + assert src._headers()["Authorization"] == "Bearer gui-tok" # GUI value wins over env + + +def test_empty_auth_values_ignored_falls_back_to_env(tmp_path, monkeypatch): + monkeypatch.setenv("OPENREVIEW_TOKEN", "env-tok") + src = OpenReviewSource(cache_dir=str(tmp_path), auth={"OPENREVIEW_TOKEN": " "}) + assert src._headers()["Authorization"] == "Bearer env-tok" # blank field → env fallback + + +def test_auth_fields_only_for_openreview(): + assert [f["env"] for f in auth_fields("openreview")] == [ + "OPENREVIEW_TOKEN", "OPENREVIEW_USERNAME", "OPENREVIEW_PASSWORD" + ] + # Public sources declare no credential fields → nothing shown in the GUI. + assert auth_fields("aclanthology") == [] + assert auth_fields("emnlp") == [] + assert auth_fields("ijcai") == [] + assert auth_fields("pscc") == [] + + +def test_make_source_threads_auth_to_openreview(tmp_path): + src = make_source( + "openreview", "https://api2.openreview.net", + cache_dir=str(tmp_path), auth={"OPENREVIEW_TOKEN": "mk-tok"}, + ) + assert isinstance(src, OpenReviewSource) + assert src._headers()["Authorization"] == "Bearer mk-tok"