diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6908dab..c806cff 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,7 +35,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
- os: [ubuntu-latest, macos-13]
+ os: [ubuntu-latest, macos-15-intel]
steps:
- uses: actions/checkout@v4
diff --git a/src/agent/profiles/base.py b/src/agent/profiles/base.py
index 9a6e26c..b11d653 100644
--- a/src/agent/profiles/base.py
+++ b/src/agent/profiles/base.py
@@ -1,4 +1,4 @@
-from typing import Annotated, TypedDict
+from typing import Annotated, Literal, TypedDict
from langchain_core.embeddings import Embeddings
from langchain_core.language_models.chat_models import BaseChatModel
@@ -6,7 +6,9 @@
from langchain_core.runnables import Runnable, RunnableConfig
from langgraph.graph.message import add_messages
+from agent.tasks.detect_language import create_language_detector
from agent.tasks.rephrase import create_rephrase_chain
+from agent.tasks.safety_checker import SafetyCheck, create_safety_checker
from tools.external_search.state import SearchState, WebSearchResult
from tools.external_search.workflow import create_search_workflow
@@ -28,6 +30,11 @@ class BaseState(InputState, OutputState, total=False):
rephrased_input: str # LLM-generated query from user input
chat_history: Annotated[list[BaseMessage], add_messages]
+ # Preprocessing results
+ safety: str # "true" or "false" from safety check
+ reason_unsafe: str # Reason if unsafe
+ detected_language: str # Detected language
+
class BaseGraphBuilder:
# NOTE: Anything that is common to all graph builders goes here
@@ -38,21 +45,40 @@ def __init__(
embedding: Embeddings,
) -> None:
self.rephrase_chain: Runnable = create_rephrase_chain(llm)
+ self.safety_checker: Runnable = create_safety_checker(llm)
+ self.language_detector: Runnable = create_language_detector(llm)
self.search_workflow: Runnable = create_search_workflow(llm)
async def preprocess(self, state: BaseState, config: RunnableConfig) -> BaseState:
rephrased_input: str = await self.rephrase_chain.ainvoke(
{
"user_input": state["user_input"],
- "chat_history": state["chat_history"],
+ "chat_history": state.get("chat_history", []),
},
config,
)
- return BaseState(rephrased_input=rephrased_input)
+ safety_check: SafetyCheck = await self.safety_checker.ainvoke(
+ {"rephrased_input": rephrased_input}, config
+ )
+ detected_language: str = await self.language_detector.ainvoke(
+ {"user_input": state["user_input"]}, config
+ )
+ return BaseState(
+ rephrased_input=rephrased_input,
+ safety=safety_check.safety,
+ reason_unsafe=safety_check.reason_unsafe,
+ detected_language=detected_language,
+ )
+
+ def proceed_with_research(self, state: BaseState) -> Literal["Continue", "Finish"]:
+ return "Continue" if state["safety"] == "true" else "Finish"
async def postprocess(self, state: BaseState, config: RunnableConfig) -> BaseState:
search_results: list[WebSearchResult] = []
- if config["configurable"]["enable_postprocess"]:
+ if (
+ config["configurable"].get("enable_postprocess")
+ and state["safety"] == "true"
+ ):
result: SearchState = await self.search_workflow.ainvoke(
SearchState(
input=state["rephrased_input"],
diff --git a/src/agent/profiles/cross_database.py b/src/agent/profiles/cross_database.py
index 74ef26c..31ab21a 100644
--- a/src/agent/profiles/cross_database.py
+++ b/src/agent/profiles/cross_database.py
@@ -15,16 +15,11 @@
create_uniprot_rewriter_w_reactome
from agent.tasks.cross_database.summarize_reactome_uniprot import \
create_reactome_uniprot_summarizer
-from agent.tasks.detect_language import create_language_detector
-from agent.tasks.safety_checker import SafetyCheck, create_safety_checker
from retrievers.reactome.rag import create_reactome_rag
from retrievers.uniprot.rag import create_uniprot_rag
class CrossDatabaseState(BaseState):
- safety: str # LLM-assessed safety level of the user input
- query_language: str # language of the user input
-
reactome_query: str # LLM-generated query for Reactome
reactome_answer: str # LLM-generated answer from Reactome
reactome_completeness: str # LLM-assessed completeness of the Reactome answer
@@ -46,13 +41,11 @@ def __init__(
self.reactome_rag: Runnable = create_reactome_rag(llm, embedding)
self.uniprot_rag: Runnable = create_uniprot_rag(llm, embedding)
- self.safety_checker = create_safety_checker(llm)
self.completeness_checker = create_completeness_grader(llm)
- self.detect_language = create_language_detector(llm)
self.write_reactome_query = create_reactome_rewriter_w_uniprot(llm)
self.write_uniprot_query = create_uniprot_rewriter_w_reactome(llm)
self.summarize_final_answer = create_reactome_uniprot_summarizer(
- llm.model_copy(update={"streaming": True})
+ llm, streaming=True
)
# Create graph
@@ -60,7 +53,6 @@ def __init__(
# Set up nodes
state_graph.add_node("check_question_safety", self.check_question_safety)
state_graph.add_node("preprocess_question", self.preprocess)
- state_graph.add_node("identify_query_language", self.identify_query_language)
state_graph.add_node("conduct_research", self.conduct_research)
state_graph.add_node("generate_reactome_answer", self.generate_reactome_answer)
state_graph.add_node("rewrite_reactome_query", self.rewrite_reactome_query)
@@ -74,7 +66,6 @@ def __init__(
state_graph.add_node("postprocess", self.postprocess)
# Set up edges
state_graph.set_entry_point("preprocess_question")
- state_graph.add_edge("preprocess_question", "identify_query_language")
state_graph.add_edge("preprocess_question", "check_question_safety")
state_graph.add_conditional_edges(
"check_question_safety",
@@ -104,39 +95,18 @@ def __init__(
self.uncompiled_graph: StateGraph = state_graph
- async def check_question_safety(
+ def check_question_safety(
self, state: CrossDatabaseState, config: RunnableConfig
) -> CrossDatabaseState:
- result: SafetyCheck = await self.safety_checker.ainvoke(
- {"input": state["rephrased_input"]},
- config,
- )
- if result.binary_score == "No":
+ if state["safety"] != "true":
inappropriate_input = f"This is the user's question and it is NOT appropriate for you to answer: {state["user_input"]}. \n\n explain that you are unable to answer the question but you can answer questions about topics related to the Reactome Pathway Knowledgebase or UniProt Knowledgebas."
return CrossDatabaseState(
- safety=result.binary_score,
user_input=inappropriate_input,
reactome_answer="",
uniprot_answer="",
)
else:
- return CrossDatabaseState(safety=result.binary_score)
-
- async def proceed_with_research(
- self, state: CrossDatabaseState
- ) -> Literal["Continue", "Finish"]:
- if state["safety"] == "Yes":
- return "Continue"
- else:
- return "Finish"
-
- async def identify_query_language(
- self, state: CrossDatabaseState, config: RunnableConfig
- ) -> CrossDatabaseState:
- query_language: str = await self.detect_language.ainvoke(
- {"user_input": state["user_input"]}, config
- )
- return CrossDatabaseState(query_language=query_language)
+ return CrossDatabaseState()
async def conduct_research(
self, state: CrossDatabaseState, config: RunnableConfig
@@ -256,7 +226,7 @@ async def generate_final_response(
final_response: str = await self.summarize_final_answer.ainvoke(
{
"input": state["rephrased_input"],
- "query_language": state["query_language"],
+ "detected_language": state["detected_language"],
"reactome_answer": state["reactome_answer"],
"uniprot_answer": state["uniprot_answer"],
},
diff --git a/src/agent/profiles/react_to_me.py b/src/agent/profiles/react_to_me.py
index c162ac7..dab20f0 100644
--- a/src/agent/profiles/react_to_me.py
+++ b/src/agent/profiles/react_to_me.py
@@ -7,6 +7,7 @@
from langgraph.graph.state import StateGraph
from agent.profiles.base import BaseGraphBuilder, BaseState
+from agent.tasks.unsafe_question import create_unsafe_answer_generator
from retrievers.reactome.rag import create_reactome_rag
@@ -23,6 +24,9 @@ def __init__(
super().__init__(llm, embedding)
# Create runnables (tasks & tools)
+ self.unsafe_answer_generator: Runnable = create_unsafe_answer_generator(
+ llm, streaming=True
+ )
self.reactome_rag: Runnable = create_reactome_rag(
llm, embedding, streaming=True
)
@@ -32,15 +36,40 @@ def __init__(
# Set up nodes
state_graph.add_node("preprocess", self.preprocess)
state_graph.add_node("model", self.call_model)
+ state_graph.add_node("generate_unsafe_response", self.generate_unsafe_response)
state_graph.add_node("postprocess", self.postprocess)
# Set up edges
state_graph.set_entry_point("preprocess")
- state_graph.add_edge("preprocess", "model")
+ state_graph.add_conditional_edges(
+ "preprocess",
+ self.proceed_with_research,
+ {"Continue": "model", "Finish": "generate_unsafe_response"},
+ )
state_graph.add_edge("model", "postprocess")
+ state_graph.add_edge("generate_unsafe_response", "postprocess")
state_graph.set_finish_point("postprocess")
self.uncompiled_graph: StateGraph = state_graph
+ async def generate_unsafe_response(
+ self, state: ReactToMeState, config: RunnableConfig
+ ) -> ReactToMeState:
+ answer: str = await self.unsafe_answer_generator.ainvoke(
+ {
+ "language": state["detected_language"],
+ "user_input": state["rephrased_input"],
+ "reason_unsafe": state["reason_unsafe"],
+ },
+ config,
+ )
+ return ReactToMeState(
+ chat_history=[
+ HumanMessage(state["user_input"]),
+ AIMessage(answer),
+ ],
+ answer=answer,
+ )
+
async def call_model(
self, state: ReactToMeState, config: RunnableConfig
) -> ReactToMeState:
diff --git a/src/agent/tasks/cross_database/summarize_reactome_uniprot.py b/src/agent/tasks/cross_database/summarize_reactome_uniprot.py
index 345c17d..8ea1746 100644
--- a/src/agent/tasks/cross_database/summarize_reactome_uniprot.py
+++ b/src/agent/tasks/cross_database/summarize_reactome_uniprot.py
@@ -36,13 +36,17 @@
("system", summarization_message),
(
"human",
- "User question: {input} \n\n Language: {query_language} \n\n Reactome-drived information: \n {reactome_answer} \n\n UniProt-drived infromation: \n {uniprot_answer}.",
+ "User question: {input} \n\n Language: {detected_language} \n\n Reactome-drived information: \n {reactome_answer} \n\n UniProt-drived infromation: \n {uniprot_answer}.",
),
]
)
-def create_reactome_uniprot_summarizer(llm: BaseChatModel) -> Runnable:
+def create_reactome_uniprot_summarizer(
+ llm: BaseChatModel, streaming: bool = False
+) -> Runnable:
+ if streaming:
+ llm = llm.model_copy(update={"streaming": True})
return (summarizer_prompt | llm | StrOutputParser()).with_config(
run_name="summarize_answer"
)
diff --git a/src/agent/tasks/safety_checker.py b/src/agent/tasks/safety_checker.py
index ac372e3..91e539f 100644
--- a/src/agent/tasks/safety_checker.py
+++ b/src/agent/tasks/safety_checker.py
@@ -4,25 +4,62 @@
from pydantic import BaseModel, Field
safety_check_message = """
-You are an expert grader with a strong understanding of ethical guidelines and responsible AI usage.
+You are an expert scientific assistant. You have advanced training in scientific ethics, dual-use assessment, and responsible AI.
-Your task is to determine whether a user-submitted question is appropriate and not harmful.
-Provide a binary output as either:
-Yes: The question is appropriate and not harmful.
-No: The question is harmful, offensive, inappropriate, or unethical.
+Tasks:
+
+1. Safety & Appropriateness Check
+ - Assess the question and determine if it is safe and appropriate for responsible AI use.
+ - The question and any plausible answer to it must NOT be harmful, offensive, unethical, or violate scientific or ethical guidelines.
+ - The question or its likely answers must NOT request, describe, or explore how to carry out harmful, illegal, dangerous, or dual-use activities (such as modifying pathogens, increasing infectivity, or other forms of misuse)—even if the scenario is hypothetical, fictional, or indirect.
+ - The question must NOT request personal, medical, legal, or other types of advice outside the scope of scientific knowledge.
+ - If a question is ambiguous or could plausibly be used for harm, treat it as unsafe.
+ - Treat hypothetical, fictional, or made-up scenarios with the same level of scrutiny as real-world questions.
+
+2. Reactome Relevance Check
+ - Determine if the question is relevant to biology, life sciences, molecular biology, or related topics.
+ - Mark questions as not relevant if they are about unrelated topics (such as programming, math, history, trivia, etc.).
+
+IMPORTANT:
+ - If the standalone question is unsafe or not relevant return "safety": "false".
+ - If the standalone question is both safe **and** relevant, return "safety": "true".
+
+Return only a JSON object in the following format:
+ "safety": "true" or "false", // Use string, not boolean. Mark as "false" if unsafe OR not relevant.
+ "reason_unsafe": "..." // If 'safety' is false, briefly state the reason
+
+Examples:
+ 1. Q: Describe methods for bypassing cellular immune responses to viral infection
+ "safety": "false",
+ "reason_unsafe": "Could be used for harmful or unethical manipulation of immune evasion."
+
+ 2. Q: What are common side effects of statins in the treatment of high cholesterol?
+ "safety": "false",
+ "reason_unsafe": "Requests medical advice, which is out of scientific scope."
+
+ 3. Q: How can gene drives be used to eliminate invasive species in an ecosystem?
+ "safety": "false",
+ "reason_unsafe": "Enables the use of gene drives, which have significant ecological and dual-use risks."
+
+ 4. Q: What is the role of the immune system in the treatment of cancer?
+ "safety": "true",
+ "reason_unsafe": ""
"""
safety_check_prompt = ChatPromptTemplate.from_messages(
[
("system", safety_check_message),
- ("human", "User question: \n\n {input}"),
+ ("human", "User question: \n\n {rephrased_input}"),
]
)
class SafetyCheck(BaseModel):
- binary_score: str = Field(
- description="Indicates whether the question is appropriate and related to molecular biology. Expected values: 'Yes' or 'No'."
+ safety: str = Field(
+ description="Indicates whether the question is appropriate and related to molecular biology. Expected values: 'true' or 'false'."
+ )
+ reason_unsafe: str = Field(
+ description="If 'safety' is false, briefly state the reason; if 'safety' is true, leave this field empty."
)
diff --git a/src/agent/tasks/unsafe_question.py b/src/agent/tasks/unsafe_question.py
new file mode 100644
index 0000000..38e65d1
--- /dev/null
+++ b/src/agent/tasks/unsafe_question.py
@@ -0,0 +1,45 @@
+from langchain_core.language_models.chat_models import BaseChatModel
+from langchain_core.output_parsers import StrOutputParser
+from langchain_core.prompts import ChatPromptTemplate
+from langchain_core.runnables import Runnable
+
+safety_check_message = """
+You are an expert scientific assistant operating under the React-to-Me platform. React-to-Me helps both experts and non-experts explore molecular biology using trusted data from the Reactome database.
+
+You have advanced training in scientific ethics, dual-use research concerns, and responsible AI use.
+
+You will receive three inputs:
+1. The user's question.
+2. A system-generated variable called `reason_unsafe`, which explains why the question cannot be answered.
+3. The user's preferred language (as a language code or name).
+
+Your task is to clearly, respectfully, and firmly explain to the user *why* their question cannot be answered, based solely on the `reason_unsafe` input. Do **not** attempt to answer, rephrase, or guide the user toward answering the original question.
+
+You must:
+- Respond in the user’s preferred language.
+- Politely explain the refusal, grounded in the `reason_unsafe`.
+- Emphasize React-to-Me’s mission: to support responsible exploration of molecular biology through trusted databases.
+- Suggest examples of appropriate topics (e.g., protein function, pathways, gene interactions using Reactome/UniProt).
+
+You must not provide any workaround, implicit answer, or redirection toward unsafe content.
+"""
+
+safety_check_prompt = ChatPromptTemplate.from_messages(
+ [
+ ("system", safety_check_message),
+ (
+ "user",
+ "Language:{language}\n\nQuestion:{user_input}\n\n Reason for unsafe or out of scope: {reason_unsafe}",
+ ),
+ ]
+)
+
+
+def create_unsafe_answer_generator(
+ llm: BaseChatModel, streaming: bool = False
+) -> Runnable:
+ if streaming:
+ llm = llm.model_copy(update={"streaming": True})
+ return (safety_check_prompt | llm | StrOutputParser()).with_config(
+ run_name="unsafe_answer_generator"
+ )
diff --git a/src/retrievers/csv_chroma.py b/src/retrievers/csv_chroma.py
index 691b884..a792c93 100644
--- a/src/retrievers/csv_chroma.py
+++ b/src/retrievers/csv_chroma.py
@@ -1,20 +1,61 @@
+import asyncio
from pathlib import Path
+from typing import Annotated, Any, Coroutine, TypedDict
import chromadb.config
from langchain.chains.query_constructor.schema import AttributeInfo
-from langchain.retrievers import EnsembleRetriever
+from langchain.retrievers import EnsembleRetriever, MultiQueryRetriever
from langchain.retrievers.merger_retriever import MergerRetriever
from langchain.retrievers.self_query.base import SelfQueryRetriever
from langchain_chroma.vectorstores import Chroma
from langchain_community.document_loaders.csv_loader import CSVLoader
from langchain_community.retrievers import BM25Retriever
+from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
from langchain_core.language_models.chat_models import BaseChatModel
-from langchain_core.retrievers import BaseRetriever
+from langchain_core.prompts.prompt import PromptTemplate
from nltk.tokenize import word_tokenize
+from pydantic import AfterValidator, Field
+from pydantic.json_schema import SkipJsonSchema
chroma_settings = chromadb.config.Settings(anonymized_telemetry=False)
+multi_query_prompt = PromptTemplate(
+ input_variables=["question"],
+ template="""You are a biomedical question expansion engine for information retrieval over the Reactome biological pathway database.
+
+Given a single user question, generate **exactly 4** alternate standalone questions. These should be:
+
+- Semantically related to the original question.
+- Lexically diverse to improve retrieval via vector search and RAG-fusion.
+- Biologically enriched with inferred or associated details.
+
+Your goal is to improve recall of relevant documents by expanding the original query using:
+- Synonymous gene/protein names (e.g., EGFR, ErbB1, HER1)
+- Pathway or process-level context (e.g., signal transduction, apoptosis)
+- Known diseases, phenotypes, or biological functions
+- Cellular localization (e.g., nucleus, cytoplasm, membrane)
+- Upstream/downstream molecular interactions
+
+Rules:
+- Each question must be **fully standalone** (no "this"/"it").
+- Do not change the core intent—preserve the user's informational goal.
+- Use appropriate biological terminology and Reactome-relevant concepts.
+- Vary the **phrasing**, **focus**, or **biological angle** of each question.
+- If the input is ambiguous, infer a biologically meaningful interpretation.
+
+Output:
+Return only the 4 alternative questions separated by newlines.
+Do not include any explanations or metadata.
+
+Original Question: {question}""",
+)
+
+
+ExcludedField = SkipJsonSchema[
+ Annotated[Any, Field(default=None, exclude=True), AfterValidator(lambda x: None)]
+]
+
def list_chroma_subdirectories(directory: Path) -> list[str]:
subdirectories = list(
@@ -31,40 +72,151 @@ def create_bm25_chroma_ensemble_retriever(
descriptions_info: dict[str, str],
field_info: dict[str, list[AttributeInfo]],
) -> MergerRetriever:
- retriever_list: list[BaseRetriever] = []
- for subdirectory in list_chroma_subdirectories(embeddings_directory):
- # set up BM25 retriever
- csv_file_name = subdirectory + ".csv"
- reactome_csvs_dir: Path = embeddings_directory / "csv_files"
- loader = CSVLoader(file_path=reactome_csvs_dir / csv_file_name)
- data = loader.load()
- bm25_retriever = BM25Retriever.from_documents(
- data,
- preprocess_func=lambda text: word_tokenize(
- text.casefold(), language="english"
- ),
- )
- bm25_retriever.k = 10
+ return HybridRetriever.from_subdirectory(
+ llm,
+ embedding,
+ embeddings_directory,
+ descriptions_info=descriptions_info,
+ field_info=field_info,
+ include_original=True,
+ )
- # set up vectorstore SelfQuery retriever
- vectordb = Chroma(
- persist_directory=str(embeddings_directory / subdirectory),
- embedding_function=embedding,
- client_settings=chroma_settings,
- )
- selfq_retriever = SelfQueryRetriever.from_llm(
- llm=llm,
- vectorstore=vectordb,
- document_contents=descriptions_info[subdirectory],
- metadata_field_info=field_info[subdirectory],
- search_kwargs={"k": 10},
- )
- rrf_retriever = EnsembleRetriever(
- retrievers=[bm25_retriever, selfq_retriever], weights=[0.2, 0.8]
+class RetrieverDict(TypedDict):
+ bm25: BM25Retriever
+ vector: SelfQueryRetriever
+
+
+class HybridRetriever(MultiQueryRetriever):
+ retriever: ExcludedField = None
+ _retrievers: dict[str, RetrieverDict]
+
+ @classmethod
+ def from_subdirectory(
+ cls,
+ llm: BaseChatModel,
+ embedding: Embeddings,
+ embeddings_directory: Path,
+ *,
+ descriptions_info: dict[str, str],
+ field_info: dict[str, list[AttributeInfo]],
+ include_original=False,
+ ):
+ _retrievers: dict[str, RetrieverDict] = {}
+ for subdirectory in list_chroma_subdirectories(embeddings_directory):
+ # set up BM25 retriever
+ csv_file_name = subdirectory + ".csv"
+ reactome_csvs_dir: Path = embeddings_directory / "csv_files"
+ loader = CSVLoader(file_path=reactome_csvs_dir / csv_file_name)
+ data = loader.load()
+ bm25_retriever = BM25Retriever.from_documents(
+ data,
+ preprocess_func=lambda text: word_tokenize(
+ text.casefold(), language="english"
+ ),
+ )
+ bm25_retriever.k = 10
+
+ # set up vectorstore SelfQuery retriever
+ vectordb = Chroma(
+ persist_directory=str(embeddings_directory / subdirectory),
+ embedding_function=embedding,
+ client_settings=chroma_settings,
+ )
+
+ selfq_retriever = SelfQueryRetriever.from_llm(
+ llm=llm,
+ vectorstore=vectordb,
+ document_contents=descriptions_info[subdirectory],
+ metadata_field_info=field_info[subdirectory],
+ search_kwargs={"k": 10},
+ )
+
+ _retrievers[subdirectory] = {
+ "bm25": bm25_retriever,
+ "vector": selfq_retriever,
+ }
+ llm_chain = MultiQueryRetriever.from_llm(
+ bm25_retriever, llm, multi_query_prompt, None, include_original
+ ).llm_chain
+ hybrid_retriever = cls(
+ llm_chain=llm_chain,
+ include_original=include_original,
+ _retrievers={},
)
- retriever_list.append(rrf_retriever)
+ hybrid_retriever._retrievers = _retrievers
+ return hybrid_retriever
+
+ def weighted_reciprocal_rank(
+ self, doc_lists: list[list[Document]]
+ ) -> list[Document]:
+ return EnsembleRetriever(
+ retrievers=[], weights=[1 / len(doc_lists)] * len(doc_lists)
+ ).weighted_reciprocal_rank(doc_lists)
- reactome_retriever = MergerRetriever(retrievers=retriever_list)
+ def retrieve_documents(self, queries: list[str], run_manager) -> list[Document]:
+ subdirectory_docs: list[Document] = []
+ for subdirectory, retrievers in self._retrievers.items():
+ bm25_retriever = retrievers["bm25"]
+ vector_retriever = retrievers["vector"]
+ doc_lists: list[list[Document]] = []
+ for i, query in enumerate(queries):
+ bm25_docs = bm25_retriever.invoke(
+ query,
+ config={
+ "callbacks": run_manager.get_child(
+ tag=f"{subdirectory}-bm25-{i}"
+ )
+ },
+ )
+ vector_docs = vector_retriever.invoke(
+ query,
+ config={
+ "callbacks": run_manager.get_child(
+ tag=f"{subdirectory}-vector-{i}"
+ )
+ },
+ )
+ doc_lists.append(bm25_docs + vector_docs)
+ subdirectory_docs.extend(self.weighted_reciprocal_rank(doc_lists))
+ return subdirectory_docs
- return reactome_retriever
+ async def aretrieve_documents(
+ self, queries: list[str], run_manager
+ ) -> list[Document]:
+ subdirectory_results: dict[str, list[Coroutine[Any, Any, list[Document]]]] = {}
+ for subdirectory, retrievers in self._retrievers.items():
+ bm25_retriever = retrievers["bm25"]
+ vector_retriever = retrievers["vector"]
+ subdirectory_results[subdirectory] = []
+ for i, query in enumerate(queries):
+ bm25_results = asyncio.to_thread(
+ bm25_retriever.invoke,
+ query,
+ config={
+ "callbacks": run_manager.get_child(
+ tag=f"{subdirectory}-bm25-{i}"
+ )
+ },
+ )
+ vector_results = asyncio.to_thread(
+ vector_retriever.invoke,
+ query,
+ config={
+ "callbacks": run_manager.get_child(
+ tag=f"{subdirectory}-vector-{i}"
+ )
+ },
+ )
+ subdirectory_results[subdirectory].extend(
+ (bm25_results, vector_results)
+ )
+ subdirectory_docs: list[Document] = []
+ for subdir_results in subdirectory_results.values():
+ results_iter = iter(await asyncio.gather(*subdir_results))
+ doc_lists: list[list[Document]] = [
+ bm25_results + vector_results
+ for bm25_results, vector_results in zip(results_iter, results_iter)
+ ]
+ subdirectory_docs.extend(self.weighted_reciprocal_rank(doc_lists))
+ return subdirectory_docs
diff --git a/src/retrievers/reactome/prompt.py b/src/retrievers/reactome/prompt.py
index 9a11526..d570cb9 100644
--- a/src/retrievers/reactome/prompt.py
+++ b/src/retrievers/reactome/prompt.py
@@ -1,25 +1,34 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
reactome_system_prompt = """
-You are an expert in molecular biology with access to the Reactome Knowledgebase.
-Your primary responsibility is to answer the user's questions comprehensively, accurately, and in an engaging manner, based strictly on the context provided from the Reactome Knowledgebase.
-Provide any useful background information required to help the user better understand the significance of the answer.
-Always provide citations and links to the documents you obtained the information from.
+You are an expert in molecular biology with access to the **Reactome Knowledgebase**.
+Your primary responsibility is to answer the user's questions **comprehensively, mechanistically, and with precision**, drawing strictly from the **Reactome Knowledgebase**.
-When providing answers, please adhere to the following guidelines:
-1. Provide answers **strictly based on the given context from the Reactome Knowledgebase**. Do **not** use or infer information from any external sources.
-2. If the answer cannot be derived from the context provided, do **not** answer the question; instead explain that the information is not currently available in Reactome.
-3. Answer the question comprehensively and accurately, providing useful background information based **only** on the context.
-4. keep track of **all** the sources that are directly used to derive the final answer, ensuring **every** piece of information in your response is **explicitly cited**.
-5. Create Citations for the sources used to generate the final asnwer according to the following:
- - For Reactome always format citations in the following format: *Source_Name*, where *Source_Name* is the name of the retrieved document.
- Examples:
- - Apoptosis
- - Cell Cycle
+Your output must emphasize biological processes, molecular complexes, regulatory mechanisms, and interactions most relevant to the user’s question.
+Provide an information-rich narrative that explains not only what is happening but also how and why, based only on Reactome context.
-6. Always provide the citations you created in the format requested, in point-form at the end of the response paragraph, ensuring **every piece of information** provided in the final answer is cited.
-7. Write in a conversational and engaging tone suitable for a chatbot.
-8. Use clear, concise language to make complex topics accessible to a wide audience.
+
+## **Answering Guidelines**
+1. Strict source discipline: Use only the information explicitly provided from Reactome. Do not invent, infer, or draw from external knowledge.
+ - Use only information directly found in Reactome.
+ - Do **not** supplement, infer, generalize, or assume based on external biological knowledge.
+ - If no relevant information exists in Reactome, explain the information is not currently available in Reactome. Do **not** answer the question.
+2. Inline citations required: Every factual statement must include ≥1 inline anchor citation in the format: display_name
+ - If multiple entries support the same fact, cite them together (space-separated).
+3. Comprehensiveness: Capture all mechanistically relevant details available in Reactome, focusing on processes, complexes, regulations, and interactions.
+4. Tone & Style:
+ - Write in a clear, engaging, and conversational tone.
+ - Use accessible language while maintaining technical precision.
+ - Ensure the narrative flows logically, presenting background, mechanisms, and significance
+5. Source list at the end: After the main narrative, provide a bullet-point list of each unique citation anchor exactly once, in the same Node Name format.
+ - Examples:
+ - Apoptosis
+ - Cell Cycle
+
+## Internal QA (silent)
+- All factual claims are cited correctly.
+- No unverified claims or background knowledge are added.
+- The Sources list is complete and de-duplicated.
"""
reactome_qa_prompt = ChatPromptTemplate.from_messages(