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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ To skip this entirely, use Gemini to curate automatically instead:
2. Get a free API key at [aistudio.google.com/app/api-keys](https://aistudio.google.com/app/api-keys)
3. Run with `--curation-backend gemini --curation-api-key YOUR_KEY`, or set it once via `$env:GOOGLE_API_KEY="YOUR_KEY"` (PowerShell) / `export GOOGLE_API_KEY=YOUR_KEY` (bash) and just pass `--curation-backend gemini`

**If this step appears to hang with nothing printing** (v3.0.2+): CANDy now logs before and after the Gemini request, and bounds each attempt to a 60-second timeout, so a multi-minute wait during retries is visible and finite rather than silent. On an older version, this step had no request timeout at all and could hang indefinitely on a stalled connection with zero output -- if you hit this, `Ctrl+C`, upgrade (`pip install --upgrade candy-cazyme`), and rerun.

### Python API

```python
Expand Down
20 changes: 19 additions & 1 deletion src/candy/curation/gemini.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
from __future__ import annotations

import ast
import logging
import os

logger = logging.getLogger(__name__)

# Bounds a single request attempt so a hung/slow connection can't stall the
# pipeline indefinitely and invisibly -- the underlying SDK retries up to 5
# times by default (on 408/429/5xx) via tenacity, which does not log
# anything itself, so without this a stall here can look identical to a
# genuine hang even though it's just silently backing off and retrying.
_REQUEST_TIMEOUT_MS = 60_000

_PROMPT_TEMPLATE = (
"I have the following list of protein domains, retrieved from InterPro's member "
"databases: {domain_names}. These sequences {family_clause}. I want you to group "
Expand Down Expand Up @@ -40,6 +50,7 @@ def __init__(self, api_key: str | None = None, model: str = "gemini-flash-latest
def curate(self, domain_names: list[str], *, family: str | None = None) -> dict[str, list[str]]:
try:
from google import genai
from google.genai import types
except ImportError as exc:
raise ImportError(
"Gemini curation requires the optional 'gemini' extra: "
Expand All @@ -51,8 +62,15 @@ def curate(self, domain_names: list[str], *, family: str | None = None) -> dict[
)
prompt = _PROMPT_TEMPLATE.format(domain_names=domain_names, family_clause=family_clause)

client = genai.Client(api_key=self.api_key)
logger.info(
"Requesting domain-name curation from Gemini (model=%s, %d domain names). This can take "
"a while if the API is transiently retrying -- it isn't stuck if it takes a few minutes.",
self.model,
len(domain_names),
)
client = genai.Client(api_key=self.api_key, http_options=types.HttpOptions(timeout=_REQUEST_TIMEOUT_MS))
response = client.models.generate_content(model=self.model, contents=prompt)
logger.info("Received curation response from Gemini.")

cleaned = response.text.replace("python", "").replace("```", "")
return ast.literal_eval(cleaned)
35 changes: 33 additions & 2 deletions tests/test_curation.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from unittest.mock import MagicMock, patch

import pytest

from candy.curation import get_curation_backend
from candy.curation.manual import ManualCurationBackend

Expand Down Expand Up @@ -43,7 +47,34 @@ def test_get_curation_backend_returns_manual():


def test_get_curation_backend_unknown_raises():
import pytest

with pytest.raises(ValueError):
get_curation_backend("unknown-backend")


def test_gemini_curate_bounds_request_with_an_explicit_timeout_and_logs_progress(caplog):
# Regression test: a real run appeared to "hang" with zero output during
# curation. The SDK's own retry loop (tenacity, on 408/429/5xx) doesn't
# log anything, and no request timeout was set, so a stall here was
# indistinguishable from a genuine hang. Assert both fixes: an explicit
# timeout is passed to the client, and progress is logged before/after.
pytest.importorskip("google.genai")
from candy.curation.gemini import GeminiCurationBackend

fake_response = MagicMock()
fake_response.text = "{'Catalytic domain': ['A', 'B']}"
fake_client = MagicMock()
fake_client.models.generate_content.return_value = fake_response

with patch("google.genai.Client", return_value=fake_client) as mock_client_cls, caplog.at_level("INFO"):
backend = GeminiCurationBackend(api_key="fake-key")
result = backend.curate(["A", "B"], family="GH173")

assert result == {"Catalytic domain": ["A", "B"]}

_, kwargs = mock_client_cls.call_args
assert kwargs["api_key"] == "fake-key"
assert kwargs["http_options"].timeout == 60_000

messages = [record.message for record in caplog.records]
assert any("Requesting domain-name curation from Gemini" in m for m in messages)
assert any("Received curation response from Gemini" in m for m in messages)
Loading