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..58303b1 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 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: BaseState = 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..d0d4557 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,9 +41,7 @@ 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(
@@ -74,7 +67,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,31 +96,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"
+ return CrossDatabaseState()
async def identify_query_language(
self, state: CrossDatabaseState, config: RunnableConfig
diff --git a/src/agent/profiles/react_to_me.py b/src/agent/profiles/react_to_me.py
index c162ac7..5211cfd 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,7 @@ def __init__(
super().__init__(llm, embedding)
# Create runnables (tasks & tools)
+ self.unsafe_answer_generator = create_unsafe_answer_generator(llm)
self.reactome_rag: Runnable = create_reactome_rag(
llm, embedding, streaming=True
)
@@ -32,15 +34,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/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..6045360
--- /dev/null
+++ b/src/agent/tasks/unsafe_question.py
@@ -0,0 +1,41 @@
+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) -> Runnable:
+ return (safety_check_prompt | llm | StrOutputParser()).with_config(
+ run_name="unsafe_answer_generator"
+ )
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(