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(