Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion conflens/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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(),
Expand All @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion conflens/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
56 changes: 49 additions & 7 deletions conflens/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,28 +240,40 @@ 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
self.cache_dir = cache_dir or os.path.join(
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

Expand Down Expand Up @@ -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.)",
Expand All @@ -778,16 +801,35 @@ 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.
return AnthologyScraper(base_url=base_url, cache_dir=cache_dir)
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"):
Expand Down
47 changes: 46 additions & 1 deletion tests/test_openreview_source.py
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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"
Loading