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(