From 39dd86ac7ce912950996144ccb009fe4f743b9a5 Mon Sep 17 00:00:00 2001 From: ahmed Date: Thu, 23 Jul 2026 18:13:15 -0400 Subject: [PATCH 1/5] Support answering stage questions and bulk article customizations --- rayyan/reviews/customizations.py | 142 ++++++++++++++++++++++++++++++- setup.cfg | 2 +- 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/rayyan/reviews/customizations.py b/rayyan/reviews/customizations.py index a274e58..6ed14f6 100644 --- a/rayyan/reviews/customizations.py +++ b/rayyan/reviews/customizations.py @@ -1,18 +1,152 @@ -from typing import Any, Dict +import json +from typing import Any, Dict, List, Optional, Union from rayyan.paths import REVIEWS_ROUTE +# Customization keys for exclusion reasons are namespaced with this prefix. +EXCLUSION_REASON_PREFIX = "__EXR__" + +# Value used to remove a customization (label/reason) from an article. +REMOVE_VALUE = -1 + + +def _to_article_ids_list(article_ids: Union[str, List[Any]]) -> List[Any]: + """Normalize article IDs (list/tuple or comma-separated string) to a list.""" + if isinstance(article_ids, (list, tuple)): + return list(article_ids) + return [part.strip() for part in str(article_ids).split(",") if part.strip()] + class ReviewCustomizations: - def customize(self, id: int, article_id: int, plan: Dict[str, Any]) -> Dict[str, Any]: + def customize( + self, + id: int, + article_id: int, + plan: Dict[str, Any], + scope: Optional[str] = None, + allow_string_value: Optional[bool] = None, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = {"article_id": article_id, "plan": plan} + if scope is not None: + payload["scope"] = scope + if allow_string_value is not None: + payload["allow_string_value"] = allow_string_value return self._request( "POST", f"{REVIEWS_ROUTE}/{id}/customize", - payload={"article_id": article_id, "plan": plan}, + payload=payload, ) - def bulk_customizations(self, id: int, key: str, value: int, article_ids: str) -> Dict[str, Any]: + def answer_question( + self, + id: int, + article_id: int, + scope: str, + answer: Union[str, List[str]], + key: str = "included", + allow_string_value: bool = True, + ) -> Dict[str, Any]: + """Answer a data-extraction / screening question for a single article. + + ``scope`` is the question's ``answer_key`` (of the form + ``__SYSTEM__STAGE____QUESTION__``). ``answer`` is + the value (or list of values) to record; it is encoded into the ``plan`` + payload the API expects, i.e. ``{key: '[{"text": }, ...]'}``. + """ + answers = [answer] if isinstance(answer, str) else list(answer) + plan = {key: json.dumps([{"text": a} for a in answers])} + return self.customize( + id=id, + article_id=article_id, + plan=plan, + scope=scope, + allow_string_value=allow_string_value, + ) + + def bulk_customizations( + self, + id: int, + key: str, + value: int, + article_ids: Union[str, List[Any]], + ) -> Dict[str, Any]: + """Apply a customization (e.g. a screening decision) to many articles at once. + + ``article_ids`` may be a list/tuple of IDs or an already comma-separated + string; it is normalized to the comma-separated string the API expects. + For screening decisions, ``key`` is typically ``"included"`` and ``value`` + is ``1`` (include), ``0`` (maybe) or ``-1`` (exclude). + """ + if isinstance(article_ids, (list, tuple)): + article_ids = ", ".join(str(article_id) for article_id in article_ids) params = {"key": key, "value": value, "article_ids": article_ids} return self._request("POST", f"{REVIEWS_ROUTE}/{id}/customize", params=params) + def _toggle_customization( + self, + id: int, + article_ids: Union[str, List[Any]], + key: str, + value: int, + ) -> Union[Dict[str, Any], List[Dict[str, Any]]]: + """Add or remove a keyed customization (label/reason) on articles. + + Adding uses the bulk endpoint (``{article_ids, key, value}``) in a single + request. Removing uses the per-article ``plan`` form + (``{article_id, plan: {key: value}}``) applied to each article, since the + remove flow is not a bulk operation on the API. + """ + if value > REMOVE_VALUE: + return self.bulk_customizations( + id=id, + key=key, + value=value, + article_ids=article_ids, + ) + return [ + self.customize(id=id, article_id=article_id, plan={key: value}) + for article_id in _to_article_ids_list(article_ids) + ] + + def set_label( + self, + id: int, + article_ids: Union[str, List[Any]], + label: str, + value: int = 1, + ) -> Union[Dict[str, Any], List[Dict[str, Any]]]: + """Add or remove a label on one or more articles. + + ``label`` is the label name, used as the customization ``key``. ``value`` + is ``1`` to add the label and ``-1`` to remove it. ``article_ids`` may be + a list/tuple of IDs or an already comma-separated string. + """ + return self._toggle_customization( + id=id, + article_ids=article_ids, + key=label, + value=value, + ) + + def set_reason( + self, + id: int, + article_ids: Union[str, List[Any]], + reason: str, + value: int = 1, + ) -> Union[Dict[str, Any], List[Dict[str, Any]]]: + """Add or remove an exclusion reason on one or more articles. + + ``reason`` is the reason text; it is namespaced with + ``EXCLUSION_REASON_PREFIX`` to form the customization ``key``. ``value`` + is ``1`` to add the reason and ``-1`` to remove it. ``article_ids`` may be + a list/tuple of IDs or an already comma-separated string. + """ + return self._toggle_customization( + id=id, + article_ids=article_ids, + key=f"{EXCLUSION_REASON_PREFIX}{reason}", + value=value, + ) + def get_customizations(self, id: int, params: Dict[str, Any]) -> Dict[str, Any]: return self._request("GET", f"{REVIEWS_ROUTE}/{id}/customizations", params=params) \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 726f675..14c1544 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = rayyan_sdk -version = 1.0rc16 +version = 1.0rc17 description = A Python SDK for Rayyan APIs long_description = file: README.md long_description_content_type = text/markdown From 5f7aaf57d2b7e8cde5cf954724c263850c1ec15c Mon Sep 17 00:00:00 2001 From: ahmed Date: Thu, 23 Jul 2026 18:30:59 -0400 Subject: [PATCH 2/5] cleanup --- rayyan/reviews/customizations.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rayyan/reviews/customizations.py b/rayyan/reviews/customizations.py index 6ed14f6..f5a9301 100644 --- a/rayyan/reviews/customizations.py +++ b/rayyan/reviews/customizations.py @@ -45,9 +45,10 @@ def answer_question( key: str = "included", allow_string_value: bool = True, ) -> Dict[str, Any]: - """Answer a data-extraction / screening question for a single article. + """Answer a data-extraction question for a single article. - ``scope`` is the question's ``answer_key`` (of the form + The question belongs to a stage, so ``scope`` is the question's + ``answer_key`` (of the form ``__SYSTEM__STAGE____QUESTION__``). ``answer`` is the value (or list of values) to record; it is encoded into the ``plan`` payload the API expects, i.e. ``{key: '[{"text": }, ...]'}``. From d5c53b8e0ece32661251d172ab6144a6d62f8b78 Mon Sep 17 00:00:00 2001 From: ahmed Date: Mon, 27 Jul 2026 10:33:58 -0400 Subject: [PATCH 3/5] rename toggle to set: aboelnoor suggestion --- rayyan/reviews/customizations.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/rayyan/reviews/customizations.py b/rayyan/reviews/customizations.py index f5a9301..b74fa85 100644 --- a/rayyan/reviews/customizations.py +++ b/rayyan/reviews/customizations.py @@ -82,7 +82,7 @@ def bulk_customizations( params = {"key": key, "value": value, "article_ids": article_ids} return self._request("POST", f"{REVIEWS_ROUTE}/{id}/customize", params=params) - def _toggle_customization( + def _set_customization( self, id: int, article_ids: Union[str, List[Any]], @@ -91,6 +91,7 @@ def _toggle_customization( ) -> Union[Dict[str, Any], List[Dict[str, Any]]]: """Add or remove a keyed customization (label/reason) on articles. + This sets the given ``key`` to ``value``. Adding uses the bulk endpoint (``{article_ids, key, value}``) in a single request. Removing uses the per-article ``plan`` form (``{article_id, plan: {key: value}}``) applied to each article, since the @@ -121,7 +122,7 @@ def set_label( is ``1`` to add the label and ``-1`` to remove it. ``article_ids`` may be a list/tuple of IDs or an already comma-separated string. """ - return self._toggle_customization( + return self._set_customization( id=id, article_ids=article_ids, key=label, @@ -142,7 +143,7 @@ def set_reason( is ``1`` to add the reason and ``-1`` to remove it. ``article_ids`` may be a list/tuple of IDs or an already comma-separated string. """ - return self._toggle_customization( + return self._set_customization( id=id, article_ids=article_ids, key=f"{EXCLUSION_REASON_PREFIX}{reason}", @@ -150,4 +151,4 @@ def set_reason( ) def get_customizations(self, id: int, params: Dict[str, Any]) -> Dict[str, Any]: - return self._request("GET", f"{REVIEWS_ROUTE}/{id}/customizations", params=params) \ No newline at end of file + return self._request("GET", f"{REVIEWS_ROUTE}/{id}/customizations", params=params) From 871f737c812c1966d5e9385abea4ae2d8ea594a1 Mon Sep 17 00:00:00 2001 From: ahmed Date: Mon, 27 Jul 2026 11:05:35 -0400 Subject: [PATCH 4/5] move scope construction to the sdk and have the template in a constant: Aboelnoor --- rayyan/reviews/customizations.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/rayyan/reviews/customizations.py b/rayyan/reviews/customizations.py index b74fa85..ac63dec 100644 --- a/rayyan/reviews/customizations.py +++ b/rayyan/reviews/customizations.py @@ -8,6 +8,17 @@ # Value used to remove a customization (label/reason) from an article. REMOVE_VALUE = -1 +# Template for a data-extraction question's answer_key (the customization +# "scope"), built from its stage and question IDs. +STAGE_QUESTION_ANSWER_KEY_TEMPLATE = "__SYSTEM__STAGE__{stage_id}__QUESTION__{question_id}" + + +def stage_question_answer_key(stage_id: Any, question_id: Any) -> str: + """Build a data-extraction question's answer_key (scope) from its IDs.""" + return STAGE_QUESTION_ANSWER_KEY_TEMPLATE.format( + stage_id=stage_id, question_id=question_id + ) + def _to_article_ids_list(article_ids: Union[str, List[Any]]) -> List[Any]: """Normalize article IDs (list/tuple or comma-separated string) to a list.""" @@ -40,18 +51,19 @@ def answer_question( self, id: int, article_id: int, - scope: str, + stage_id: Any, + question_id: Any, answer: Union[str, List[str]], key: str = "included", allow_string_value: bool = True, ) -> Dict[str, Any]: """Answer a data-extraction question for a single article. - The question belongs to a stage, so ``scope`` is the question's - ``answer_key`` (of the form - ``__SYSTEM__STAGE____QUESTION__``). ``answer`` is - the value (or list of values) to record; it is encoded into the ``plan`` - payload the API expects, i.e. ``{key: '[{"text": }, ...]'}``. + The question is identified by its ``stage_id`` and ``question_id``, from + which the customization ``scope`` (the question's answer_key) is built. + ``answer`` is the value (or list of values) to record; it is encoded into + the ``plan`` payload the API expects, i.e. + ``{key: '[{"text": }, ...]'}``. """ answers = [answer] if isinstance(answer, str) else list(answer) plan = {key: json.dumps([{"text": a} for a in answers])} @@ -59,7 +71,7 @@ def answer_question( id=id, article_id=article_id, plan=plan, - scope=scope, + scope=stage_question_answer_key(stage_id, question_id), allow_string_value=allow_string_value, ) From 0681ea5bb3412d553d30bb4998fcd4ba9fb60d2b Mon Sep 17 00:00:00 2001 From: ahmed Date: Mon, 27 Jul 2026 15:37:53 -0400 Subject: [PATCH 5/5] fix stage decisions --- rayyan/reviews/customizations.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/rayyan/reviews/customizations.py b/rayyan/reviews/customizations.py index ac63dec..4b4ab04 100644 --- a/rayyan/reviews/customizations.py +++ b/rayyan/reviews/customizations.py @@ -8,16 +8,18 @@ # Value used to remove a customization (label/reason) from an article. REMOVE_VALUE = -1 -# Template for a data-extraction question's answer_key (the customization -# "scope"), built from its stage and question IDs. -STAGE_QUESTION_ANSWER_KEY_TEMPLATE = "__SYSTEM__STAGE__{stage_id}__QUESTION__{question_id}" +# Template for a stage-level customization "scope", built from a stage ID. +STAGE_SCOPE_TEMPLATE = "__SYSTEM__STAGE__{stage_id}" + + +def stage_scope(stage_id: Any) -> str: + """Build a stage-level customization scope from its stage ID.""" + return STAGE_SCOPE_TEMPLATE.format(stage_id=stage_id) def stage_question_answer_key(stage_id: Any, question_id: Any) -> str: """Build a data-extraction question's answer_key (scope) from its IDs.""" - return STAGE_QUESTION_ANSWER_KEY_TEMPLATE.format( - stage_id=stage_id, question_id=question_id - ) + return f"{stage_scope(stage_id)}__QUESTION__{question_id}" def _to_article_ids_list(article_ids: Union[str, List[Any]]) -> List[Any]: @@ -81,6 +83,7 @@ def bulk_customizations( key: str, value: int, article_ids: Union[str, List[Any]], + stage_id: Optional[Any] = None, ) -> Dict[str, Any]: """Apply a customization (e.g. a screening decision) to many articles at once. @@ -88,10 +91,15 @@ def bulk_customizations( string; it is normalized to the comma-separated string the API expects. For screening decisions, ``key`` is typically ``"included"`` and ``value`` is ``1`` (include), ``0`` (maybe) or ``-1`` (exclude). + + When ``stage_id`` is given, the customization is scoped to that stage (so + the decision is recorded for the stage rather than at the review level). """ if isinstance(article_ids, (list, tuple)): article_ids = ", ".join(str(article_id) for article_id in article_ids) - params = {"key": key, "value": value, "article_ids": article_ids} + params: Dict[str, Any] = {"key": key, "value": value, "article_ids": article_ids} + if stage_id is not None: + params["scope"] = stage_scope(stage_id) return self._request("POST", f"{REVIEWS_ROUTE}/{id}/customize", params=params) def _set_customization(