diff --git a/backend/app/controllers/bmdb_controller.py b/backend/app/controllers/bmdb_controller.py new file mode 100644 index 0000000..3094233 --- /dev/null +++ b/backend/app/controllers/bmdb_controller.py @@ -0,0 +1,54 @@ +import httpx +from typing import List +from fastapi import HTTPException, Response +from app.schemas.bmdb_schema import BMDBRequestParams +from app.services.databases_service import ( + get_xml_file, + fetch_bmdb_models, + get_bmdb_model_info, +) + + +async def get_bmdb_models_controller(params: BMDBRequestParams) -> dict: + """ + Controller function to retrieve biomodels based on filters and sorting. + Raises: + HTTPException: If the BMDB API request fails. + """ + try: + biomodels = await fetch_bmdb_models(params) + return biomodels + except httpx.HTTPStatusError as e: + raise HTTPException( + status_code=e.response.status_code, detail="Error fetching biomodels." + ) + except httpx.RequestError as e: + raise HTTPException( + status_code=500, detail="Error communicating with BMDB API." + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +async def get_xml_controller(bmdbID: str, truncate: bool = False) -> str: + """ + Controller function to fetch the contents of the XML file for a bmdb biomodel. + Raises: + HTTPException: If the URL cannot be generated. + """ + try: + return await get_xml_file(bmdbID, truncate) + except Exception as e: + raise HTTPException(status_code=500, detail="Error fetching XML file.") + + +async def get_bmdb_model_info_controller(bmdbID: str) -> dict: + """ + Controller function to fetch information about a specific BMDB model. + Raises: + HTTPException: If the URL cannot be generated. + """ + try: + return await get_bmdb_model_info(bmdbID) + except Exception as e: + raise HTTPException(status_code=500, detail="Error fetching BMDB model info.") \ No newline at end of file diff --git a/backend/app/controllers/llms_controller.py b/backend/app/controllers/llms_controller.py index fe271f5..73bacb3 100644 --- a/backend/app/controllers/llms_controller.py +++ b/backend/app/controllers/llms_controller.py @@ -7,7 +7,7 @@ ) -async def get_llm_response(conversation_history: list[dict]) -> tuple[str, list]: +async def get_llm_response(conversation_history: list[dict], database: str) -> tuple[str, list]: """ Controller function to interact with the LLM service. Args: @@ -16,8 +16,9 @@ async def get_llm_response(conversation_history: list[dict]) -> tuple[str, list] tuple[str, list]: A tuple containing the final response and bmkeys list. """ try: - result, bmkeys = await get_response_with_tools(conversation_history) - return result, bmkeys + print("DEBUG20: BMDB POST: get_llm_response") + result, bmkeys, tool_summary = await get_response_with_tools(conversation_history, database) + return result, bmkeys, tool_summary except Exception as e: raise HTTPException(status_code=500, detail=f"Error: {str(e)}") diff --git a/backend/app/controllers/vcelldb_controller.py b/backend/app/controllers/vcelldb_controller.py index 93371ff..a95668a 100644 --- a/backend/app/controllers/vcelldb_controller.py +++ b/backend/app/controllers/vcelldb_controller.py @@ -2,7 +2,7 @@ from typing import List from fastapi import HTTPException, Response from app.schemas.vcelldb_schema import BiomodelRequestParams, SimulationRequestParams -from app.services.vcelldb_service import ( +from app.services.databases_service import ( fetch_biomodels, fetch_simulation_details, get_vcml_file, @@ -21,7 +21,9 @@ async def get_biomodels_controller(params: BiomodelRequestParams) -> dict: HTTPException: If the VCell API request fails. """ try: + print("About to call fetch_biomodels()") biomodels = await fetch_biomodels(params) + print("fetch_biomodels() completed successfully") return biomodels except httpx.HTTPStatusError as e: raise HTTPException( @@ -142,7 +144,9 @@ async def get_publications_controller() -> List[dict]: HTTPException: If the VCell API request fails. """ try: + print("About to call fetch_publications()") publications = await fetch_publications() + print("fetch_publications() completed successfully") return publications except httpx.HTTPStatusError as e: raise HTTPException( diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7a9df5b..40ece26 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,4 +1,4 @@ -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): @@ -24,4 +24,10 @@ class Settings(BaseSettings): LANGFUSE_PUBLIC_KEY: str LANGFUSE_HOST: str + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index f48d10b..bec6694 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from app.routes.llms_router import router as llms_router from app.routes.qdrant_router import router as qdrant_router from app.routes.knowledge_base_router import router as knowledge_base_router +from app.routes.bmdb_router import router as bmdb_router ascii_art = """ ╔════════════════════════════════════════════════════════════════════════════════════╗ @@ -54,6 +55,7 @@ async def startup_event(): # Including the routers app.include_router(knowledge_base_router, tags=["Knowledge Base"], prefix="/kb") app.include_router(llms_router, tags=["LLM with Tool Calling"]) +app.include_router(bmdb_router, tags=["BMDB API Wrapper"]) app.include_router(vcelldb_router, tags=["VCellDB API Wrapper"]) app.include_router(qdrant_router, tags=["Qdrant Vector DB"], prefix="/qdrant") diff --git a/backend/app/routes/bmdb_router.py b/backend/app/routes/bmdb_router.py new file mode 100644 index 0000000..42c9943 --- /dev/null +++ b/backend/app/routes/bmdb_router.py @@ -0,0 +1,42 @@ +from fastapi import APIRouter, Depends, HTTPException +from app.schemas.bmdb_schema import BMDBRequestParams +from app.controllers.bmdb_controller import ( + get_bmdb_models_controller, + get_xml_controller, + get_bmdb_model_info_controller, +) + +router = APIRouter() + + +@router.get("/search", response_model=dict) +async def get_biomodels(params: BMDBRequestParams = Depends()): + """ + Endpoint to retrieve bmdb models based on provided parameters. + """ + try: + return await get_bmdb_models_controller(params) + except HTTPException as e: + raise e + + +@router.get("/get-xml", response_model=str) +async def get_xml(bmdbID: str, truncate: bool = False): + """ + Endpoint to get XML file contents for a given biomodel. + """ + try: + return await get_xml_controller(bmdbID, truncate) + except HTTPException as e: + raise e + + +@router.get("/model-info", response_model=dict) +async def get_model_info(bmdbID: str): + """ + Endpoint to get information about a specific BMDB model. + """ + try: + return await get_bmdb_model_info_controller(bmdbID) + except HTTPException as e: + raise e \ No newline at end of file diff --git a/backend/app/routes/llms_router.py b/backend/app/routes/llms_router.py index 35ed27a..3607ad8 100644 --- a/backend/app/routes/llms_router.py +++ b/backend/app/routes/llms_router.py @@ -1,4 +1,10 @@ -from fastapi import APIRouter +from multiprocessing import process +from fastapi import APIRouter, Depends, HTTPException, Response +from typing import List +from app.schemas.bmdb_schema import BMDBRequestParams +from app.schemas.vcelldb_schema import BiomodelRequestParams +import httpx +import requests from app.controllers.llms_controller import ( get_llm_response, analyse_biomodel_controller, @@ -8,6 +14,25 @@ router = APIRouter() +# For BioModelsDB search using BioModelsDB API +@router.post("/bmdb-search") +async def search_llm(conversation_history: dict): + """ + Endpoint to query the LLM and execute the necessary tools. + Args: + conversation_history (dict): The conversation history containing user prompts and responses. + database (str): The database to query - bmdb in this case. + Returns: + dict: The final response after processing the prompt with the tools. + """ + + print("DEBUG20: BMDB POST: ROUTER") + result, bmdbkeys, tool_summary = await get_llm_response( + conversation_history.get("conversation_history", []), database="bmdb" + ) + return {"response": result, "bmkeys": bmdbkeys, "tool_summary": tool_summary} + + @router.post("/query") async def query_llm(conversation_history: dict): @@ -15,13 +40,14 @@ async def query_llm(conversation_history: dict): Endpoint to query the LLM and execute the necessary tools. Args: conversation_history (dict): The conversation history containing user prompts and responses. + database (str): The database to query - vcdb in this case. Returns: dict: The final response after processing the prompt with the tools. """ - result, bmkeys = await get_llm_response( - conversation_history.get("conversation_history", []) + result, bmkeys, tool_summary = await get_llm_response( + conversation_history.get("conversation_history", []), database="vcdb" ) - return {"response": result, "bmkeys": bmkeys} + return {"response": result, "bmkeys": bmkeys, "tool_summary": tool_summary} @router.post("/analyse/{biomodel_id}") diff --git a/backend/app/schemas/bmdb_schema.py b/backend/app/schemas/bmdb_schema.py new file mode 100644 index 0000000..ce3d33f --- /dev/null +++ b/backend/app/schemas/bmdb_schema.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import date +from enum import Enum + + +class CategoryEnum(str, Enum): + all = "all" + public = "public" + shared = "shared" + tutorials = "tutorial" + educational = "educational" + + +class OrderByEnum(str, Enum): + date_desc = "date_desc" + date_asc = "date_asc" + name_desc = "name_desc" + name_asc = "name_asc" + + +# Biomodel Request Parameters schema +class BMDBRequestParams(BaseModel, use_enum_values=True): + bmName: Optional[str] = "" # Name of the biomodel to search for + bmId: Optional[str] = "" # Biomodel ID diff --git a/backend/app/schemas/vcelldb_schema.py b/backend/app/schemas/vcelldb_schema.py index 51d467c..8964e47 100644 --- a/backend/app/schemas/vcelldb_schema.py +++ b/backend/app/schemas/vcelldb_schema.py @@ -33,7 +33,6 @@ class BiomodelRequestParams(BaseModel, use_enum_values=True): OrderByEnum.date_desc ) # Order of results (default is "date_desc") - class SimulationRequestParams(BaseModel): bmId: str # Biomodel ID for which simulations will be fetched simId: str # Simulation ID to fetch specific simulation details diff --git a/backend/app/services/vcelldb_service.py b/backend/app/services/databases_service.py similarity index 75% rename from backend/app/services/vcelldb_service.py rename to backend/app/services/databases_service.py index 8382024..a9c6a12 100644 --- a/backend/app/services/vcelldb_service.py +++ b/backend/app/services/databases_service.py @@ -8,8 +8,10 @@ from typing import List VCELL_API_BASE_URL = "https://vcell.cam.uchc.edu/api/v0" +BIOMODELS_API_URL = "https://biomodels.org/" logger = get_logger("vcelldb_service") +print("CHECK: in VCELL_DB_SERVICE") def sanitize_vcml_content(vcml_content: str) -> str: @@ -38,6 +40,10 @@ def sanitize_vcml_content(vcml_content: str) -> str: logger.info("VCML content sanitized: ImageData tags removed") return sanitized_content +# def sanitize_xml_content(vcml_content: str) -> str: + +# return sanitized_content + async def check_vcell_connectivity() -> bool: """ @@ -75,8 +81,11 @@ async def fetch_biomodels(params: BiomodelRequestParams) -> dict: Returns: dict: A dictionary containing a list of biomodels with metadata. """ + + print("CHECK: in VCELL_DB_SERVICE") # Transform None to "" (optional, only if needed for empty fields) params_dict = {k: (v if v is not None else "") for k, v in params.dict().items()} + print("DEBUG: " + str(params_dict)) logger.info(f"Fetching biomodels with parameters: {params_dict}") @@ -120,6 +129,7 @@ async def fetch_simulation_details(params: SimulationRequestParams) -> dict: Returns: Simulation: A Simulation object containing simulation details. """ + print("CHECK: in VCELL_DB_SERVICE") async with httpx.AsyncClient() as client: response = await client.get( f"{VCELL_API_BASE_URL}/biomodel/{params.bmId}/simulation/{params.simId}" @@ -127,6 +137,126 @@ async def fetch_simulation_details(params: SimulationRequestParams) -> dict: response.raise_for_status() return response.json() +@observe(name="FETCH_BMDB_MODELS") +async def fetch_bmdb_models(params: BiomodelRequestParams) -> dict: + print("DEBUG20: BMDB POST: in tool FETCH_BMDB_MODELS") + + # Construct the query string using urlencoded parameters (params_dict) + query_string = params.bmName if params.bmName else params.bmId + + # Construct the full URL + url = f"{BIOMODELS_API_URL}search?query={query_string}&format=json" + + # Log the URL being queried + logger.info(f"Querying URL: {url}") + + # Perform the API request + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + raw_data = response.json() + + print("FINAL URL:", response.request.url) + print("STATUS CODE:", response.status_code) + print("RAW JSON:", raw_data) + + # Extract list + biomodels = raw_data.get("models", []) + + # Build response with metadata + return { + "search_params": { + "bmId": params.bmId, + "bmName": params.bmName + }, + "models_count": len(biomodels), + "data": biomodels + } + + +@observe(name="GET_XML_FILE") +async def get_xml_file(bmId: str, truncate: bool = False, max_retries: int = 3) -> str: + + logger.info(f"Fetching XML file for biomodel: {bmId}") + + # Check connectivity first + if not await check_vcell_connectivity(): + logger.error( + "BMDB API is not reachable. Please check your network connection and DNS settings." + ) + raise Exception( + "BMDB API is not reachable. Please check your network connection and DNS settings." + ) + + for attempt in range(max_retries + 1): + try: + url = f"{BIOMODELS_API_URL}model/download/{bmId}?filename={bmId}_url.xml" + logger.info( + f"Requesting URL: {url} (attempt {attempt + 1}/{max_retries + 1})" + ) + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url) + logger.info(f"Response status: {response.status_code}") + logger.info(f"Response headers: {dict(response.headers)}") + response.raise_for_status() + + return response.text + # if truncate: + # return sanitize_vcml_content(response.text[:500]) + # else: + # return sanitize_vcml_content(response.text) + + except httpx.HTTPStatusError as e: + logger.error( + f"HTTP error fetching XML file for biomodel {bmId}: {e.response.status_code} - {e.response.text}" + ) + if attempt == max_retries: + raise e + logger.warning(f"Retrying in {2 ** attempt} seconds...") + await asyncio.sleep(2**attempt) + + except httpx.RequestError as e: + logger.error( + f"Request error fetching XML file for biomodel {bmId}: {str(e)}" + ) + if attempt == max_retries: + raise e + logger.warning(f"Retrying in {2 ** attempt} seconds...") + await asyncio.sleep(2**attempt) + + except Exception as e: + logger.error( + f"Unexpected error fetching XML file for biomodel {bmId}: {str(e)}" + ) + if attempt == max_retries: + raise e + logger.warning(f"Retrying in {2 ** attempt} seconds...") + await asyncio.sleep(2**attempt) + + # This should never be reached, but just in case + raise Exception( + f"Failed to fetch XML file for biomodel {bmId} after {max_retries + 1} attempts" + ) + + +@observe(name="GET_BMDB_MODEL_INFO") +async def get_bmdb_model_info(bmdbID: str) -> dict: + """ + Fetches information about a specific given model from BMDB. + """ + url = f"{BIOMODELS_API_URL}/{bmdbID}?format=json" + + logger.info(f"Fetching BMDB model info from URL: {url}") + + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + raw_data = response.json() + + # returns dictionary with model info, including name, description, etc. + return raw_data + @observe(name="GET_VCML_FILE") async def get_vcml_file( diff --git a/backend/app/services/llms_service.py b/backend/app/services/llms_service.py index 5d5e91d..c12948b 100644 --- a/backend/app/services/llms_service.py +++ b/backend/app/services/llms_service.py @@ -1,15 +1,21 @@ +# IMPLEMENTATION: separating tools into subsets and sending only relevant tools to llm from app.utils.tools_utils import ( - ToolsDefinitions as tools, - execute_tool, + BMDB_TOOLS as bmdbtools, + execute_tool, + select_tools_for_prompt, + should_use_tools, + default_rows, ) -from app.services.vcelldb_service import ( +from app.services.databases_service import ( fetch_biomodels, get_vcml_file, get_diagram_url, ) from app.utils.system_prompt import SYSTEM_PROMPT +from app.utils.bmdb_system_prompt import BMDB_SYSTEM_PROMPT +from app.utils.vcdb_system_prompt import VCDB_SYSTEM_PROMPT from app.schemas.vcelldb_schema import BiomodelRequestParams from app.core.singleton import get_openai_client @@ -17,9 +23,61 @@ import json from app.core.logger import get_logger +import time + +# adding specific time logs for easier profiling +def log_timing(label: str, start: float): + duration = time.perf_counter() - start + logger.info(f"{label}: {duration:.3f}s") + logger = get_logger("llm_service") client = get_openai_client() +# IMPLEMENTATION: extract the last user message from the conversation history +def _last_user_message(conversation_history: list[dict]) -> str: + for msg in reversed(conversation_history): + if msg.get("role") == "user" and msg.get("content"): + return str(msg["content"]).strip() + return "" + +# IMPLEMENTATION: directly call llm without any tools for simple, conversational queries +def _direct_chat_completion(messages: list[dict]) -> str: + response = client.chat.completions.create( + name="GET_RESPONSE_DIRECT", + model=settings.AZURE_DEPLOYMENT_NAME, + messages=messages, + ) + return response.choices[0].message.content or "" + + +# do not change the tool call formatting, only shorten results +# this way the llm will stop returning false results +def summarize_tool_result(result): + if isinstance(result, dict) and "models" in result: + return { + "models": [ + { + "id": m.get("id"), + "name": m.get("name"), + "description": m.get("description", "")[:200], + "score": m.get("score"), # keep useful signals + } + for m in result["models"][:5] + ], + "total": result.get("total"), + } + + return result + + +# adding specific time logs for easier profiling +async def timed_tool_call(name, args): + start = time.perf_counter() + result = await execute_tool(name, args) + log_timing(f"TOOL {name}", start) + return result + + async def get_llm_response(system_prompt: str, user_prompt: str): """ @@ -44,27 +102,74 @@ async def get_llm_response(system_prompt: str, user_prompt: str): return response.choices[0].message.content -async def get_response_with_tools(conversation_history: list[dict]): +async def get_response_with_tools(conversation_history: list[dict], database: str): + # start the total request timer for timing of the entire process + total_start = time.perf_counter() messages = [ { "role": "system", - "content": SYSTEM_PROMPT, + "content": SYSTEM_PROMPT + (BMDB_SYSTEM_PROMPT if database == "bmdb" else VCDB_SYSTEM_PROMPT), }, ] messages = messages + conversation_history - user_prompt = conversation_history[-1]["content"] + # create a summary string of all timing logs to print to frontend + tool_summary = "" - logger.info(f"User prompt: {user_prompt}") + # llm tool selection call + llm1_start = time.perf_counter() - response = client.chat.completions.create( - name="GET_RESPONSE_WITH_TOOLS::RETRIEVE_TOOLS", - model=settings.AZURE_DEPLOYMENT_NAME, - messages=messages, - tools=tools, - tool_choice="auto", - ) + if database == "bmdb": + print("DEBUG20: BMDB POST: get_response_with_tools") + response = client.chat.completions.create( + model=settings.AZURE_DEPLOYMENT_NAME, + messages=messages, + tools=bmdbtools, + tool_choice="auto", + ) + + # IMPLEMENTATION: changing the way llm sees/chooses tools + elif database == "vcdb": + # extract last user message + user_prompt = _last_user_message(conversation_history) + logger.info(f"User prompt: {user_prompt}") + + # avoid the tool-calling process for simple, conversational promptsß + if not should_use_tools(user_prompt): + # if no tools are used, then skip to immediate response + llm_direct_start = time.perf_counter() + + # generate the response directly + final_response = _direct_chat_completion(messages) + + # log timing for profiling + log_timing("LLM direct (no tools)", llm_direct_start) + log_timing("TOTAL REQUEST", total_start) + + # return response with no tool calls + return final_response, [], "" # no tool summary since no tools used + + # only include relevant tools to the llm instead of all tools + selected_tools = select_tools_for_prompt(user_prompt) + logger.info(f"TOOL SUBSET: {selected_tools}") + + # first llm call to decide which tool to use from the given subset + response = client.chat.completions.create( + name="GET_RESPONSE_WITH_TOOLS::RETRIEVE_TOOLS", + model=settings.AZURE_DEPLOYMENT_NAME, + messages=messages, + tools=selected_tools, + tool_choice="auto", + ) + + # log timing after the llm selects which tool to use + log_timing("LLM1 - selecting tools from the subset", llm1_start) + llm1_time = time.perf_counter() - llm1_start + print(selected_tools) + tool_summary += f"*We selected subset tools: {', '.join([t.function.name for t in selected_tools])}* " + tool_summary += f"*The LLM call to select tools from the subset took {llm1_time:.2f}s.* " + tool_summary += f"*The LLM chose to use {len(response.choices[0].message.tool_calls)} tool(s) from the subset.* " # Handle the tool calls tool_calls = response.choices[0].message.tool_calls @@ -73,45 +178,100 @@ async def get_response_with_tools(conversation_history: list[dict]): bmkeys = [] - if tool_calls: - for tool_call in tool_calls: - # Extract the function name and arguments - name = tool_call.function.name - args = json.loads(tool_call.function.arguments) - logger.info(f"Tool Call: {name} with args: {args}") + # introduce a fast path: if no tool_calls, return immediately + if not tool_calls: + direct_text = response.choices[0].message or "" + logger.info(f"LLM Response (no tools): {direct_text}") + return direct_text, bmkeys, "" - # Execute the tool function - result = await execute_tool(name, args) + # perform tool calls concurrently rather than sequentially to reduce response time + if tool_calls: + import asyncio + import json - logger.info(f"Tool Result: {str(result)[:500]}") + # execute all tool calls concurrently + tasks = [] + parsed_calls = [] + tool_timings = [] + for tool_call in tool_calls: + name = tool_call.function.name + args = json.loads(tool_call.function.arguments) + parsed_calls.append((tool_call, name, args)) + tasks.append(timed_tool_call(name, args)) + + # log timing for how long the tool calls take to execute in total + tools_total_start = time.perf_counter() + results = await asyncio.gather(*tasks) + + # log total time for all tool calls together + tools_total_time = time.perf_counter() - tools_total_start + log_timing("EXECUTION OF TOOL CALLS", tools_total_start) + tool_summary += f"*Executing the tool calls took {tools_total_time:.2f}s.* " + + + for (tool_call, name, args), result in zip(parsed_calls, results): + compact_result = summarize_tool_result(result) + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": json.dumps(compact_result, ensure_ascii=False), + }) + + # log timing for each individual tool call + tool_timings.append({ + "tool_name": name, + "args": args, + "duration_s": round(time.perf_counter() - tools_total_start, 3) + }) + logger.info(f"Individual tool call timings: {tool_timings}") + tool_summary += f"Executing each tool call took: " + ", ".join([f"{t['tool_name']} ({t['duration_s']}s)" for t in tool_timings]) + "." + + # extract the bmkeys + for tool_call in tool_calls: + bmkeys = [] # Extract bmkeys only if result is a dictionary and contains the expected key if isinstance(result, dict): - bmkeys = result.get("unique_model_keys (bmkey)", []) + if database == "vcdb": + bmkeys = result.get("unique_model_keys (bmkey)", []) + elif database == "bmdb": + bmdb_models = result.get("data", []) + bmkeys = [model.get("id") for model in bmdb_models if model.get("id")] - # Send the result back to the model - messages.append( - {"role": "tool", "tool_call_id": tool_call.id, "content": str(result)} - ) + + logger.info("DEBUG100-START") + print(len(str(messages))) + print("DEBUG100: ", messages) - logger.info(str(messages)) + # log timing for the final llm call that uses the tool result + llm2_start = time.perf_counter() # Send back the final response incorporating the tool result completion = client.chat.completions.create( name="GET_RESPONSE_WITH_TOOLS::PROCESS_TOOL_RESULTS", model=settings.AZURE_DEPLOYMENT_NAME, messages=messages, - metadata={ - "tool_calls": tool_calls, - }, + # metadata={ + # "tool_calls": tool_calls, + # }, ) + llm2_time = time.perf_counter() - llm2_start + log_timing("LLM2 (final response)", llm2_start) + tool_summary += f"*The final LLM call took {llm2_time:.2f}s.* " + + logger.info("DEBUG100-END") + final_response = completion.choices[0].message.content logger.info(f"LLM Response: {final_response}") + log_timing("TOTAL REQUEST TIME (from initial request to final output)", total_start) + total_time = time.perf_counter() - total_start + tool_summary += f"*Total request time: {total_time:.2f}s.*" + tool_summary += f"\n*Max rows fetched for list of biomodels was {default_rows}.*" - return final_response, bmkeys + return final_response, bmkeys, tool_summary async def analyse_vcml(biomodel_id: str): diff --git a/backend/app/tests/test_vcelldb_service.py b/backend/app/tests/test_vcelldb_service.py index a55e024..8dc9ee5 100644 --- a/backend/app/tests/test_vcelldb_service.py +++ b/backend/app/tests/test_vcelldb_service.py @@ -7,7 +7,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) -from app.services.vcelldb_service import ( +from app.services.databases_service import ( fetch_biomodels, fetch_simulation_details, get_vcml_file, diff --git a/backend/app/utils/bmdb_system_prompt.py b/backend/app/utils/bmdb_system_prompt.py new file mode 100644 index 0000000..d93d6db --- /dev/null +++ b/backend/app/utils/bmdb_system_prompt.py @@ -0,0 +1,27 @@ +BMDB_SYSTEM_PROMPT = """ +## Formatting Guidelines for Biomodels +You MUST follow this exact output format. Do NOT modify, omit, or reorder any fields. +ALWAYS use the provided name and biomodelID exactly. Format the name as [name](/search/${id}). + +### Formatting Guidelines for biomodels retrieved from BioModels database (BMDB) +* For each BMDB model: +``` +1. **[Biomodel Name](/search/${id})** + - **Biomodel Key:** ${id} + - **Owner:** ${owner} + - **Description:** ${description or summary of the biomodel, do not include `clonedFrom` info} +``` + +### Rules for LONG LISTS (>10 models) + +- ALWAYS continue numbering sequentially (1, 2, 3, ...) +- Repeat the EXACT same structure for EVERY item +- If applications exist, do NOT omit them +- Do NOT summarize or shorten later items +- Do NOT merge multiple models into one entry +- Maintain identical formatting across all entries + +### Biomodel Analysis Guidelines +* Include as many relevant details as possible, such as biomodel ID, names, descriptions, parameters, and any other relevant metadata that can aid in the user's understanding. +* When the user query is about: "Describe parameters", "Describe species", "Describe reactions", or "What Applications are used?" — specifically in the context of model analysis: Make sure to use the `get_xml_file` tool to retrieve the SBML XML file for the BMDB biomodel. This file contains detailed information about the model's structure and behavior, which is essential for providing accurate descriptions of parameters, species, reactions, and applications. Use also the "fetch_bmdb_models" tool to gather additional context about the biomodel, and Try when asked these questions to focus on the asked aspects, Do not provide general summaries, model structure, or unrelated metadata unless explicitly requested. Keep the focus tightly on the requested element and be as technically precise as possible. Elaborate as much as you can on the requested aspect, providing detailed descriptions and explanations based on the SBML XML content. +""" diff --git a/backend/app/utils/system_prompt.py b/backend/app/utils/system_prompt.py index da79c67..962a34b 100644 --- a/backend/app/utils/system_prompt.py +++ b/backend/app/utils/system_prompt.py @@ -1,9 +1,8 @@ SYSTEM_PROMPT = """ -You are a VCell BioModel Assistant, designed to help users understand and interact with biological models in VCell. +You are a mathematical modeler in biology, designed to help users understand and interact with biological models in VCell, and in +SBML format (taken from BioModels database, also called BMDB or BioModels.org). Your task is to provide human-readable, accurate, detailed, and contextually appropriate responses based on the tools available. -## Core Guidelines - ### General Guidelines * Stick strictly to the user's query. * Do not make assumptions or inferences about missing or incomplete information in the user's input. @@ -11,17 +10,19 @@ * You can call tools multiple times if needed to gather sufficient data or refine your answer. * If asked about irrelevant topics, politely decline to answer. -### Formatting Guidelines -* When using mathematical expressions, wrap them properly: use `$expression$` for inline math (e.g., $k_{on}$, $\text{mmol}\cdot\text{ml}^{-1}$) and `$$expression$$` for display math blocks. Always use `\text{}` for text within math mode (e.g., $\text{Sos (Inactive)}$, $\text{concentration}$). -* Format all units, chemical names, reaction rates, and numerical expressions using math mode to ensure proper rendering. Example: "The rate is $5.2 \times 10^{-3} \text{ mmol}\cdot\text{ml}^{-1}\cdot\text{min}^{-1}$". -* If there is an opportunity for follow-up questions or further actions, always ask the user if they'd like to explore more options or if you can assist with other related tasks. +### Formatting Guidelines for Mathematical Expressions +* When using mathematical expressions, wrap them properly: use `$expression$` for inline math +(e.g., $k_{on}$, $\text{mmol}\cdot\text{ml}^{-1}$) and `$$expression$$` for display math blocks. Always +use `\text{}` for text within math mode (e.g., $\text{Sos (Inactive)}$, $\text{concentration}$). +* Format all units, chemical names, reaction rates, and numerical expressions using math mode to ensure +proper rendering. Example: "The rate is $5.2 \times 10^{-3} \text{ mmol}\cdot\text{ml}^{-1}\cdot\text{min}^{-1}$". -### Biomodel Analysis Guidelines -* Include as many relevant details as possible, such as biomodel ID, names, descriptions, parameters, and any other relevant metadata that can aid in the user's understanding. -* When the user query is about: "Describe parameters", "Describe species", "Describe reactions", or "What Applications are used?" — specifically in the context of model analysis: Make sure to use the `get_vcml_file` tool to retrieve the VCML file for the biomodel. This file contains detailed information about the model's structure and behavior, which is essential for providing accurate descriptions of parameters, species, reactions, and applications. Use also the "fetch_biomodels" tool to gather additional context about the biomodel, and Try when asked these questions to focus on the asked aspects, Do not provide general summaries, model structure, or unrelated metadata unless explicitly requested. Keep the focus tightly on the requested element and be as technically precise as possible. Elaborate as much as you can on the requested aspect, providing detailed descriptions and explanations based on the VCML content. +### Formatting Guidelines for Elements with Identifiers.org Links +* Any model element that includes a link to identifiers.org MUST be formatted as an underlined clickable link. +* ONLY identifiers.org links should be formatted this way. +* Do not hyperlink any other model elements (including names, descriptions, or internal links like /search/...). -### Publications Guidelines -* If asked for publications, research papers, pubmed articles, etc. use the `fetch_publications` tool. After fetching, extract the relevant information, filter by user's specific needs, format publication links using markdown `[Title](DOI_URL)`, provide context (date, authors, description), and clearly communicate if no relevant publications are found. -* When using the `fetch_publications` tool, the response contains the full list of VCell related publications with fields: `pubKey` (unique identifier), `title`, `authors` (array), `year`, `citation` (full citation string in journal format), `pubmedid` (PubMed ID), `doi` (DOI link to the publication), `biomodelReferences` (array of related biomodels), and `mathmodelReferences` (array of related mathematical models). -* When presenting publications, always provide elaborate, fact-based responses based solely on the available tool results. +### Guidelines for Follow-up Questions and Further Actions +* If there is an opportunity for follow-up questions or further actions, always ask the user if they'd like to explore +more options or if you can assist with other related tasks. """ diff --git a/backend/app/utils/tools_utils.py b/backend/app/utils/tools_utils.py index fe17151..af7ee9f 100644 --- a/backend/app/utils/tools_utils.py +++ b/backend/app/utils/tools_utils.py @@ -1,9 +1,11 @@ from typing import List -from app.services.vcelldb_service import ( +from app.services.databases_service import ( fetch_biomodels, fetch_simulation_details, get_vcml_file, fetch_publications, + fetch_bmdb_models, + get_xml_file ) from app.services.knowledge_base_service import get_similar_chunks from app.schemas.vcelldb_schema import BiomodelRequestParams, SimulationRequestParams @@ -14,9 +16,15 @@ ParameterSchema, ) from app.core.logger import get_logger +import re logger = get_logger("tools_utils") +# NUMBER OF ROWS TO RETURN: +min_rows = 1 +max_rows = 50 +default_rows = 25 + # Function calling Definitions using Pydantic schema objects fetch_biomodels_tool = ToolDefinition( type="function", @@ -65,7 +73,9 @@ }, "maxRows": { "type": "integer", - "default": 1000, + "default": default_rows, + "minimum": min_rows, + "maximum": max_rows, "description": "The maximum number of results to return per page.", }, "orderBy": { @@ -178,6 +188,52 @@ ), ) + +fetch_bmdb_tool = ToolDefinition( + type="function", + function=FunctionDefinition( + name="fetch_bmdb_models", + description="Retrieves a list of biomodels from the BioModels database based on filtering criteria which is the biomodel name. This allows to search for specific biomodels in the BioModels database based on their attributes and retrieve the results.", + parameters=ParameterSchema( + type="object", + properties={ + "bmId": { + "type": "string", + "default": "", + "description": "The unique identifier of the biomodel. This can be used to retrieve specific biomodels directly by their ID. It is under the format BIOMD followed by 10 numbers or MODEL followed by 10 numbers.", + }, + "bmName": { + "type": "string", + "default": "", + "description": "The name or part of the name of the biomodel you are searching for. This can be used to find biomodels that match the provided name or keyword.", + },}, + required=["bmId", "bmName"], + additionalProperties=False, + ), + strict=True, + ), +) + +get_xml_file_tool = ToolDefinition( + type="function", + function=FunctionDefinition( + name="get_xml_file", + description="Retrieves the SBML XML (eXtensible Markup Language) file content for a specified BioModels model (BIOMD ID). SBML (Systems Biology Markup Language) files provide a detailed, machine-readable representation of a biomodel's structure and behavior, which is used for simulation and model analysis. This function downloads the XML representation of a biomodel for further analysis.", + parameters=ParameterSchema( + type="object", + properties={ + "bmId": { + "type": "string", + "description": "ID of the biomodel to retrieve VCML", + } + }, + required=["bmId"], + additionalProperties=False, + ), + strict=True, + ), +) + # List of all tool definitions ToolsDefinitions = [ fetch_biomodels_tool, @@ -186,6 +242,91 @@ search_vcell_knowledge_base_tool, fetch_publications_tool, ] +BMDB_TOOLS = [fetch_bmdb_tool, + get_xml_file_tool] + + +# IMPLEMENTATION: separating all tool definitions into subsets +DB_TOOLS = [ + fetch_biomodels_tool, + fetch_simulation_details_tool, + get_vcml_file_tool, +] +KB_TOOLS = [ + search_vcell_knowledge_base_tool, +] +PUB_TOOLS = [ + fetch_publications_tool, +] + +# decide which subset (if any) of tools to send to the llm +# returning false skips tools and directly calls llm +# returning true allows the llm to use tools +def should_use_tools(prompt: str) -> bool: + if not prompt: + return False + + p = prompt.lower().strip() + + # common prefixes where tools are unnecessary + plain_chat_prefixes = ( + "summarize this", + "improve this", + "make this clearer", + ) + if p.startswith(plain_chat_prefixes): + return False + + # each signal indicates when tools are needed + # list of patterns that suggest a database lookup/a structured retrieval + tool_signals = [ + r"\b(list|show|find|get|fetch|search)\b", + r"\bmodel\b|\bmodels\b|\bbiomodel\b|\bbiomodels\b", + r"\bsimulation\b|\bsimulations\b", + r"\bvcml\b|\bxml\b", + r"\bpublication\b|\bpublications\b|\bpaper\b|\bpapers\b|\bpubmed\b", + r"\btutorial\b|\beducational\b|\bknowledge base\b", + r"\bhow do i\b|\bhow to\b|\bwhat is vcell\b", + r"\bBM\d+\b|\bBIOMD\d+\b", + ] + + # if any tool signal matches then use tools + return any(re.search(pattern, p) for pattern in tool_signals) + +# select only a subset of tools based on the user prompt +def select_tools_for_prompt(prompt: str): + p = (prompt or "").lower() + + # tools that the llm will see when making its choice + selected = [] + + # Database/data-fetch intent + if re.search(r"\b(model|models|biomodel|biomodels|simulation|simulations|vcml|bm\d+)\b", p): + selected.extend(DB_TOOLS) + + # Publications intent + if re.search(r"\b(publication|publications|paper|papers|pubmed)\b", p): + selected.extend(PUB_TOOLS) + + # Knowledge / tutorial / how-to intent + if re.search(r"\b(tutorial|educational|knowledge base|how do i|how to|what is vcell|explain)\b", p): + selected.extend(KB_TOOLS) + + # Default fallback: if tools are needed but no bucket matched, keep KB only. + if not selected: + selected = KB_TOOLS + + # De-duplicate while preserving order + deduped = [] + seen = set() + for tool in selected: + name = tool.function.name + if name not in seen: + deduped.append(tool) + seen.add(name) + + return deduped + # Tool Executor Function @@ -206,8 +347,9 @@ async def execute_tool(name, args): # args["savedLow"] = None # if args.get("savedHigh") == "": # args["savedHigh"] = None - args["maxRows"] = 1000 + args["maxRows"] = default_rows params = BiomodelRequestParams(**args) + print("DEBUG About to call fetch_biomodels()") return await fetch_biomodels(params) elif name == "fetch_simulation_details": @@ -221,10 +363,17 @@ async def execute_tool(name, args): query = args["query"] limit = args.get("limit", 5) logger.info(f"Executing tool: {name} with query {query}") + print("DEBUG About to call search_vcell_knowledge_base") return get_similar_chunks(query=query, limit=limit) elif name == "fetch_publications": return await fetch_publications() + + elif name == "fetch_bmdb_models": + params = BiomodelRequestParams(**args) + return await fetch_bmdb_models(params) + elif name == "get_xml_file": + return await get_xml_file(args["bmId"]) else: return {} diff --git a/backend/app/utils/vcdb_system_prompt.py b/backend/app/utils/vcdb_system_prompt.py new file mode 100644 index 0000000..c645c30 --- /dev/null +++ b/backend/app/utils/vcdb_system_prompt.py @@ -0,0 +1,38 @@ +VCDB_SYSTEM_PROMPT = """ +### Publications Guidelines +* If asked for publications, research papers, pubmed articles, etc. use the `fetch_publications` tool. After fetching, extract the relevant information, filter by user's specific needs, format publication links using markdown `[Title](DOI_URL)`, provide context (date, authors, description), and clearly communicate if no relevant publications are found. +* When using the `fetch_publications` tool, the response contains the full list of VCell related publications with fields: `pubKey` (unique identifier), `title`, `authors` (array), `year`, `citation` (full citation string in journal format), `pubmedid` (PubMed ID), `doi` (DOI link to the publication), `biomodelReferences` (array of related biomodels), and `mathmodelReferences` (array of related mathematical models). +* When presenting publications, always provide elaborate, fact-based responses based solely on the available tool results. + + +## Formatting Guidelines for Biomodels +You MUST follow this exact output format. Do NOT modify, omit, or reorder any fields. +ALWAYS use the provided name and biomodelID exactly. Format the name as [name](/search/biomodelID). + +### Formatting Guidelines for biomodels retrieved from VCell database (VCDB) +* For each VCELL model: +``` +1. **[Biomodel Name](/search/${biomodelID})** + - **Biomodel Key:** ${biomodelId} + - **Owner:** ${owner} + - **Description:** ${description or summary of the biomodel, do not include `clonedFrom` info} + - **Applications:** + +List every application name for the model in italics, each on its own bullet point. Under each +bulleted application name, list its corresponding simulations, with each simulation followed by a solver in round brackets. +Do not omit any applications. +``` + +### Rules for LONG LISTS (>10 models) + +- ALWAYS continue numbering sequentially (1, 2, 3, ...) +- Repeat the EXACT same structure for EVERY item +- If applications exist, do NOT omit them +- Do NOT summarize or shorten later items +- Do NOT merge multiple models into one entry +- Maintain identical formatting across all entries + +### Biomodel Analysis Guidelines +* Include as many relevant details as possible, such as biomodel ID, names, descriptions, parameters, and any other relevant metadata that can aid in the user's understanding. +* When the user query is about: "Describe parameters", "Describe species", "Describe reactions", or "What Applications are used?" — specifically in the context of model analysis: Make sure to use the `get_vcml_file` tool to retrieve the VCML file for the VCELL biomodel. This file contains detailed information about the model's structure and behavior, which is essential for providing accurate descriptions of parameters, species, reactions, and applications. Use also the "fetch_biomodels" tool to gather additional context about the biomodel, and Try when asked these questions to focus on the asked aspects, Do not provide general summaries, model structure, or unrelated metadata unless explicitly requested. Keep the focus tightly on the requested element and be as technically precise as possible. Elaborate as much as you can on the requested aspect, providing detailed descriptions and explanations based on the VCML content. +""" diff --git a/docker-compose.yml b/docker-compose.yml index 958ec35..bb644a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,9 @@ services: container_name: frontend-vcell ports: - "3000:3000" + environment: + NEXT_PUBLIC_API_URL: http://localhost:8000 + NEXT_PUBLIC_API_URL_BMDB: https://www.biomodels.org/ depends_on: - backend env_file: diff --git a/frontend/app/admin/settings/page.tsx b/frontend/app/admin/settings/page.tsx index 28fa6c7..f2ea618 100644 --- a/frontend/app/admin/settings/page.tsx +++ b/frontend/app/admin/settings/page.tsx @@ -277,7 +277,7 @@ export default function AdminSettingsPage() {
Follow the steps below to get started with your local deployment.

- For more details, check https://github.com/KacemMathlouthi/VCell-GSoC + For more details, check https://github.com/KacemMathlouthi/VCell-GSoC

@@ -287,10 +287,10 @@ export default function AdminSettingsPage() {

Step 1: Clone the Repository

- git clone https://github.com/KacemMathlouthi/VCell-GSoC.git + git clone https://github.com/virtualcell/VCell-AI.git
}>
{/* Header */} @@ -122,7 +133,8 @@ export default function ChatPage() {
@@ -133,5 +145,6 @@ export default function ChatPage() { onClose={handleOnboardingClose} />
+ ); } diff --git a/frontend/app/search/[bmid]/page.tsx b/frontend/app/search/[bmid]/page.tsx index fc7c478..705c2c9 100644 --- a/frontend/app/search/[bmid]/page.tsx +++ b/frontend/app/search/[bmid]/page.tsx @@ -1,5 +1,5 @@ "use client"; - +import { extractDescription } from "../page"; import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -78,10 +78,25 @@ interface BiomodelDetail { applications: Application[]; } +interface BiomodelDBDetail { + bmdbID: string; + name: string; + author?: string; + description?: string; + files: Array<{ + name: string; + description: string; + fileSize: string; + downloadLink: string; + }>; +} + export default function BiomodelDetailPage() { const params = useParams<{ bmid: string }>(); const bmid = params?.bmid; + const bmdbID = bmid.startsWith("BIOMD") || bmid.startsWith("MODEL"); const [data, setData] = useState(null); + const [bmdbData, setBmdbData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [activeTab, setActiveTab] = useState("overview"); @@ -127,10 +142,46 @@ export default function BiomodelDetailPage() { }, ]; + // set default selected checkbox as bmdb if the id is from biomodels database, otherwise set to vcdb + const [selectedDatabases, setSelectedDatabases] = useState<("bmdb" | "vcdb")[]>( + bmdbID ? ["bmdb"] : ["vcdb"] + ); + useEffect(() => { if (!bmid) return; setLoading(true); setError(""); + + if (bmdbID) { + fetch(`${process.env.NEXT_PUBLIC_API_URL_BMDB}/${bmid}?format=json`) + .then((res) => { + if (!res.ok) throw new Error("Failed to fetch biomodel details"); + return res.json(); + }) + .then((json) => { + setBmdbData({ + bmdbID: bmid, + name: json.name, + author: json.publication?.authors?.map((a: any) => a.name) || [], + description: extractDescription(json.description || ""), + files: [ + ...(json.files?.main || []), + ...(json.files?.additional || []) + ] + // max out at 4 files shown - can change if needed + .slice(0, 4) + .map((file: any) => ({ + name: file.name, + description: file.description || "", + fileSize: file.fileSize || "", + downloadLink: `${process.env.NEXT_PUBLIC_API_URL_BMDB}/model/download/${bmid}?filename=${file.name}`, + })), + }); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + } else { + fetch(`${process.env.NEXT_PUBLIC_API_URL}/biomodel?bmId=${bmid}`) .then((res) => { if (!res.ok) throw new Error("Failed to fetch biomodel details"); @@ -145,13 +196,32 @@ export default function BiomodelDetailPage() { }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); + } }, [bmid]); - useEffect(() => { - if (!data?.bmKey) return; + if (!bmid) return; const fetchDiagramAnalysis = async () => { try { + + // get diagram image from biomodels database + if (bmdbID) { + const bmdbAPIUrl = process.env.NEXT_PUBLIC_API_URL_BMDB; + const bmdbRes = await fetch(`${bmdbAPIUrl}/model/download/${bmid}?filename=${bmid}.png`); + if (bmdbRes.ok) { + // convert the .png response into blob to store image as a string + const blob = await bmdbRes.blob(); + const reader = new FileReader(); + + // store the base64 string + reader.onloadend = () => { + const base64data = reader.result as string; + setDiagramAnalysis(base64data); + }; + reader.readAsDataURL(blob); + } + } else { + if (!data?.bmKey) return; const apiUrl = process.env.NEXT_PUBLIC_API_URL; const res = await fetch(`${apiUrl}/analyse/${data.bmKey}/diagram`, { method: "POST", @@ -167,29 +237,176 @@ export default function BiomodelDetailPage() { const errorData = await res.json(); setAnalysisError(errorData.detail || "Failed to analyze diagram."); } - } catch (err) { + }} catch (err) { setAnalysisError("Failed to fetch diagram analysis."); } - }; + }; - fetchDiagramAnalysis(); - }, [data?.bmKey]); + fetchDiagramAnalysis(); + }, [data?.bmKey, bmdbID, bmid]); // Create combined messages when diagram analysis is ready useEffect(() => { - if (diagramAnalysis) { + if (diagramAnalysis && !bmdbID && data?.bmKey) { const diagramMessage = `# Diagram Analysis \n ${diagramAnalysis}`; setCombinedMessages([diagramMessage]); } }, [diagramAnalysis]); if (error) return
{error}
; - if (!data) return null; + if (!data && !bmdbData) return null; + + let biomodelDiagramUrl = ""; + if (data) { + biomodelDiagramUrl = `https://vcell.cam.uchc.edu/api/v0/biomodel/${data.bmKey}/diagram`; + } else if (bmdbID) { + biomodelDiagramUrl = diagramAnalysis; // the base64 image string + } - const biomodelDiagramUrl = `https://vcell.cam.uchc.edu/api/v0/biomodel/${data.bmKey}/diagram`; + if (error) return
{error}
; + if (loading) return
Loading...
; return ( -
+ <> + {bmdbID && bmdbData && ( +
+
+ + + + + {bmdbData?.name} + + + {" "} + {bmdbData?.bmdbID} + + {" "} + {bmdbData?.author} + + + + + + + + Overview + + + + AI Analysis + + + + + {/* Diagram section */} +
+ Biomodel Diagram setError("Failed to load diagram image.")} + onLoad={() => setError("")} + /> +
+ + {/* Description Section */} +
+ + +
+ + + Description + + +
+
+ +
+ {bmdbData && bmdbData.description && bmdbData.description.trim() !== "" + ? bmdbData.description + : "No description is available for this biomodel"} +
+
+
+ + {/* Files Section */} + + +
+ + + Files + + +
+
+ + {bmdbData?.files?.length > 0 && ( +
+ {bmdbData.files.map((file, index) => ( +
+ +
+ {file.name} +
+ +
+ {file.description} +
+ + + Download → + +
+ ))} +
+ )} +
+
+
+
+
+
+ + + {/* AI Analysis Section */} +
+
+ + + AI Analysis Assistant + +
+
+ +
+
+
+ +
+
+
+
+
+)} {!bmdbID && data && ( +
@@ -259,7 +476,7 @@ export default function BiomodelDetailPage() { - + Overview @@ -434,7 +651,8 @@ export default function BiomodelDetailPage() {
-
- ); + )} + + ); } + diff --git a/frontend/app/search/page.tsx b/frontend/app/search/page.tsx index c602b96..8d8d206 100644 --- a/frontend/app/search/page.tsx +++ b/frontend/app/search/page.tsx @@ -48,6 +48,30 @@ interface BiomodelResult { groupUsers: string[]; } +// add function to format description from the BMDB response, which is in XML format +// extract using DOMParser +export function extractDescription(xmlString: string): string { + if (!xmlString) return ""; + + try { + const parser = new DOMParser(); + const document = parser.parseFromString(xmlString, "text/xml"); + + // Get
+ const descriptionDiv = document.querySelector("div.dc\\:description p"); + + if (descriptionDiv?.textContent) { + return descriptionDiv.textContent.trim(); + } + return ""; + // else first paragraph + // const firstP = document.querySelector("p"); + // return firstP?.textContent?.trim() || ""; + } catch (err) { + return ""; + } +} + export default function BiomodelSearchPage() { const [filters, setFilters] = useState({ bmId: "", @@ -61,11 +85,17 @@ export default function BiomodelSearchPage() { orderBy: "date_desc", }); + const [BMDBQuery, setBMDBQuery] = useState(""); + const [BMDBResults, setBMDBResults] = useState([]); + const [BMDBIsLoading, setBMDBIsLoading] = useState(false); + + const [results, setResults] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isAdvancedSearchOpen, setIsAdvancedSearchOpen] = useState(false); const handleSearch = async () => { + setBMDBResults([]); setIsLoading(true); try { // Build query params from filters, omitting empty bmName @@ -109,6 +139,56 @@ export default function BiomodelSearchPage() { } }; + // introduce a separate search function for the BioModel DB search form. + const handleSearchBMDB = async () => { + setResults([]); + setBMDBIsLoading(true); + try { + // build API url + const apiUrl = `${process.env.NEXT_PUBLIC_API_URL_BMDB}/search?query=${BMDBQuery}&format=json`; + + const res = await fetch(apiUrl); + if (!res.ok) throw new Error("Failed to fetch biomodels"); + const data = await res.json(); + console.log("BioModels response in data.models:", data.models); + + // get description from the id's specific endpoint + const description = (data.models || []).map(async (model: any) => { + try { + + // build url using model ID + const descURL = `${process.env.NEXT_PUBLIC_API_URL_BMDB}/${model.id}?format=json`; + const descRes = await fetch(descURL); + if (!descRes.ok) throw new Error("Failed to fetch model description"); + const descData = await descRes.json(); + + return descData.description || ""; + } catch (err) { + console.error(`Error fetching description for model ${model.id}:`, err); + return ""; + } + }); + + // get descriptions for all of the models from the query results + let descriptions = await Promise.all(description); + + // get the actual description from the xml format returned by the API + descriptions = descriptions.map((desc) => extractDescription(desc)); + + // format API response to include id, name, and description for each model + const mappedResults = (data.models || []).map((model: any, index: number) => ({ + id: model.id, + name: model.name, + description: descriptions[index], + })); + setBMDBResults(mappedResults); + } catch (err) { + setBMDBResults([]); + } finally { + setBMDBIsLoading(false); + }}; + + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString("en-US", { year: "numeric", @@ -382,18 +462,90 @@ export default function BiomodelSearchPage() { + {/* search box for Biomodel DB */} + + + + + Search BioModels Database + + + +
+
+ + + setBMDBQuery(e.target.value) + } + className="border-slate-300 focus:border-blue-500 h-9" + /> +
+
+ + {/* Search Button for BioModel DB*/} +
+ +
+
+
+ {/* Results Section */} - {results.length > 0 && ( + {(results.length > 0 || BMDBResults.length > 0) && (

Search Results

- {results.length} models found + {results.length > 0 + ? results.length + : BMDBResults.length} models found
+ {/* Output for BioModels Database Results */} + {BMDBResults.length > 0 && ( +
+ {BMDBResults.map((model: any) => ( + + + +

{model.name}

+

{model.id}

+ {model.description && ( +

+ {model.description} +

+ )} +
+
+ + ))} +
+ )} + + {/* Output for VCell Biomodel Database Results */} + {results.length > 0 && (
{results.map((model) => ( ))}
+ )}
)}
diff --git a/frontend/components/ChatBox.tsx b/frontend/components/ChatBox.tsx index 15c89fd..27f4ade 100644 --- a/frontend/components/ChatBox.tsx +++ b/frontend/components/ChatBox.tsx @@ -6,9 +6,11 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { MarkdownRenderer } from "@/components/markdown-renderer"; import { MessageSquare, Send, Bot, User, Loader2 } from "lucide-react"; +import { useSearchParams } from "next/navigation"; + interface Message { id: string; - role: "user" | "assistant"; + role: "user" | "assistant" | "system"; content: string; timestamp: Date; } @@ -32,9 +34,12 @@ interface ChatParameters { } interface ChatBoxProps { + database?: ("vcdb" | "bmdb")[]; + placeholder?: string; startMessage: string | string[]; quickActions: QuickAction[]; - supplementalActions?: QuickAction[]; + VCellActions?: QuickAction[]; + bmdbActions?: QuickAction[]; cardTitle: string; promptPrefix?: string; isLoading?: boolean; @@ -42,9 +47,12 @@ interface ChatBoxProps { } export const ChatBox: React.FC = ({ + database, + placeholder, startMessage, quickActions, - supplementalActions, + VCellActions, + bmdbActions, cardTitle, promptPrefix, isLoading: isInitialLoading = false, @@ -72,14 +80,44 @@ export const ChatBox: React.FC = ({ return []; }; - const [messages, setMessages] = useState( - createInitialMessages(startMessage), - ); + const [messages, setMessages] = useState(() => { + return createInitialMessages(startMessage); +}); + + const searchParams = useSearchParams(); + + useEffect(() => { + const id = searchParams.get("conversation"); + + if (id) { + const stored = localStorage.getItem("chat_conversations"); + if (!stored) return; + + const conversations = JSON.parse(stored); + const convo = conversations.find((c: any) => c.id === id); + + if (convo) { + setMessages(convo.messages); + setConversationId(id); +} + } else { + setMessages(createInitialMessages(startMessage)); + } +}, [searchParams, startMessage]); + + const [inputMessage, setInputMessage] = useState(""); - const [isLoading, setIsLoading] = useState(false); + const [conversationId, setConversationId] = useState(null); + const [useVCDB, setUseVCDB] = useState(database ? database.includes("vcdb") : true); + const [useBMDB, setUseBMDB] = useState(database ? database.includes("bmdb") : false); + const [isLoadingVCDB, setIsLoadingVCDB] = useState(false); + const [isLoadingBMDB, setIsLoadingBMDB] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); + const abortController = useRef(null); + const isLoading = isLoadingVCDB || isLoadingBMDB; + const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; @@ -88,45 +126,126 @@ export const ChatBox: React.FC = ({ scrollToBottom(); }, [messages]); - // Update messages when startMessage changes (when analysis completes) - useEffect(() => { - if (startMessage && !isInitialLoading) { - setMessages(createInitialMessages(startMessage)); + +useEffect(() => { + if (messages.length === 0) return; + const hasUserMessage = messages.some((m) => m.role === "user"); + if (!hasUserMessage) return; + + saveConversation(messages); + + window.dispatchEvent(new Event("conversation-updated")); +}, [messages]); + +const saveConversation = (messages: Message[]) => { + if (messages.length === 0) return; + + const stored = localStorage.getItem("chat_conversations"); + const conversations = stored ? JSON.parse(stored) : []; + + let id = conversationId; + + // If no conversation yet then create a new one + if (!id) { + id = crypto.randomUUID(); + setConversationId(id); + + const firstUserMessage = messages.find((m) => m.role === "user"); + + const newConversation = { + id, + title: firstUserMessage?.content.slice(0, 40) || "Chat", + messages, + }; + + conversations.unshift(newConversation); + } else { + // Update existing conversation + const index = conversations.findIndex((c: any) => c.id === id); + if (index !== -1) { + conversations[index].messages = messages; } - }, [startMessage, isInitialLoading]); + } - // Helper function to format biomodel IDs as hyperlinks - const formatBiomodelIds = (content: string, bmkeys: string[]): string => { + localStorage.setItem("chat_conversations", JSON.stringify(conversations)); +}; + + const activeActions = [ + ...(useVCDB && VCellActions ? VCellActions : []), + ...(useBMDB && bmdbActions ? bmdbActions : []), + ]; + + const formatBiomodelIds = (content: string, bmkeys: string[], db: "vcdb" | "bmdb"): string => { if (!bmkeys || bmkeys.length === 0) return content; let formattedContent = content; + let db_link = ""; + if (db == "vcdb") { + db_link = "https://vcell.cam.uchc.edu/api/v0/biomodel/" + } else if (db == "bmdb") { + db_link = "https://www.biomodels.org/" + } // Replace biomodel IDs with hyperlinks bmkeys.forEach((bmId) => { - const searchString = `${bmId}`; - const encodedPrompt = encodeURIComponent(`Describe model`); - /* const ai_link = `[AI Analysis](/analyze/${bmId}?prompt=${encodedPrompt})`; - const db_link = `[Database](/search/${bmId})`; - const replacementString = `**${bmId}** -- ${ai_link}  |  ${db_link}`; */ - const db_link = `[Database Details](/search/${bmId})`; - const replacementString = `**${bmId}** || ${db_link}`; - formattedContent = formattedContent.replaceAll( - searchString, - replacementString, - ); + const link = `[Database Details](${db_link}${bmId})`; + const replacementString = `${bmId} || ${link}`; + + // only replace if bmId is not already in a /search/ link + const regex = new RegExp(`(? { - setInputMessage(""); - handleSendMessage(message); + if (database) { + setInputMessage(""); + const userMessage: Message = { + id: Date.now().toString(), + role: "user", + content: message, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMessage]); + if (database.includes("vcdb")) { + handleSendMessage(message); + } + if (database.includes("bmdb")) { + handleSendMessageBMDB(message); + } + } else { + handleSend(message) + } }; + const handleSend = (inputMessage: string) => { + if (!inputMessage.trim()) return; + if (!useVCDB && !useBMDB) { + alert("Please select at least one database."); + return; + } + const userMessage: Message = { + id: Date.now().toString(), + role: "user", + content: inputMessage, + timestamp: new Date(), + }; + setMessages((prev) => [...prev, userMessage]); + + if (useVCDB) {handleSendMessage(inputMessage);} + if (useBMDB){handleSendMessageBMDB(inputMessage);} +}; + const handleSendMessage = async (overrideMessage?: string) => { const msg = overrideMessage ?? inputMessage; if (!msg.trim()) return; + + const controller = new AbortController(); + abortController.current = controller; + // Build parameter context string let parameterContext = ""; if (parameters) { @@ -162,19 +281,34 @@ export const ChatBox: React.FC = ({ } } - const userMessage: Message = { - id: Date.now().toString(), - role: "user", - content: msg, - timestamp: new Date(), - }; - setMessages((prev) => [...prev, userMessage]); + console.log("PPPPPP: " + "This is the msg: " + msg); + // const userMessage: Message = { + // id: Date.now().toString(), + // role: "user", + // content: msg, + // timestamp: new Date(), + // }; + // setMessages((prev) => [...prev, userMessage]); setInputMessage(""); - setIsLoading(true); + setIsLoadingVCDB(true); try { const finalPrompt = promptPrefix ? `${promptPrefix} ${msg}${parameterContext}` : `${msg}${parameterContext}`; + console.log("RRRRRR: " + "This is the promptPrefix: " + promptPrefix); + console.log("RRRRRR: " + "This is the finalPrompt sent to backend: " + finalPrompt); + + // Only send system messages, last 5 non-system messages, and new user prompt to backend + const systemMessages = messages.filter((m) => m.role === "system"); + const recentNonSystem = messages.filter((m) => m.role !== "system").slice(-5); + + const historyToSend = [ + ...systemMessages, + ...recentNonSystem, + { role: "user", content: finalPrompt }, + ].map((msg) => ({ role: msg.role, content: msg.content })); + + const res = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/query`, { @@ -183,29 +317,41 @@ export const ChatBox: React.FC = ({ "Content-Type": "application/json", accept: "application/json", }, - body: JSON.stringify({ - conversation_history: [ - ...messages, - { role: "user", content: finalPrompt }, - ].map(msg => ({ - role: msg.role, - content: msg.content, - })), - }), + body: JSON.stringify({ conversation_history: historyToSend }), +//JSON.stringify({ + // conversation_history: [ + // ...messages, + // { role: "user", content: finalPrompt }, + // ].map(msg => ({ + // role: msg.role, + // content: msg.content, + // })), + // }), + signal: controller.signal, }, ); + console.log("AAAAAA API query sent to backend: " + finalPrompt); const data = await res.json(); const aiResponse = data.response || "Sorry, I didn't get a response from the server."; const bmkeys = data.bmkeys || []; + console.log(bmkeys) + const toolSummary = data.tool_summary || ""; // Format the response to include hyperlinks for biomodel IDs - const formattedResponse = formatBiomodelIds(aiResponse, bmkeys); + const formattedResponse = formatBiomodelIds(aiResponse, bmkeys, "vcdb"); + + // show the tool summar text in the website output + const toolSummaryText = toolSummary + ? `\n\n${toolSummary}` + : ""; const assistantMessage: Message = { id: (Date.now() + 1).toString(), role: "assistant", - content: formattedResponse, + content: useVCDB && useBMDB + ? `**VCell Database:**\n\n${formattedResponse}${toolSummaryText}` + : `${formattedResponse}${toolSummaryText}`, timestamp: new Date(), }; setMessages((prev) => [...prev, assistantMessage]); @@ -221,17 +367,168 @@ export const ChatBox: React.FC = ({ }, ]); } finally { - setIsLoading(false); + setIsLoadingVCDB(false); } - }; + }; // End of handleSendMessage + +// +const handleSendMessageBMDB = async (overrideMessage?: string) => { + const msg = overrideMessage ?? inputMessage; + if (!msg.trim()) return; + const controller = new AbortController(); + abortController.current = controller; + // Build parameter context string + let parameterContext = ""; + if (parameters) { + const contextParts = []; + + if (parameters.biomodelId) { + contextParts.push(`biomodel ID: ${parameters.biomodelId}`); + } + if (parameters.bmName) { + contextParts.push(`model name: ${parameters.bmName}`); + } + if (parameters.owner) { + contextParts.push(`authored by: ${parameters.owner}`); + } + if (parameters.category && parameters.category !== "all") { + contextParts.push(`category: ${parameters.category}`); + } + if (parameters.savedLow) { + contextParts.push(`saved after: ${parameters.savedLow}`); + } + if (parameters.savedHigh) { + contextParts.push(`saved before: ${parameters.savedHigh}`); + } + if (parameters.maxRows && parameters.maxRows !== 1000) { + contextParts.push(`max results: ${parameters.maxRows}`); + } + if (parameters.orderBy && parameters.orderBy !== "date_desc") { + contextParts.push(`sort by: ${parameters.orderBy}`); + } + + if (contextParts.length > 0) { + parameterContext = `\n\nHere are some specifics that I want: ${contextParts.join(", ")}`; + } + } + + console.log("PPPPPP: " + "This is the msg: " + msg); + // const userMessage: Message = { + // id: Date.now().toString(), + // role: "user", + // content: msg, + // timestamp: new Date(), + // }; + // setMessages((prev) => [...prev, userMessage]); + setInputMessage(""); + setIsLoadingBMDB(true); + try { + const finalPrompt = promptPrefix + ? `${promptPrefix} ${msg}${parameterContext}` + : `${msg}${parameterContext}`; + + // Only send system messages, last 5 non-system messages, and new user prompt to backend + const systemMessages = messages.filter((m) => m.role === "system"); + const recentNonSystem = messages.filter((m) => m.role !== "system").slice(-5); + + const historyToSend = [ + ...systemMessages, + ...recentNonSystem, + { role: "user", content: finalPrompt }, + ].map((msg) => ({ role: msg.role, content: msg.content })); + + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/bmdb-search`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + accept: "application/json", + }, + body: JSON.stringify({ conversation_history: historyToSend }), + // JSON.stringify({ + // conversation_history: [ + // ...messages, + // { role: "user", content: finalPrompt }, + // ].map(msg => ({ + // role: msg.role, + // content: msg.content, + // })), + // }), + signal: controller.signal, + }, + ); + const data = await res.json(); + const aiResponse = + data.response || "Sorry, I didn't get a response from the server."; + const bmkeys = data.bmkeys || []; + + const toolSummary = data.tool_summary || ""; + + console.log(bmkeys) + // Format the response to include hyperlinks for biomodel IDs + const formattedResponse = formatBiomodelIds(aiResponse, bmkeys, "bmdb"); + + // show tool summary text in the website output + const toolSummaryText = toolSummary + ? `\n\n${toolSummary}` + : ""; + + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: "assistant", + content: useVCDB && useBMDB + ? `**BIOMD Database:**\n\n${formattedResponse}${toolSummaryText}` + : `${formattedResponse}${toolSummaryText}`, + timestamp: new Date(), + }; + setMessages((prev) => [...prev, assistantMessage]); + } catch (error) { + setMessages((prev) => [ + ...prev, + { + id: (Date.now() + 2).toString(), + role: "assistant", + content: + "There was an error connecting to the backend. Please try again.", + timestamp: new Date(), + }, + ]); + } finally { + setIsLoadingBMDB(false); + } + }; // End of handleSendMessageBMDB + + const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - handleSendMessage(); + handleSend(inputMessage); + } + }; + + const handleStop = () => { + if (abortController.current) { + abortController.current.abort(); + abortController.current = null; + + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + role: "system", + content: "Response stopped by user.", + timestamp: new Date(), + }, + ]); + + setIsLoadingVCDB(false); + setIsLoadingBMDB(false); } }; + const session = searchParams.get('session') return ( @@ -246,11 +543,14 @@ export const ChatBox: React.FC = ({ {messages.map((message) => (
= ({
+
+ + + +
setInputMessage(e.target.value)} onKeyPress={handleKeyPress} - placeholder="Ask any questions about VCell biomodels..." + placeholder={placeholder || (useVCDB && useBMDB + ? "Ask about VCell and BioModels biomodels..." + : useVCDB + ? "Ask about VCell biomodels..." + : "Ask about BioModels biomodels..." + )} className="flex-1 border-slate-300 focus:border-blue-500" disabled={isLoading || isInitialLoading} /> + +
{/* Quick Actions - positioned directly under search bar */} @@ -354,10 +685,11 @@ export const ChatBox: React.FC = ({
)} - {supplementalActions && ( + + {useVCDB && !useBMDB && VCellActions && (
- {supplementalActions.map((action, idx) => ( + {VCellActions.map((action, idx) => (
)} + + {useBMDB && !useVCDB && bmdbActions && ( +
+
+ {bmdbActions.map((action, idx) => ( + + ))} +
+
+ )} + + {useVCDB && useBMDB && activeActions.length > 0 && ( +
+
+ {activeActions.map((action, idx) => ( + + ))} +
+
+ )} +
); diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index c02a5b3..feede0a 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -32,22 +32,38 @@ import { useSidebar, } from "@/components/ui/sidebar"; -const historyItems = [ - "Calcium Biomodel Comparison", - "Protein Details on Tutorial Models", - "Biomodels authored by ModelBrick", - "Count of Rule-based models", - "VCML File Analysis of Calcium Models", -]; +import { useState, useEffect } from "react"; export function AppSidebar() { const pathname = usePathname(); const { state } = useSidebar(); const isCollapsed = state === "collapsed"; + const [historyItems, setHistoryItems] = useState([]); + + useEffect(() => { + const loadHistory = () => { + const stored = localStorage.getItem("chat_conversations"); + if (!stored) return; + + const conversations = JSON.parse(stored); + + setHistoryItems( + conversations.map((c: any) => ({ + id: c.id, + text: c.title + })) + ); + }; + + loadHistory(); + window.addEventListener("conversation-updated", loadHistory); + return () => window.removeEventListener("storage", loadHistory); +}, []); + if (pathname == "/" || pathname == "/signin" || pathname == "/signup") { return null; - } + } return ( @@ -229,13 +245,17 @@ export function AppSidebar() { - {historyItems.map((item, index) => ( - - - {item} - - - ))} + {historyItems.map((item) => ( + + + + {item.text} + + + + ))} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3fb5178..377dbef 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -555,7 +555,6 @@ "integrity": "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2165,7 +2164,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -2177,7 +2175,6 @@ "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -2250,6 +2247,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2260,6 +2258,7 @@ "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -2282,7 +2281,6 @@ "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -2293,24 +2291,21 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", @@ -2318,7 +2313,6 @@ "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -2330,8 +2324,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", @@ -2339,7 +2332,6 @@ "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -2353,7 +2345,6 @@ "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -2364,7 +2355,6 @@ "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -2374,8 +2364,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", @@ -2383,7 +2372,6 @@ "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -2401,7 +2389,6 @@ "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -2416,7 +2403,6 @@ "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -2430,7 +2416,6 @@ "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -2446,7 +2431,6 @@ "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -2457,16 +2441,14 @@ "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/acorn": { "version": "8.15.0", @@ -2488,7 +2470,6 @@ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -2502,6 +2483,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2710,6 +2692,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -2728,8 +2711,7 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/busboy": { "version": "1.6.0", @@ -2863,7 +2845,6 @@ "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -3238,7 +3219,8 @@ "version": "8.5.1", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.5.1.tgz", "integrity": "sha512-JUb5+FOHobSiWQ2EJNaueCNT/cQU9L6XWBbWmorWPQT9bkbk+fhsuLr8wWrzXKagO3oWszBO7MSx+GfaRk4E6A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.5.1", @@ -3274,7 +3256,6 @@ "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -3300,8 +3281,7 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/escalade": { "version": "3.2.0", @@ -3330,7 +3310,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -3345,7 +3324,6 @@ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -3359,7 +3337,6 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -3370,7 +3347,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -3397,7 +3373,6 @@ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -3633,16 +3608,14 @@ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true, - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/has-flag": { "version": "4.0.0", @@ -3650,7 +3623,6 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -4026,7 +3998,6 @@ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -4056,8 +4027,7 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "1.0.0", @@ -4106,7 +4076,6 @@ "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.11.5" } @@ -4458,8 +4427,7 @@ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", @@ -5071,7 +5039,6 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -5082,7 +5049,6 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -5190,8 +5156,7 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/next": { "version": "15.2.4", @@ -5462,6 +5427,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5620,7 +5586,6 @@ "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -5630,6 +5595,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5639,6 +5605,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -5651,6 +5618,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6058,8 +6026,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/scheduler": { "version": "0.26.0", @@ -6106,7 +6073,6 @@ "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "randombytes": "^2.1.0" } @@ -6210,7 +6176,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6230,7 +6195,6 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -6454,7 +6418,6 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -6492,6 +6455,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -6584,7 +6548,6 @@ "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.14.0", @@ -6604,7 +6567,6 @@ "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -6639,8 +6601,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/thenify": { "version": "3.3.1", @@ -7007,7 +6968,6 @@ "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -7082,7 +7042,6 @@ "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }