diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..4115eeb82b75b7a6305b328700ae8e7190493f7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Temporary files +*.tmp +*.temp + +# HF Spaces specific +.git/ diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..882ab2139d338f74da5c523065b5ffdb7d32f219 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +# This file marks the tools directory as a Python package. diff --git a/app.py b/app.py index 744aa6b00d83feda5f1c1868d52c9f7928e4a5ef..552b102c77ce453dbacfd2c59e892abdfe6779f4 100644 --- a/app.py +++ b/app.py @@ -1,59 +1,2427 @@ -#!/usr/bin/env python3 """ -Fresh HF Space Test - ID Agents Simple -Brand new deployment to test if the issue is Space-specific +app.py +------ + +Main application entry point for the modular AI agent system. Handles UI (Gradio), agent orchestration, knowledge loading, and chat interface logic. + +- Initializes logging and structlog for consistent, timestamped logs. +- Loads and manages agent configurations and RAG retriever. +- Provides chat, knowledge ingestion, and agent builder functions. +- Integrates with OpenAI via llm_connector and supports streaming responses. +- Designed for extensibility and future integration with Model Context Protocol (MCP) and Agent-to-Agent (A2A) standards. + """ +# --- Imports --- import gradio as gr +import json +import re -def simple_test(message): - """Simple test function""" - if not message: - return "Please enter a message to test the deployment!" - return f"πŸŽ‰ SUCCESS! Your message: '{message}' - Deployment is working perfectly!" - -# Create a simple, clean interface -with gr.Blocks( - title="ID Agents - New Space Test", - theme=gr.themes.Soft() -) as demo: - - gr.Markdown(""" - # 🦠 ID Agents - Fresh Space Test - - This is a brand new HF Space to test if deployment works. - """) +import openai +from openai import RateLimitError, APIError, APIConnectionError, OpenAI +from typing import Dict, cast +from core.utils.rag import KnowledgeLoader, SimpleRAGRetriever +from core.utils.skills_registry import tool_registry, get_tool_by_name +import asyncio +import structlog +import logging +from structlog.stdlib import LoggerFactory, BoundLogger +from core.utils.llm_connector import AgentLLMConnector +import pandas as pd +from gradio import ChatMessage +from core.agents.agent_utils import linkify_citations, build_agent, load_prefilled, prepare_download, preload_demo_chat, _safe_title, extract_clinical_variables_from_history +from config import agents_config, skills_library, prefilled_agents +from core.ui.ui import build_ui, show_landing, show_builder, show_chat, refresh_active_agents_widgets +import tools + +import os + +# Print API keys for debugging +# Print all SERPER_* environment variables for robust debugging +#for k, v in os.environ.items(): +# if k.startswith("SERPER"): +# print(f"{k} in Python: {v}") +#print("OPENAI_API_KEY in Python:", os.getenv("OPENAI_API_KEY")) +#print("SERPER_API_KEY in Python:", os.getenv("SERPER_API_KEY")) + +# Logging setup +logging.basicConfig(filename="app.log", level=logging.INFO, format="%(message)s") +structlog.configure(logger_factory=LoggerFactory()) +logger: BoundLogger = structlog.get_logger() +# Structlog config +structlog.configure( + processors=[ + structlog.processors.TimeStamper(fmt="iso"), + structlog.dev.ConsoleRenderer() + ], + logger_factory=LoggerFactory(), + wrapper_class=structlog.make_filtering_bound_logger(logging.INFO), + cache_logger_on_first_use=True, +) + +# OpenAI API Key Check +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") +if not OPENAI_API_KEY: + raise RuntimeError("OPENAI_API_KEY environment variable is not set. Please set it before running the application.") +OPENAI_API_KEY = cast(str, OPENAI_API_KEY) + +# initialize client once, pulling key from your environment +client = OpenAI(api_key=OPENAI_API_KEY) + +def simple_chat_response(user_message, history): + """ + A bare-bones GPT-3.5-turbo chat using the v1.0+ SDK. + - history is a list of dicts: [{"role":"user"|"assistant","content":...}, …] + - Returns (updated_history, "") + """ + if history is None: + history = [] + + # 1) record the user’s message + history.append({"role": "user", "content": user_message}) + + # 2) call the new chat endpoint + completion = client.chat.completions.create( + model="gpt-3.5-turbo", + messages=history, + temperature=0.7, + ) # :contentReference[oaicite:0]{index=0} + + + # 3) extract and record the assistant reply + reply_content = completion.choices[0].message.content + reply = reply_content.strip() if reply_content else "" + history.append({"role": "assistant", "content": reply}) + + # 4) return the chat history (for gr.Chatbot) and clear the input box + return history, "" + + + + + +# --- Chat orchestration logic is now in chat_orchestrator.py --- +from core.agents.chat_orchestrator import simulate_agent_response_stream, build_log, _stream_to_agent, MAX_HISTORY, orchestrators + + +def reset_chat(agent_json): + """ + Clears history, input, invocation log, and active_children state. + Also clears orchestrator state to prevent response persistence. + """ + # Clear orchestrator state to prevent persistence across conversations + from core.agents.chat_orchestrator import orchestrators + orchestrators.clear() - with gr.Row(): - with gr.Column(): - message_input = gr.Textbox( - label="Test Message", - placeholder="Type any message to test...", - lines=2 - ) - test_btn = gr.Button("πŸš€ Test Deployment", variant="primary") - - with gr.Column(): - result_output = gr.Textbox( - label="Response", + agent_data = json.loads(agent_json) + name = agent_data.get("agent_name", "Agent") + welcome = f"πŸ‘‹ Hello! I'm {name}. How can I assist you today?" + chat_history = [{"role":"assistant","content":welcome}] + invocation_log = "" + active_children= [] + return chat_history, "", invocation_log, active_children + +def load_agent_to_builder(agent_name): + if agent_name in agents_config: + agent_data = json.loads(agents_config[agent_name]) + return ( + agent_data.get("agent_type", ""), + agent_data.get("agent_name", ""), + agent_data.get("agent_mission", ""), + agent_data.get("skills", []) + ) + else: + return None, "", "", [] + +def remove_selected_agent(agent_name: str): + # 1) Remove from your in-memory store + if agent_name in agents_config: + del agents_config[agent_name] + + # 2) Re-render the list of active agents + if agents_config: + active_md = "### 🧠 Active Agents\n" + "\n".join(f"- {name}" for name in agents_config) + else: + active_md = "### 🧠 Active Agents\n_(None yet)_" + + # 3) Build the new dropdown state + new_choices = list(agents_config.keys()) + # gr.update to reset selection (value=None) and update choices + dropdown_update = gr.update(choices=new_choices, value=None) + + # Return in the same order you wired the outputs + return active_md, dropdown_update + +def update_skills(selected_type: str): + """Callback to repopulate the skills checkbox based on chosen agent type.""" + return gr.update(choices=skills_library.get(selected_type, [])) + +def handle_uploaded_files(files): + """ + Callback for the File component: spin up (or reuse) your RAG retriever + and index any newly uploaded docs. + """ + global rag_retriever + rag_retriever = SimpleRAGRetriever(openai_api_key=os.getenv("OPENAI_API_KEY")) + if files: + for f in files: + rag_retriever.add_knowledge(f) + # Return a tuple of (visible-update, message) to your upload_alert Markdown + return gr.update(visible=True), "βœ… Files uploaded and indexed successfully!" + +def populate_from_preset(prefilled_name): + if prefilled_name != "None": + at, an, am, sk = load_prefilled(prefilled_name, prefilled_agents) + return at, an, am, sk, True + return None, "", "", [], False + +def on_agent_type_change(selected_type, was_prefilled): + # always refresh the skill choices + new_skills = skills_library.get(selected_type, []) + skill_update = gr.update(choices=new_skills, value=[]) + if was_prefilled: + # consume the flag, but leave name & mission intact + return skill_update, gr.update(), gr.update(), False + # manual change: clear everything + return skill_update, gr.update(value=""), gr.update(value=""), False + +def chat_selected_agent(agent_name): + agent_json = agents_config.get(agent_name, "") + if agent_json: + # reset_chat returns 4 values: + # (chat_history, cleared_input, invocation_log, active_children) + chat_history, cleared_input, invocation_log, active_children = reset_chat(agent_json) + return chat_history, cleared_input, invocation_log, active_children, agent_json + + # If no agent is selected, clear everything + return [], "", "", [], "" + +def load_history(agent_name, histories): + # if we’ve never chatted, start with a greeting + if agent_name not in histories: + histories[agent_name] = [ + {"role":"assistant","content":f"πŸ‘‹ Hello! I'm {agent_name}. How can I help?"} + ] + return histories[agent_name] + +def chatpanel_handle(agent_name, user_text, histories): + """ + Uses your simulate_agent_response_stream (tool-aware) in a blocking way, + so that tool invocations actually happen. + Returns (final_history, updated_histories, cleared_input). + """ + # 1) Look up the JSON you saved in agents_config + agent_json = agents_config.get(agent_name) + if not agent_json: + return [], histories, "", "" + + # 2) Grab the prior history (or seed a greeting) + history = histories.get(agent_name, []) + if not history: + history = [{"role":"assistant", + "content":f"πŸ‘‹ Hello! I'm {agent_name}. How can I help?"}] + + # 3) Call your streaming function synchronously + + # simulate_agent_response_stream is async, so we need to run it in an event loop + import asyncio + async def run_stream(): + async for updated_history, _, invocation_log, _, challenger_info in simulate_agent_response_stream( + agent_json=agent_json, + history=history, + user_input=user_text, + debug_flag=False, + active_children=[] + ): + yield updated_history, invocation_log + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + gen = run_stream() + final_history = history + final_invocation_log = "" + try: + while True: + updated_history, invocation_log = loop.run_until_complete(gen.__anext__()) + final_history = updated_history + final_invocation_log = invocation_log + except StopAsyncIteration: + pass + + # 4) Save back and clear the input box + histories[agent_name] = final_history + return final_history, histories, "", final_invocation_log + +def refresh_chat_dropdown(): + return gr.update(choices=list(agents_config.keys()), value=None) + +def build_ui(): + # --- App Layout --- + with gr.Blocks() as app: + # Separate chat histories for each panel + simple_chat_history = gr.State([]) + builder_chat_histories = gr.State({}) + deployed_chat_histories = gr.State({}) + + # --- Custom CSS --- + app.css = """ + /* Futuristic color palette (lighter background) */ + :root { + --futuristic-bg: #fcfdff; /* nearly white */ + --futuristic-panel: #e8ecf7; + --futuristic-accent: #00ffe7; + --futuristic-accent2: #7f5cff; + --futuristic-card: #fafdff; + --futuristic-border: #c7d0e6; + --futuristic-text: #23263a; + --futuristic-muted: #7a7e8c; + } + + body, .gradio-container, .gr-block, .gr-app { + background: var(--futuristic-bg) !important; + color: var(--futuristic-text) !important; + } + + #landing_card, + #agent_form { + opacity: 1; + transition: opacity 0.5s ease; + } + + .hidden { + opacity: 0 !important; + pointer-events: none; + } + + /* Ensure the row containing the panels stretches and has a white background */ + #agent_form .gr-row { + display: flex !important; + align-items: stretch !important; + background: #fff !important; + border-radius: 20px; + box-shadow: 0 4px 32px #7f5cff11, 0 1.5px 8px #00ffe711; + min-height: 600px; + padding: 0 0.5em; + } + + /* Panels: remove min-height/height, keep original proportions */ + #agent_form .left-panel, + #agent_form .right-panel { + display: flex !important; + flex-direction: column !important; + /* No height or min-height here */ + } + + /* Vibrant animated gradient for 'Get Started' button */ + #start_button { + background: linear-gradient(90deg, #ff6b6b, #fddb3a, #00ffe7, #7f5cff); + background-size: 300% 300%; + color: #181c27 !important; + border: none; + border-radius: 8px; + font-weight: 700; + box-shadow: 0 0 16px #fddb3a88, 0 0 4px #ff6b6b88; + animation: gradient-move 3s ease-in-out infinite, pulse 2s infinite; + font-size: 18px; + padding: 12px 24px; + transition: background-position 0.5s; + } + + /* Exciting but static look for 'Chat With the Agents You Deployed' button */ + #to_chat_button { + background: linear-gradient(90deg, #7f5cff, #a259ff, #6e4cff, #c2a3ff); + background-size: 300% 300%; + color: #fff !important; + border: none; + border-radius: 8px; + font-weight: 700; + box-shadow: 0 0 16px #a259ff88, 0 0 4px #7f5cff88; + /* No animation here, just static gradient */ + font-size: 18px; + padding: 12px 24px; + } + + @keyframes gradient-move { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } + } + @keyframes pulse { + 0% { box-shadow: 0 0 16px var(--futuristic-accent2), 0 0 4px var(--futuristic-accent); } + 50% { box-shadow: 0 0 32px var(--futuristic-accent), 0 0 8px var(--futuristic-accent2); } + 100% { box-shadow: 0 0 16px var(--futuristic-accent2), 0 0 4px var(--futuristic-accent); } + } + + .left-panel { + background: linear-gradient(135deg, #e8ecf7 60%, #fafdff 100%); + padding: 15px; + border-radius: 16px; + border: 1px solid var(--futuristic-border); + box-shadow: 0 2px 16px #7f5cff22, 0 1.5px 8px #00ffe722; + } + .right-panel { + background: linear-gradient(135deg, #e8ecf7 60%, #fafdff 100%); + padding: 15px; + border-radius: 16px; + border: 1px solid var(--futuristic-border); + box-shadow: 0 2px 16px #7f5cff22, 0 1.5px 8px #00ffe722; + } + + #chat_history { + height: 520px; + overflow: auto; + background: #181c27; + border-radius: 12px; + border: 1px solid var(--futuristic-border); + } + + #chat_input { + width: 100%; + background: #23263a; + color: var(--futuristic-text); + border-radius: 8px; + border: 1px solid var(--futuristic-accent2); + } + + #chat_buttons .gr-button { + width: 49%; + } + + .right-panel { + display: flex; + flex-direction: column; + } + + .landing-card { + background: linear-gradient(135deg, #23263a 60%, #1a1f2b 100%); + border: 1.5px solid var(--futuristic-accent2); + border-radius: 28px; + padding: 32px 36px 32px 36px; + margin-bottom: 40px; + box-shadow: 0 4px 32px #00ffe733, 0 1.5px 8px #7f5cff33; + box-sizing: border-box; + } + + .landing-title { + text-align: center; + font-size: 2.1rem; + font-weight: 700; + margin-bottom: 0.5em; + } + .landing-subtitle { + text-align: center; + font-size: 1.15rem; + color: var(--futuristic-muted); + margin-bottom: 1.2em; + } + .steps-box { + background: var(--futuristic-card); + border: 1px solid var(--futuristic-border); + border-radius: 16px; + max-width: 840px; + margin: 0 auto 1.5em auto; + padding: 18px 28px 18px 28px; + box-shadow: 0 2px 12px #7f5cff22; + text-align: left; + } + .steps-box ul { + margin: 0; + padding-left: 1.2em; + } + .steps-box li { + margin-bottom: 0.5em; + font-size: 1.05em; + } + + .animated-button { + animation: pulse 2s ease-out 1; + font-size: 18px; + padding: 12px 24px; + } + + @keyframes pulse { + 0% { transform: scale(1); box-shadow: 0 0 16px var(--futuristic-accent2); } + 50% { transform: scale(1.05); box-shadow: 0 0 32px var(--futuristic-accent); } + 100% { transform: scale(1); box-shadow: 0 0 16px var(--futuristic-accent2); } + } + + #advanced_options .gr-accordion-content { + font-size: 0.85em; + line-height: 1.3; + padding-left: 1rem; + } + + #chat_history, + #chat_input, + #chat_input textarea { + box-sizing: border-box; + } + + button.generate-btn { + background: linear-gradient(90deg, var(--futuristic-accent2), var(--futuristic-accent)); + color: #181c27 !important; + border: none !important; + font-weight: 600; + box-shadow: 0 4px 24px var(--futuristic-accent2), 0 2px 8px var(--futuristic-accent); + transition: transform 0.2s ease, box-shadow 0.2s ease; + } + + button.generate-btn:hover { + transform: translateY(-2px) scale(1.03); + box-shadow: 0 8px 32px var(--futuristic-accent), 0 4px 16px var(--futuristic-accent2); + } + + button.generate-btn:active { + transform: translateY(0) scale(0.98); + box-shadow: 0 3px 8px var(--futuristic-accent2); + } + + .agent-controls { + display: inline-flex !important; + flex-wrap: nowrap !important; + align-items: center; + gap: 0.5rem; + } + + .agent-controls .action-btn, + .agent-controls .agent-dropdown { + width: auto !important; + flex: none !important; + } + + .agent-controls .agent-dropdown-column, + .agent-controls .agent-button-group { + background: var(--futuristic-panel) !important; + box-shadow: none !important; + padding: 0 !important; + } + + .agent-controls > div, + .agent-controls > div > div, + .agent-controls > div > div > div { + background: transparent !important; + box-shadow: none !important; + padding: 0 !important; + margin: 0 !important; + } + + /* Chatbot message bubbles */ + .gr-chat-message.user { + background: linear-gradient(90deg, #23263a 60%, #181c27 100%); + color: var(--futuristic-accent); + border-radius: 12px 12px 4px 12px; + border: 1px solid var(--futuristic-accent2); + } + .gr-chat-message.assistant { + background: linear-gradient(90deg, #181c27 60%, #23263a 100%); + color: var(--futuristic-text); + border-radius: 12px 12px 12px 4px; + border: 1px solid var(--futuristic-accent); + } + + /* Muted text */ + .gr-markdown, .gradio-markdown, .gradio-container .gr-markdown { + color: var(--futuristic-muted) !important; + } + + /* Patient Cards Section */ + .patient-cards-section { + margin-top: 2rem !important; + padding-top: 1.5rem !important; + border-top: 2px solid #ffffff !important; + padding-bottom: 1rem !important; + background: var(--futuristic-panel) !important; + border-radius: 16px !important; + border: 1px solid var(--futuristic-border) !important; + box-shadow: 0 2px 12px rgba(127, 92, 255, 0.1) !important; + } + + .patient-cards-header { + text-align: center !important; + margin-bottom: 1.5rem !important; + color: var(--futuristic-text) !important; + font-size: 1.1rem !important; + font-weight: 600 !important; + } + + .patient-cards-row { + margin-bottom: 1rem !important; + gap: 1rem !important; + } + + .patient-cards-row:last-child { + margin-bottom: 0 !important; + } + + .patient-card { + background: linear-gradient(135deg, var(--futuristic-card) 60%, #ffffff 100%) !important; + border: 2px solid var(--futuristic-border) !important; + border-radius: 12px !important; + padding: 1rem !important; + cursor: pointer !important; + transition: all 0.3s ease !important; + box-shadow: 0 2px 8px rgba(127, 92, 255, 0.1) !important; + display: flex !important; + flex-direction: column !important; + justify-content: space-between !important; + min-height: 120px !important; + margin: 0.5rem !important; + } + + .patient-card:hover { + transform: translateY(-3px) !important; + box-shadow: 0 6px 20px rgba(127, 92, 255, 0.2) !important; + border-color: var(--futuristic-accent2) !important; + background: linear-gradient(135deg, #f0f4ff 60%, #ffffff 100%) !important; + } + + .patient-card.selected { + border-color: var(--futuristic-accent) !important; + box-shadow: 0 6px 24px rgba(0, 255, 231, 0.3) !important; + background: linear-gradient(135deg, var(--futuristic-accent) 5%, var(--futuristic-card) 60%, #ffffff 100%) !important; + } + + .patient-card-btn { + background: transparent !important; + border: none !important; + padding: 0 !important; + width: 100% !important; + height: 100% !important; + text-align: left !important; + font-size: 0.85rem !important; + line-height: 1.3 !important; + color: var(--futuristic-text) !important; + cursor: pointer !important; + white-space: pre-line !important; + } + + .patient-card-btn:hover { + background: transparent !important; + } + + /* Hide chat panel elements globally by default */ + .chat-only-btn { + display: none !important; + } + .chat-panel-buttons { + display: none !important; + } + .patient-cards-group { + display: none !important; + } + .patient-cards-section { + display: none !important; + } + .patient-cards-row { + display: none !important; + } + .patient-cards-grid { + display: none !important; + } + .chat-only-content { + display: none !important; + } + .patient-card-btn { + display: none !important; + } + .patient-cards-container { + display: none !important; + } + .chat-only-section { + display: none !important; + } + + /* Hide chat panel elements in builder panel (redundant but explicit) */ + #agent_form .chat-only-btn { + display: none !important; + } + #agent_form .chat-panel-buttons { + display: none !important; + } + #agent_form .patient-cards-group { + display: none !important; + } + #agent_form .patient-cards-section { + display: none !important; + } + #agent_form .patient-cards-row { + display: none !important; + } + #agent_form .patient-cards-grid { + display: none !important; + } + #agent_form .chat-only-content { + display: none !important; + } + #agent_form .patient-card-btn { + display: none !important; + } + #agent_form .patient-cards-container { + display: none !important; + } + #agent_form .chat-only-section { + display: none !important; + } + + /* Show chat panel elements only in chat panel */ + #agent_chat .chat-only-btn { + display: inline-block !important; + } + #agent_chat .chat-panel-buttons { + display: flex !important; + } + #agent_chat .patient-cards-group { + display: block !important; + } + #agent_chat .patient-cards-section { + display: block !important; + } + #agent_chat .patient-cards-row { + display: flex !important; + } + #agent_chat .patient-cards-grid { + display: grid !important; + } + #agent_chat .chat-only-content { + display: block !important; + } + #agent_chat .patient-card-btn { + display: block !important; + } + #agent_chat .patient-cards-container { + display: flex !important; + } + #agent_chat .chat-only-section { + display: block !important; + } + """ + + # Helper functions for chat control state management + def show_initial_instruction_state(): + """Return initial state with instruction message and disabled controls""" + instruction_chat = [{"role": "assistant", "content": "πŸ“‹ **Agent generated successfully!**\n\nTo start testing your agent:\n1. Select your agent from the dropdown menu above\n2. Click 'πŸ’¬ Chat with Selected Agent'\n3. Then you can type your questions in the chat box\n\n*Please select an agent from the dropdown to begin testing.*"}] + return ( + instruction_chat, # builder_chatbot + gr.update(value="", interactive=False, placeholder="Please select an agent to start chatting..."), # chat_input + gr.update(interactive=False), # builder_send_button + gr.update(interactive=False), # reset_button + "", # invocation_log + [] # active_children + ) + + def enable_chat_controls_with_agent(agent_name): + """Enable chat controls and show proper agent greeting when agent is selected""" + agent_json = agents_config.get(agent_name, "") + if agent_json: + # Get agent data for greeting + agent_data = json.loads(agent_json) + agent_display_name = agent_data.get("agent_name", agent_name) + + # Create greeting message + greeting_chat = [{"role": "assistant", "content": f"πŸ‘‹ Hello! I'm {agent_display_name}. How can I assist you today?"}] + + return ( + greeting_chat, # builder_chatbot with agent greeting + gr.update(value="", interactive=True, placeholder="Type your question here…"), # chat_input enabled + gr.update(interactive=True), # builder_send_button enabled + gr.update(interactive=True), # reset_button enabled + "", # clear invocation_log + [], # clear active_children + agent_json # agent_output + ) + else: + # No valid agent selected, return disabled state + return show_initial_instruction_state() + ("",) # Add empty agent_output + + # Update instruction when agent is selected from dropdown + def update_instruction_on_dropdown_change(agent_name): + """Update instruction message when agent is selected from dropdown""" + if agent_name: + instruction_msg = f"πŸ“‹ **Agent '{agent_name}' selected!**\n\nTo start testing this agent:\nβ€’ Click 'πŸ’¬ Chat with Selected Agent' button above\nβ€’ Then you can type your questions in the chat box\n\n*Click the chat button to begin testing.*" + else: + instruction_msg = "πŸ“‹ **Welcome to the Agent Builder!**\n\nTo start testing your agents:\n1. Generate an agent using the form on the left\n2. Select your agent from the dropdown menu above\n3. Click 'πŸ’¬ Chat with Selected Agent'\n4. Then you can type your questions in the chat box\n\n*Please create and select an agent to begin testing.*" + + return [{"role": "assistant", "content": instruction_msg}] + + # 1) HEADER & LANDING CARD + with gr.Group(elem_id="landing_card", elem_classes="landing-card", visible=True) as landing_panel: + gr.Markdown("
🦠 Infectious Diseases Agent Builder
", elem_id=None, elem_classes=None) + gr.Markdown("
Build your own ID-focused chat agent in 5 easy steps β€” no coding required.
", elem_id=None, elem_classes=None) + gr.HTML(""" +
+ +
+ """) + start_button = gr.Button( + "πŸš€ Get Started", + elem_id="start_button", + elem_classes="animated-button" + ) + gr.HTML("
") + # Only the simple GPT-3.5 Chatbot (no active agents or builder UI) + gr.Markdown("### πŸ’¬ Try A Simple Chatbot Before You Build Your ID Agents") + simple_chatbot = gr.Chatbot(label="GPT-3.5 Chat", type="messages") + simple_input = gr.Textbox( + placeholder="Ask anything…", + show_label=False, + lines=2, + max_lines=4, + ) + simple_send = gr.Button("Send") + simple_reset = gr.Button("Reset") + + # 2) AGENT FORM (HIDDEN UNTIL CLICK) + prefill_flag = gr.State(False) + with gr.Group(elem_id="agent_form", visible=False) as agent_form: + # Move Back to Home button to the very top + back_button = gr.Button("πŸ”™ Back to Home", elem_id="back_button") + # Steps box at the top of the builder panel for user guidance + gr.HTML(""" +
+ +
+ """) + gr.Markdown("### πŸŽ›οΈ Infectious Diseases Agent Builder") + with gr.Row(): + # Left panel + with gr.Column(scale=3, elem_classes="left-panel"): + prefilled = gr.Dropdown(choices=["None"] + list(prefilled_agents.keys()), label="Start with a prefilled agent?") + with gr.Accordion("πŸ› οΈ Basic Settings", open=True): + agent_type = gr.Radio( + choices=[ + "πŸ›‘οΈ Antimicrobial Stewardship", + "🦠 Infection Prevention and Control", + "πŸ”¬ Research Assistant", + "πŸ₯ Clinical Assistant", + "πŸ“š Education Assistant", + "🎼 Orchestrator", + ], + label="Select Agent Type", + elem_id="select_agent_type_radio" + ) + agent_name = gr.Textbox(label="Agent Name", placeholder="e.g., SmartSteward", max_lines=1) + agent_mission = gr.Textbox(label="Agent Mission", placeholder="Describe what your agent should do…", lines=4) + skills = gr.CheckboxGroup(choices=[], label="Select Skills") + + + with gr.Accordion("βš™οΈ Advanced Options", open=False): + link1, link2, link3, link4 = [ + gr.Textbox(label=f"Trusted Source Link {i} (optional)") + for i in range(1,5) + ] + web_access_toggle = gr.Checkbox(label="Allow Internet Search 🌐", value=True, interactive=True) + allow_fallback_toggle = gr.Checkbox(label="Allow Fallback to LLM General Knowledge πŸ€–", value=True) + challenger_toggle = gr.Checkbox(label="Enable Adversarial AI Validation (Challenger)", value=False, info="If enabled, agent replies will be critiqued by an adversarial LLM before being shown to the user.") + + # --- Auto-toggle logic for web_access_toggle --- + def update_web_access_toggle(l1, l2, l3, l4): + links = [l1, l2, l3, l4] + any_links = any(l.strip() for l in links if l) + if any_links: + # If any trusted link is present, force checked and disable + return gr.update(value=True, interactive=False) + else: + # If all empty, allow user to toggle + return gr.update(interactive=True) + + # Wire up the logic: any change to link1-4 updates web_access_toggle + for link in [link1, link2, link3, link4]: + link.change( + fn=update_web_access_toggle, + inputs=[link1, link2, link3, link4], + outputs=[web_access_toggle] + ) + + generate_button = gr.Button("✨ Generate Agent Config", elem_classes="generate-btn") + + with gr.Accordion("πŸ“¦ Generated Agent Config", open=False): + agent_loader = gr.Markdown("") + agent_output = gr.Code(label="Configuration (JSON)", language="json") + download_button = gr.DownloadButton(label="Download Config") + + # Move Upload Knowledge Files section below agent config + with gr.Accordion("πŸ“š Upload Knowledge Files (Global)", open=False): + uploaded_files = gr.File(label="Upload Knowledge Files", file_count="multiple") + upload_alert = gr.Markdown("", visible=False) + + # Right panel + with gr.Column(scale=9, elem_classes="right-panel"): + builder_active_agents = gr.Markdown("### 🧠 Active Agents\n_(None yet)_") + # dropdown + action buttons inline + with gr.Row(elem_classes="agent-controls"): + with gr.Column(scale=3, elem_classes="agent-dropdown-column"): + agent_remove_dropdown = gr.Dropdown( + label="Select an agent", + choices=[], + elem_classes="agent-dropdown" + ) + with gr.Column(scale=1, elem_classes="agent-button-group"): + chat_agent_button = gr.Button( + "πŸ’¬ Chat with Selected Agent", + elem_classes="action-btn" + ) + edit_agent_button = gr.Button( + "πŸ›  Edit Selected Agent", + elem_classes="action-btn" + ) + remove_agent_button = gr.Button( + "❌ Remove Selected Agent", + elem_classes="action-btn" + ) + show_debug = gr.Checkbox(label="πŸ”Ž Show tool reasoning", value=False) + # Only one chatbot in builder panel + builder_chatbot = gr.Chatbot( + label="πŸ’¬ Live Conversation with Your ID Agent", + type="messages", + value=[{"role": "assistant", "content": "πŸ“‹ **Welcome to the Agent Builder!**\n\nTo start testing your agents:\n1. Generate an agent using the form on the left\n2. Select your agent from the dropdown menu above\n3. Click 'πŸ’¬ Chat with Selected Agent'\n4. Then you can type your questions in the chat box\n\n*Please create and select an agent to begin testing.*"}] + ) + chat_input = gr.Textbox( + placeholder="Please select an agent to start chatting...", + show_label=False, + lines=3, + max_lines=5, + interactive=False + ) + active_children = gr.State([]) # will hold a list of JSON-configs + # --- Builder panel clinical variable fields (hidden, but needed for wiring) --- + builder_deescalation_culture = gr.Textbox(visible=False) + builder_deescalation_meds = gr.Textbox(visible=False) + builder_stewardship_site = gr.Textbox(visible=False) + builder_stewardship_biofilm = gr.Textbox(visible=False) + builder_stewardship_response = gr.Textbox(visible=False) + builder_stewardship_crcl = gr.Textbox(visible=False) + builder_stewardship_severity = gr.Textbox(visible=False) + builder_stewardship_allergies = gr.Textbox(visible=False) + builder_empiric_age = gr.Textbox(visible=False) + builder_empiric_allergies = gr.Textbox(visible=False) + builder_empiric_labs = gr.Textbox(visible=False) + builder_empiric_culture = gr.Textbox(visible=False) + builder_empiric_meds = gr.Textbox(visible=False) + builder_empiric_site = gr.Textbox(visible=False) + builder_empiric_biofilm = gr.Textbox(visible=False) + builder_empiric_response = gr.Textbox(visible=False) + builder_empiric_crcl = gr.Textbox(visible=False) + builder_empiric_severity = gr.Textbox(visible=False) + # Add the builder send button under the chatbox + builder_send_button = gr.Button("Send", elem_id="builder_send_button", interactive=False) + reset_button = gr.Button("πŸ”„ Reset Chat", interactive=False) + invocation_log = gr.Markdown( + value="", + label="πŸ” Tool Invocation Log", + visible=True + ) + + gr.Markdown("---\nBuilt with ❀️ for ID Week 2025 β€” Empowering Infectious Diseases Innovation") + # Move the Chat With the Agents You Deployed button to the bottom, below the disclaimer + to_chat_button = gr.Button("πŸ—¨οΈ Chat With the Agents You Deployed", elem_id="to_chat_button") + # --- Builder panel send button logic --- + def builderpanel_handle_with_dynamic_vars( + agent_name, user_text, histories, + deescalation_culture, deescalation_meds, + stewardship_site, stewardship_biofilm, stewardship_response, stewardship_crcl, stewardship_severity, stewardship_allergies, + empiric_age, empiric_allergies, empiric_labs, empiric_culture, empiric_meds, empiric_site, empiric_biofilm, empiric_response, empiric_crcl, empiric_severity + ): + agent_json = agents_config.get(agent_name) + if agent_json: + agent_data = json.loads(agent_json) + skills = agent_data.get("skills", []) + history = histories.get(agent_name, []) + # --- Trusted links wiring --- + trusted_links = [] + for k in ["trusted_links", "trusted_links_1", "trusted_links_2", "trusted_links_3", "trusted_links_4"]: + # Support both list and individual keys + if isinstance(agent_data.get(k), list): + trusted_links.extend([l for l in agent_data[k] if l]) + elif isinstance(agent_data.get(k), str) and agent_data[k]: + trusted_links.append(agent_data[k]) + # Also check for link1-link4 keys (legacy) + for k in ["link1", "link2", "link3", "link4"]: + if agent_data.get(k): + trusted_links.append(agent_data[k]) + trusted_links = [l for l in trusted_links if l] + # Do not prepend trusted links to every user message; just keep them available for tools + # if trusted_links: + # links_str = ", ".join(trusted_links) + # user_text = f"Trusted sources for this agent: {links_str}\n\n" + user_text + # --- End trusted links wiring --- + # Deescalation tool + if "recommend_deescalation" in skills: + var_names = ["culture", "meds", "site_of_infection", "risk_of_biofilm", "current_response", "creatinine_clearance", "severity_of_infection", "known_allergies"] + user_vars = { + "culture": deescalation_culture, + "meds": deescalation_meds, + "site_of_infection": stewardship_site, + "risk_of_biofilm": stewardship_biofilm, + "current_response": stewardship_response, + "creatinine_clearance": stewardship_crcl, + "severity_of_infection": stewardship_severity, + "known_allergies": stewardship_allergies + } + extracted = extract_clinical_variables_from_history(history, var_names) + for k in var_names: + if not user_vars[k]: + user_vars[k] = extracted.get(k) or "" + # Only prepend if at least one field is non-empty + if any(user_vars[k] for k in var_names): + user_text = f"[DEESCALATION_TOOL_INPUT] {json.dumps(user_vars)}\n" + user_text + elif "alert_prolonged_antibiotic_use" in skills: + var_names = ["site_of_infection", "risk_of_biofilm", "current_response", "creatinine_clearance", "severity_of_infection", "known_allergies"] + user_vars = { + "site_of_infection": stewardship_site, + "risk_of_biofilm": stewardship_biofilm, + "current_response": stewardship_response, + "creatinine_clearance": stewardship_crcl, + "severity_of_infection": stewardship_severity, + "known_allergies": stewardship_allergies + } + extracted = extract_clinical_variables_from_history(history, var_names) + for k in var_names: + if not user_vars[k]: + user_vars[k] = extracted.get(k) or "" + if any(user_vars[k] for k in var_names): + user_text = f"[ALERT_PROLONGED_ABX_INPUT] {json.dumps(user_vars)}\n" + user_text + elif "recommend_empiric_therapy" in skills: + # Remove 'known_allergies' as a separate required field (it's covered by 'allergies') + var_names = [ + "age", "allergies", "labs", "culture", "meds", "site_of_infection", + "risk_of_biofilm", "current_response", "creatinine_clearance", "severity_of_infection" + ] + user_vars = { + "age": empiric_age, + "allergies": empiric_allergies, + "labs": empiric_labs, + "culture": empiric_culture, + "meds": empiric_meds, + "site_of_infection": empiric_site, + "risk_of_biofilm": empiric_biofilm, + "current_response": empiric_response, + "creatinine_clearance": empiric_crcl, + "severity_of_infection": empiric_severity + } + extracted = extract_clinical_variables_from_history(history, var_names) + for k in var_names: + if not user_vars[k]: + user_vars[k] = extracted.get(k) or "" + # If any required field is missing, prompt = ... + missing = [k.replace('_', ' ').capitalize() for k in var_names if not user_vars[k].strip()] + if missing: + prompt = f"Please provide the following required information for empiric therapy: {', '.join(missing)}." + # Show this as an assistant message and do not call the tool + history.append({"role": "assistant", "content": prompt}) + return history, histories, "" + # All required fields present, prepend tool input + user_text = f"[EMPIRIC_THERAPY_INPUT] {json.dumps(user_vars)}\n" + user_text + # Use the same chat handling logic, but ensure the builder_chatbot is updated and history is preserved + # Call chatpanel_handle, but get extra challenger info from simulate_agent_response_stream + import asyncio + from core.agents.chat_orchestrator import simulate_agent_response_stream + agent_json_val = agents_config.get(agent_name) + history_val = histories.get(agent_name, []) + result = None + async def run_stream(): + gen = simulate_agent_response_stream( + agent_json=agent_json_val, + history=history_val, + user_input=user_text, + debug_flag=False, + active_children=[] + ) + last_result = None + async for result in gen: + last_result = result + return last_result + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + result = loop.run_until_complete(run_stream()) + if result is not None and len(result) == 5: + final_history, _, invocation_log, _, challenger_info = result + else: + final_history, updated_histories, cleared_input, invocation_log = chatpanel_handle(agent_name, user_text, histories) + challenger_info = None + return final_history, updated_histories, cleared_input, invocation_log, "" + except Exception: + # fallback to old behavior if error + final_history, updated_histories, cleared_input, invocation_log = chatpanel_handle(agent_name, user_text, histories) + challenger_info = None + return final_history, updated_histories, cleared_input, invocation_log, "" + # Update histories + updated_histories = histories.copy() + updated_histories[agent_name] = final_history + # Prepare challenger markdown (debug log for builder panel) + challenger_md = "" + if isinstance(challenger_info, dict): + orig = challenger_info.get("original_reply", "") + crit = challenger_info.get("challenger_critique", "") + final = challenger_info.get("final_reply", "") + # Ensure critique is never None or empty in the UI + if not crit or str(crit).strip().lower() == "none": + crit = "OK" + # Only show the final (challenger-approved) answer in the chatbox + if final and final_history and isinstance(final_history, list): + final_history[-1]["content"] = final + # In the builder panel log, show the critique, but in the chatbox, only show the final answer + # If the challenger changed the answer, only show the suggested revision as the final answer, not the critique text + if final != orig and crit != "OK": + challenger_md = f"**Original Agent Answer:**\n\n{orig}\n\n**Challenger Critique:**\n\n{crit}\n\n**Final Answer Shown to User:**\n\n{final}" + else: + challenger_md = f"**Original Agent Answer:**\n\n{orig}\n\n**Final Answer Shown to User:**\n\n{final}" + return final_history, updated_histories, "", invocation_log, challenger_md + + # Add a Markdown for challenger debug info under the invocation log, only in the builder panel + challenger_debug_md = gr.Markdown("", visible=True) + # Place the Markdown visually under the invocation log in the builder panel only + builder_send_button.click( + fn=builderpanel_handle_with_dynamic_vars, + inputs=[ + agent_remove_dropdown, chat_input, builder_chat_histories, + builder_deescalation_culture, builder_deescalation_meds, + builder_stewardship_site, builder_stewardship_biofilm, builder_stewardship_response, builder_stewardship_crcl, builder_stewardship_severity, builder_stewardship_allergies, + builder_empiric_age, builder_empiric_allergies, builder_empiric_labs, builder_empiric_culture, builder_empiric_meds, builder_empiric_site, builder_empiric_biofilm, builder_empiric_response, builder_empiric_crcl, builder_empiric_severity + ], + outputs=[builder_chatbot, builder_chat_histories, chat_input, invocation_log, challenger_debug_md] + ) + + # Hide the challenger debug markdown in the deployed chat and simple chat panels + # (No code needed, as those panels do not use this Markdown block) + + + # (3) Agent-Chat panel, hidden by default + + with gr.Group(elem_id="agent_chat", visible=False) as chat_panel: + gr.Markdown("### πŸ—¨οΈ Chat with Your ID Agents") + chat_active_agents = gr.Markdown("### 🧠 Active Agents\n_(None yet)_") + + # Dropdown to pick which agent to chat with + agent_picker = gr.Dropdown( + label="Select Agent", + choices=list(agents_config.keys()), + interactive=True + ) + + # The ChatGPT-style history box + chat_view = gr.Chatbot(label="Conversation", type="messages") + + # --- Stewardship Tool Clinical Variables Section (Deescalation & Alert Prolonged Abx Use) --- + with gr.Accordion("Stewardship Clinical Variables", open=False, visible=False) as stewardship_vars_section: + deescalation_culture = gr.Textbox(label="Culture & Sensitivity Results", lines=2, visible=False) + deescalation_meds = gr.Textbox(label="Current Antibiotic Regimen", lines=2, visible=False) + stewardship_site = gr.Textbox(label="Site of Infection", lines=1) + stewardship_biofilm = gr.Textbox(label="Risk or Presence of Biofilm", lines=1) + stewardship_response = gr.Textbox(label="Current Response to Antibiotics", lines=1) + stewardship_crcl = gr.Textbox(label="Creatinine Clearance", lines=1) + stewardship_severity = gr.Textbox(label="Severity of Infection", lines=1) + stewardship_allergies = gr.Textbox(label="Known Drug Allergies", lines=1) + + # --- Empiric Therapy Tool Clinical Variables Section --- + with gr.Accordion("Empiric Therapy Clinical Variables", open=False, visible=False) as empiric_vars_section: + empiric_age = gr.Textbox(label="Age", lines=1) + empiric_allergies = gr.Textbox(label="Allergies", lines=1) + empiric_labs = gr.Textbox(label="Recent Labs", lines=2) + empiric_culture = gr.Textbox(label="Culture & Sensitivity Results", lines=2) + empiric_meds = gr.Textbox(label="Current Antibiotic Regimen", lines=2) + empiric_site = gr.Textbox(label="Site of Infection", lines=1) + empiric_biofilm = gr.Textbox(label="Risk or Presence of Biofilm", lines=1) + empiric_response = gr.Textbox(label="Current Response to Antibiotics", lines=1) + empiric_crcl = gr.Textbox(label="Creatinine Clearance", lines=1) + empiric_severity = gr.Textbox(label="Severity of Infection", lines=1) + empiric_known_allergies = gr.Textbox(label="Known Drug Allergies", lines=1) + + # --- Infection Prevention and Control Clinical Variables Section --- + with gr.Accordion("IPC Clinical Variables", open=False, visible=False) as ipc_vars_section: + ipc_facility_name = gr.Textbox(label="Facility Name", lines=1) + ipc_location = gr.Textbox(label="Location/Unit", lines=1) + ipc_infection_type = gr.Textbox(label="Type of Infection (HAI, SSI, CLABSI, etc.)", lines=1) + ipc_onset_date = gr.Textbox(label="Infection Onset Date", lines=1) + ipc_device_days = gr.Textbox(label="Device Days (Central Line, Ventilator, etc.)", lines=1) + ipc_pathogen = gr.Textbox(label="Pathogen Identified", lines=1) + ipc_resistance_pattern = gr.Textbox(label="Resistance Pattern (MRSA, CRE, etc.)", lines=1) + ipc_isolation_status = gr.Textbox(label="Current Isolation Precautions", lines=1) + ipc_compliance_issues = gr.Textbox(label="Compliance/Breach Issues", lines=2) + + # --- Clinical Assistant Clinical Variables Section --- + with gr.Accordion("Clinical Assessment Variables", open=False, visible=False) as clinical_vars_section: + clinical_chief_complaint = gr.Textbox(label="Chief Complaint", lines=2) + clinical_history_present = gr.Textbox(label="History of Present Illness", lines=3) + clinical_past_medical = gr.Textbox(label="Past Medical History", lines=2) + clinical_medications = gr.Textbox(label="Current Medications", lines=2) + clinical_allergies = gr.Textbox(label="Allergies", lines=1) + clinical_social_history = gr.Textbox(label="Social History (Travel, Exposures)", lines=2) + clinical_vital_signs = gr.Textbox(label="Vital Signs", lines=1) + clinical_physical_exam = gr.Textbox(label="Physical Examination Findings", lines=3) + clinical_lab_results = gr.Textbox(label="Laboratory Results", lines=2) + clinical_imaging = gr.Textbox(label="Imaging Results", lines=2) + + # --- Orchestrator Coordination Variables Section --- + with gr.Accordion("Multi-Agent Coordination Variables", open=False, visible=False) as orchestrator_vars_section: + # Stewardship Variables (8 fields) + orchestrator_culture = gr.Textbox(label="Culture Results", lines=1) + orchestrator_meds = gr.Textbox(label="Current Medications", lines=1) + orchestrator_site = gr.Textbox(label="Site of Infection", lines=1) + orchestrator_biofilm = gr.Textbox(label="Risk of Biofilm", lines=1) + orchestrator_response = gr.Textbox(label="Current Response", lines=1) + orchestrator_crcl = gr.Textbox(label="Creatinine Clearance", lines=1) + orchestrator_severity = gr.Textbox(label="Severity of Infection", lines=1) + orchestrator_allergies = gr.Textbox(label="Known Allergies", lines=1) + + # IPC Variables (9 fields) + orchestrator_facility_name = gr.Textbox(label="Facility Name", lines=1) + orchestrator_location = gr.Textbox(label="Location/Unit", lines=1) + orchestrator_infection_type = gr.Textbox(label="Type of Infection (HAI, SSI, CLABSI, etc.)", lines=1) + orchestrator_onset_date = gr.Textbox(label="Infection Onset Date", lines=1) + orchestrator_device_days = gr.Textbox(label="Device Days (Central Line, Ventilator, etc.)", lines=1) + orchestrator_pathogen = gr.Textbox(label="Pathogen Identified", lines=1) + orchestrator_resistance_pattern = gr.Textbox(label="Resistance Pattern (MRSA, CRE, etc.)", lines=1) + orchestrator_isolation_status = gr.Textbox(label="Current Isolation Precautions", lines=1) + orchestrator_compliance_issues = gr.Textbox(label="Compliance/Breach Issues", lines=2) + + # Clinical Assistant Variables (10 fields) + orchestrator_chief_complaint = gr.Textbox(label="Chief Complaint", lines=2) + orchestrator_history_present = gr.Textbox(label="History of Present Illness", lines=3) + orchestrator_past_medical = gr.Textbox(label="Past Medical History", lines=2) + orchestrator_medications = gr.Textbox(label="Current Medications", lines=2) + orchestrator_patient_allergies = gr.Textbox(label="Patient Allergies", lines=1) + orchestrator_social_history = gr.Textbox(label="Social History (Travel, Exposures)", lines=2) + orchestrator_vital_signs = gr.Textbox(label="Vital Signs", lines=1) + orchestrator_physical_exam = gr.Textbox(label="Physical Examination Findings", lines=3) + orchestrator_lab_results = gr.Textbox(label="Laboratory Results", lines=2) + orchestrator_imaging = gr.Textbox(label="Imaging Results", lines=2) + + # Add chat input box to chat panel + chat_panel_input = gr.Textbox( + placeholder="Type your question here…", + show_label=False, lines=3, - interactive=False + max_lines=5 ) + + # Only show chat_send, chat_reset, and chat_back in the chat panel + with gr.Row(elem_classes="chat-panel-buttons"): + chat_send = gr.Button("Send", elem_classes="chat-only-btn") + chat_reset = gr.Button("πŸ”„ Reset Chat", elem_classes="chat-only-btn") + chat_back = gr.Button("πŸ”™ Back to Builder", elem_id="chat_back", elem_classes="chat-only-btn") + + # Patient cards section - positioned at bottom of chat panel + with gr.Group(elem_classes="patient-cards-section"): + gr.Markdown("### 🎯 Select Context-Aware Chat Scenario (Optional)", elem_classes="patient-cards-header") + + # First row - 3 cards + with gr.Row(elem_classes="patient-cards-row"): + # Patient Card 1: Stewardship Case + with gr.Column(elem_classes="patient-card", scale=1): + patient_card_1 = gr.Button( + "πŸ“‹ Patient A: ICU Sepsis\n\nπŸ›‘οΈ SmartSteward Case\n\n68F, ICU day 5, on vancomycin + piperacillin-tazobactam for sepsis. Blood cultures positive for MSSA. Patient improving, normal renal function.", + elem_classes="patient-card-btn" + ) + + # Patient Card 2: IPC Case + with gr.Column(elem_classes="patient-card", scale=1): + patient_card_2 = gr.Button( + "🦠 Patient B: CLABSI Investigation\n\n🦠 InfectoGuard Case\n\n45M, ICU patient with central line x6 days. Developed fever, positive blood cultures for MRSA. Potential healthcare-associated infection.", + elem_classes="patient-card-btn" + ) + + # Patient Card 3: Research Case + with gr.Column(elem_classes="patient-card", scale=1): + patient_card_3 = gr.Button( + "πŸ”¬ Research Query\n\nπŸ”¬ ResearchRanger Case\n\nLiterature search needed for novel carbapenem-resistant Enterobacterales treatment options and resistance mechanisms.", + elem_classes="patient-card-btn" + ) + + # Second row - 3 cards + with gr.Row(elem_classes="patient-cards-row"): + # Patient Card 4: Clinical Case + with gr.Column(elem_classes="patient-card", scale=1): + patient_card_4 = gr.Button( + "πŸ₯ Patient C: Complex Diagnosis\n\nπŸ₯ ClinicoPilot Case\n\n32M with fever, rash, and joint pain after recent travel to Southeast Asia. Multiple differential diagnoses to consider.", + elem_classes="patient-card-btn" + ) + + # Patient Card 5: Education Case + with gr.Column(elem_classes="patient-card", scale=1): + patient_card_5 = gr.Button( + "πŸ“š Education Request\n\nπŸ“š EduMedCoach Case\n\nMedical student requesting board exam questions and educational materials on antimicrobial resistance mechanisms.", + elem_classes="patient-card-btn" + ) + + # Patient Card 6: Orchestrator Case + with gr.Column(elem_classes="patient-card", scale=1): + patient_card_6 = gr.Button( + "🎼 Complex Multi-Agent Case\n\n🎼 ID Maestro Case\n\n75M with multiple ID issues: MDRO pneumonia, C. diff colitis, and suspected endocarditis requiring comprehensive analysis.", + elem_classes="patient-card-btn" + ) + + # Store patient data in hidden state + patient_data = gr.State({}) + + # Define patient case data + patient_cases = { + "patient_1": { + "name": "Patient A", + "age": "68", + "summary": "68-year-old female in ICU, day 5 of admission for sepsis", + "current_meds": "vancomycin 1g q12h, piperacillin-tazobactam 4.5g q6h", + "culture_results": "Blood cultures (day 3): methicillin-sensitive Staphylococcus aureus (MSSA), sensitive to cefazolin, nafcillin, clindamycin", + "site_of_infection": "bloodstream", + "biofilm_risk": "central venous catheter present", + "response": "clinically improving, fever resolved, WBC trending down", + "creatinine_clearance": "75 mL/min (normal)", + "severity": "severe sepsis, now stable", + "allergies": "NKDA", + "agent_focus": "πŸ›‘οΈ Antimicrobial Stewardship", + "context": "This patient is a perfect candidate for antibiotic deescalation given the MSSA blood culture results and clinical improvement. Current broad-spectrum therapy can likely be narrowed." + }, + "patient_2": { + "name": "Patient B", + "age": "45", + "summary": "45-year-old male, ICU patient with central line-associated bloodstream infection", + "diagnosis": "Central line-associated bloodstream infection (CLABSI)", + "central_line_days": "6 days", + "culture_results": "Blood cultures positive for methicillin-resistant Staphylococcus aureus (MRSA)", + "symptoms": "fever (38.8Β°C), chills, no other obvious source", + "location": "Methodist Hospital, Dallas, Texas", + "agent_focus": "🦠 Infection Prevention and Control", + "context": "This case requires evaluation for NHSN CLABSI criteria, appropriate isolation precautions for MRSA, and reporting requirements for healthcare-associated infections in Texas." + }, + "patient_3": { + "name": "Research Query", + "topic": "Carbapenem-resistant Enterobacterales (CRE) treatment", + "research_focus": "Novel treatment options for CRE infections", + "specific_interests": "resistance mechanisms, combination therapies, newer antibiotics", + "urgency": "clinical decision support needed", + "agent_focus": "πŸ”¬ Research Assistant", + "context": "Literature search and evidence synthesis needed for treatment of carbapenem-resistant Enterobacterales infections, including mechanism-based approaches and newest therapeutic options." + }, + "patient_4": { + "name": "Patient C", + "age": "32", + "summary": "32-year-old male with fever, rash, and arthralgia after travel", + "travel_history": "Recent travel to Southeast Asia (Thailand, Vietnam) 3 weeks ago", + "symptoms": "fever (39.1Β°C), maculopapular rash on trunk and extremities, polyarthralgia", + "duration": "symptoms for 5 days", + "differential": "considering dengue fever, chikungunya, Zika virus, typhus, malaria", + "agent_focus": "πŸ₯ Clinical Assistant", + "context": "Complex infectious disease case requiring systematic evaluation of travel-related illnesses and patient education about diagnostic workup and treatment options." + }, + "patient_5": { + "name": "Education Request", + "level": "Medical student, 3rd year", + "topic": "Antimicrobial resistance mechanisms", + "request": "Board exam questions and educational materials", + "focus_areas": "beta-lactamase types, carbapenemases, ESBL, AmpC", + "format_needed": "multiple choice questions, flashcards, presentation slides", + "agent_focus": "πŸ“š Education Assistant", + "context": "Educational content creation for antimicrobial resistance mechanisms, suitable for medical student board exam preparation with varying difficulty levels." + }, + "patient_6": { + "name": "Patient D", + "age": "75", + "summary": "75-year-old male with multiple infectious complications", + "problem_1": "Ventilator-associated pneumonia with XDR Pseudomonas aeruginosa", + "problem_2": "Clostridioides difficile colitis (severe, recurrent)", + "problem_3": "Suspected infective endocarditis (blood cultures pending)", + "comorbidities": "diabetes, chronic kidney disease (CrCl 30 mL/min), heart failure", + "current_status": "ICU day 12, on multiple antibiotics, clinically complex", + "agent_focus": "🎼 Orchestrator", + "context": "Complex multi-system infectious disease case requiring coordination between stewardship, infection control, and clinical decision-making across multiple agents and specialties." + } + } + + # Patient card click handlers + def load_patient_1(): + case = patient_cases["patient_1"] + context_msg = f"""**Patient Case Loaded: {case['name']}** + +**Clinical Summary:** {case['summary']} +- **Age:** {case['age']} years old +- **Current Antibiotics:** {case['current_meds']} +- **Culture Results:** {case['culture_results']} +- **Site of Infection:** {case['site_of_infection']} +- **Current Response:** {case['response']} +- **Renal Function:** {case['creatinine_clearance']} +- **Allergies:** {case['allergies']} + +**Agent Focus:** {case['agent_focus']} + +*How can I help with this stewardship case?*""" + + # Auto-populate stewardship clinical variables + return ( + [{"role": "assistant", "content": context_msg}], + case, + case.get('culture_results', ''), # deescalation_culture + case.get('current_meds', ''), # deescalation_meds + case.get('site_of_infection', ''), # stewardship_site + case.get('biofilm_risk', ''), # stewardship_biofilm + case.get('response', ''), # stewardship_response + case.get('creatinine_clearance', ''), # stewardship_crcl + case.get('severity', ''), # stewardship_severity + case.get('allergies', ''), # stewardship_allergies + case.get('age', ''), # empiric_age + case.get('allergies', ''), # empiric_allergies + '', # empiric_labs (not in patient data) + case.get('culture_results', ''), # empiric_culture + case.get('current_meds', ''), # empiric_meds + case.get('site_of_infection', ''), # empiric_site + case.get('biofilm_risk', ''), # empiric_biofilm + case.get('response', ''), # empiric_response + case.get('creatinine_clearance', ''), # empiric_crcl + case.get('severity', ''), # empiric_severity + '', '', '', '', '', '', '', '', '', # ipc fields (9 fields) + '', '', '', '', '', '', '', '', '', '', # clinical fields (10 fields) + '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '' # orchestrator fields (27 fields) + ) + + def load_patient_2(): + case = patient_cases["patient_2"] + context_msg = f"""**Patient Case Loaded: {case['name']}** + +**Clinical Summary:** {case['summary']} +- **Age:** {case['age']} years old +- **Diagnosis:** {case['diagnosis']} +- **Central Line Duration:** {case['central_line_days']} +- **Culture Results:** {case['culture_results']} +- **Symptoms:** {case['symptoms']} +- **Location:** {case['location']} + +**Agent Focus:** {case['agent_focus']} + +*How can I help with this infection prevention case?*""" + # Auto-populate IPC clinical variables for Patient B + return ( + [{"role": "assistant", "content": context_msg}], + case, + '', '', '', '', '', '', '', '', # stewardship fields (8 fields) + '', '', '', '', '', '', '', '', '', '', # empiric fields (10 fields) + '', # ipc_facility_name (blank for user input) + '', # ipc_location (blank for user input) + case.get('diagnosis', ''), # ipc_infection_type + 'admission + ' + case.get('central_line_days', ''), # ipc_onset_date + case.get('central_line_days', ''), # ipc_device_days + 'MRSA' if 'MRSA' in case.get('culture_results', '') else case.get('culture_results', ''), # ipc_pathogen + 'MRSA' if 'MRSA' in case.get('culture_results', '') else 'pending resistance testing', # ipc_resistance_pattern + 'Contact precautions for MRSA', # ipc_isolation_status + 'Review central line maintenance', # ipc_compliance_issues + '', '', '', '', '', '', '', '', '', '', # clinical fields (10 fields) + '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '' # orchestrator fields (27 fields) + ) + + def load_patient_3(): + case = patient_cases["patient_3"] + context_msg = f"""**Research Query Loaded: {case['name']}** + +**Research Focus:** {case['research_focus']} +- **Topic:** {case['topic']} +- **Specific Interests:** {case['specific_interests']} +- **Urgency:** {case['urgency']} + +**Agent Focus:** {case['agent_focus']} + +*How can I help with your research needs?*""" + + # Return empty clinical variables for research case + return ( + [{"role": "assistant", "content": context_msg}], + case, + '', '', '', '', '', '', '', '', # stewardship fields (8 fields) + '', '', '', '', '', '', '', '', '', '', # empiric fields (10 fields) + '', '', '', '', '', '', '', '', '', # ipc fields (9 fields) + '', '', '', '', '', '', '', '', '', '', # clinical fields (10 fields) + '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '' # orchestrator fields (27 fields) + ) + + def load_patient_4(): + case = patient_cases["patient_4"] + context_msg = f"""**Patient Case Loaded: {case['name']}** + +**Clinical Summary:** {case['summary']} +- **Age:** {case['age']} years old +- **Travel History:** {case['travel_history']} +- **Symptoms:** {case['symptoms']} +- **Duration:** {case['duration']} +- **Differential Diagnosis:** {case['differential']} + +**Agent Focus:** {case['agent_focus']} + +*How can I help with this clinical case?*""" + + # Return clinical assistant-focused variables for Patient E case + return ( + [{"role": "assistant", "content": context_msg}], + case, + '', '', '', '', '', '', '', '', # stewardship fields (8 fields) + '', '', '', '', '', '', '', '', '', '', # empiric fields (10 fields) + '', '', '', '', '', '', '', '', '', # ipc fields (9 fields) + case['symptoms'], # chief_complaint + f"Patient with travel history to {case['travel_history']} presenting with {case['symptoms']} for {case['duration']}", # history_present + 'Travel medicine history as noted', # past_medical_history + '', # current_medications + '', # allergies + f"Recent travel to {case['travel_history']}", # social_history + '', # vital_signs + '', # physical_exam + '', # lab_results + '', # imaging_results + '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '' # orchestrator fields (27 fields) + ) + + def load_patient_5(): + case = patient_cases["patient_5"] + context_msg = f"""**Education Request Loaded: {case['name']}** + +**Educational Details:** +- **Student Level:** {case['level']} +- **Topic:** {case['topic']} +- **Request:** {case['request']} +- **Focus Areas:** {case['focus_areas']} +- **Formats Needed:** {case['format_needed']} + +**Agent Focus:** {case['agent_focus']} + +*How can I help with your educational materials?*""" + + # Return empty clinical variables for education case + return ( + [{"role": "assistant", "content": context_msg}], + case, + '', '', '', '', '', '', '', '', # stewardship fields (8 fields) + '', '', '', '', '', '', '', '', '', '', # empiric fields (10 fields) + '', '', '', '', '', '', '', '', '', # ipc fields (9 fields) + '', '', '', '', '', '', '', '', '', '', # clinical fields (10 fields) + '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '' # orchestrator fields (27 fields) + ) + + def load_patient_6(): + case = patient_cases["patient_6"] + context_msg = f"""**Complex Case Loaded: {case['name']}** + +**Clinical Summary:** {case['summary']} +- **Age:** {case['age']} years old +- **Problem 1:** {case['problem_1']} +- **Problem 2:** {case['problem_2']} +- **Problem 3:** {case['problem_3']} +- **Comorbidities:** {case['comorbidities']} +- **Current Status:** {case['current_status']} + +**Agent Focus:** {case['agent_focus']} + +*This complex case requires multi-agent coordination. How can I help orchestrate the care?*""" + + # Auto-populate clinical variables for complex orchestrator case + return ( + [{"role": "assistant", "content": context_msg}], + case, + '', # deescalation_culture (no specific culture data) + 'multiple antibiotics', # deescalation_meds (from current_status) + 'multiple sites (respiratory, GI)', # stewardship_site (inferred from problems) + 'biofilm risk from devices', # stewardship_biofilm (inferred) + 'complex, multiple infections', # stewardship_response + case.get('comorbidities', '').split(',')[1].strip() if 'CrCl' in case.get('comorbidities', '') else '30 mL/min', # stewardship_crcl + 'severe, multiple complications', # stewardship_severity + 'review allergies needed', # stewardship_allergies + case.get('age', ''), # empiric_age + 'review allergies needed', # empiric_allergies + '', # empiric_labs + '', # empiric_culture + 'multiple antibiotics', # empiric_meds + 'multiple sites (respiratory, GI)', # empiric_site + 'biofilm risk from devices', # empiric_biofilm + 'complex, multiple infections', # empiric_response + '30 mL/min', # empiric_crcl + 'severe, multiple complications', # empiric_severity + '', '', '', '', '', '', '', '', '', # ipc fields (9 fields) + 'Multiple acute medical problems', # chief_complaint + f"Complex patient with {case['problem_1']}, {case['problem_2']}, and {case['problem_3']}", # history_present + case['comorbidities'], # past_medical_history + 'Multiple antibiotics, supportive care', # current_medications + 'Review needed', # allergies + 'ICU setting, complex care', # social_history + 'Multiple abnormalities expected', # vital_signs + 'Complex findings across systems', # physical_exam + 'Multiple abnormal values', # lab_results + 'Multiple studies indicated', # imaging_results + # Auto-populate orchestrator variables from case data + '', # orchestrator_culture + 'multiple antibiotics', # orchestrator_meds + 'multiple sites (respiratory, GI)', # orchestrator_site + 'biofilm risk from devices', # orchestrator_biofilm + 'complex, multiple infections', # orchestrator_response + '30 mL/min', # orchestrator_crcl + 'severe, multiple complications', # orchestrator_severity + 'review allergies needed', # orchestrator_allergies + '', # orchestrator_facility_name + 'ICU', # orchestrator_location + 'multiple HAIs', # orchestrator_infection_type + 'admission', # orchestrator_onset_date + 'multiple devices', # orchestrator_device_days + 'multiple pathogens', # orchestrator_pathogen + 'review resistance patterns', # orchestrator_resistance_pattern + 'contact precautions', # orchestrator_isolation_status + 'complex care coordination', # orchestrator_compliance_issues + 'Multiple acute medical problems', # orchestrator_chief_complaint + f"Complex patient with {case['problem_1']}, {case['problem_2']}, and {case['problem_3']}", # orchestrator_history_present + case['comorbidities'], # orchestrator_past_medical + 'Multiple antibiotics, supportive care', # orchestrator_medications + 'Review needed', # orchestrator_patient_allergies + 'ICU setting, complex care', # orchestrator_social_history + 'Multiple abnormalities expected', # orchestrator_vital_signs + 'Complex findings across systems', # orchestrator_physical_exam + 'Multiple abnormal values', # orchestrator_lab_results + 'Multiple studies indicated' # orchestrator_imaging + ) + + # Store patient data in hidden state + patient_data = gr.State({}) + + # Define patient case data + patient_cases = { + "patient_1": { + "name": "Patient A", + "age": "68", + "summary": "68-year-old female in ICU, day 5 of admission for sepsis", + "current_meds": "vancomycin 1g q12h, piperacillin-tazobactam 4.5g q6h", + "culture_results": "Blood cultures (day 3): methicillin-sensitive Staphylococcus aureus (MSSA), sensitive to cefazolin, nafcillin, clindamycin", + "site_of_infection": "bloodstream", + "biofilm_risk": "central venous catheter present", + "response": "clinically improving, fever resolved, WBC trending down", + "creatinine_clearance": "75 mL/min (normal)", + "severity": "severe sepsis, now stable", + "allergies": "NKDA", + "agent_focus": "πŸ›‘οΈ Antimicrobial Stewardship", + "context": "This patient is a perfect candidate for antibiotic deescalation given the MSSA blood culture results and clinical improvement. Current broad-spectrum therapy can likely be narrowed." + }, + "patient_2": { + "name": "Patient B", + "age": "45", + "summary": "45-year-old male, ICU patient with central line-associated bloodstream infection", + "diagnosis": "Central line-associated bloodstream infection (CLABSI)", + "central_line_days": "6 days", + "culture_results": "Blood cultures positive for methicillin-resistant Staphylococcus aureus (MRSA)", + "symptoms": "fever (38.8Β°C), chills, no other obvious source", + "location": "Methodist Hospital, Dallas, Texas", + "agent_focus": "🦠 Infection Prevention and Control", + "context": "This case requires evaluation for NHSN CLABSI criteria, appropriate isolation precautions for MRSA, and reporting requirements for healthcare-associated infections in Texas." + }, + "patient_3": { + "name": "Research Query", + "topic": "Carbapenem-resistant Enterobacterales (CRE) treatment", + "research_focus": "Novel treatment options for CRE infections", + "specific_interests": "resistance mechanisms, combination therapies, newer antibiotics", + "urgency": "clinical decision support needed", + "agent_focus": "πŸ”¬ Research Assistant", + "context": "Literature search and evidence synthesis needed for treatment of carbapenem-resistant Enterobacterales infections, including mechanism-based approaches and newest therapeutic options." + }, + "patient_4": { + "name": "Patient C", + "age": "32", + "summary": "32-year-old male with fever, rash, and arthralgia after travel", + "travel_history": "Recent travel to Southeast Asia (Thailand, Vietnam) 3 weeks ago", + "symptoms": "fever (39.1Β°C), maculopapular rash on trunk and extremities, polyarthralgia", + "duration": "symptoms for 5 days", + "differential": "considering dengue fever, chikungunya, Zika virus, typhus, malaria", + "agent_focus": "πŸ₯ Clinical Assistant", + "context": "Complex infectious disease case requiring systematic evaluation of travel-related illnesses and patient education about diagnostic workup and treatment options." + }, + "patient_5": { + "name": "Education Request", + "level": "Medical student, 3rd year", + "topic": "Antimicrobial resistance mechanisms", + "request": "Board exam questions and educational materials", + "focus_areas": "beta-lactamase types, carbapenemases, ESBL, AmpC", + "format_needed": "multiple choice questions, flashcards, presentation slides", + "agent_focus": "πŸ“š Education Assistant", + "context": "Educational content creation for antimicrobial resistance mechanisms, suitable for medical student board exam preparation with varying difficulty levels." + }, + "patient_6": { + "name": "Patient D", + "age": "75", + "summary": "75-year-old male with multiple infectious complications", + "problem_1": "Ventilator-associated pneumonia with XDR Pseudomonas aeruginosa", + "problem_2": "Clostridioides difficile colitis (severe, recurrent)", + "problem_3": "Suspected infective endocarditis (blood cultures pending)", + "comorbidities": "diabetes, chronic kidney disease (CrCl 30 mL/min), heart failure", + "current_status": "ICU day 12, on multiple antibiotics, clinically complex", + "agent_focus": "🎼 Orchestrator", + "context": "Complex multi-system infectious disease case requiring coordination between stewardship, infection control, and clinical decision-making across multiple agents and specialties." + } + } + + + + # Connect patient card click handlers + patient_card_1.click( + fn=load_patient_1, + inputs=[], + outputs=[ + chat_view, patient_data, + deescalation_culture, deescalation_meds, + stewardship_site, stewardship_biofilm, stewardship_response, stewardship_crcl, stewardship_severity, stewardship_allergies, + empiric_age, empiric_allergies, empiric_labs, empiric_culture, empiric_meds, empiric_site, empiric_biofilm, empiric_response, empiric_crcl, empiric_severity, + ipc_facility_name, ipc_location, ipc_infection_type, ipc_onset_date, ipc_device_days, ipc_pathogen, ipc_resistance_pattern, ipc_isolation_status, ipc_compliance_issues, + clinical_chief_complaint, clinical_history_present, clinical_past_medical, clinical_medications, clinical_allergies, clinical_social_history, clinical_vital_signs, clinical_physical_exam, clinical_lab_results, clinical_imaging, + orchestrator_culture, orchestrator_meds, orchestrator_site, orchestrator_biofilm, orchestrator_response, orchestrator_crcl, orchestrator_severity, orchestrator_allergies, orchestrator_facility_name, orchestrator_location, orchestrator_infection_type, orchestrator_onset_date, orchestrator_device_days, orchestrator_pathogen, orchestrator_resistance_pattern, orchestrator_isolation_status, orchestrator_compliance_issues, orchestrator_chief_complaint, orchestrator_history_present, orchestrator_past_medical, orchestrator_medications, orchestrator_patient_allergies, orchestrator_social_history, orchestrator_vital_signs, orchestrator_physical_exam, orchestrator_lab_results, orchestrator_imaging + ] + ) + + patient_card_2.click( + fn=load_patient_2, + inputs=[], + outputs=[ + chat_view, patient_data, + deescalation_culture, deescalation_meds, + stewardship_site, stewardship_biofilm, stewardship_response, stewardship_crcl, stewardship_severity, stewardship_allergies, + empiric_age, empiric_allergies, empiric_labs, empiric_culture, empiric_meds, empiric_site, empiric_biofilm, empiric_response, empiric_crcl, empiric_severity, + ipc_facility_name, ipc_location, ipc_infection_type, ipc_onset_date, ipc_device_days, ipc_pathogen, ipc_resistance_pattern, ipc_isolation_status, ipc_compliance_issues, + clinical_chief_complaint, clinical_history_present, clinical_past_medical, clinical_medications, clinical_allergies, clinical_social_history, clinical_vital_signs, clinical_physical_exam, clinical_lab_results, clinical_imaging, + orchestrator_culture, orchestrator_meds, orchestrator_site, orchestrator_biofilm, orchestrator_response, orchestrator_crcl, orchestrator_severity, orchestrator_allergies, orchestrator_facility_name, orchestrator_location, orchestrator_infection_type, orchestrator_onset_date, orchestrator_device_days, orchestrator_pathogen, orchestrator_resistance_pattern, orchestrator_isolation_status, orchestrator_compliance_issues, orchestrator_chief_complaint, orchestrator_history_present, orchestrator_past_medical, orchestrator_medications, orchestrator_patient_allergies, orchestrator_social_history, orchestrator_vital_signs, orchestrator_physical_exam, orchestrator_lab_results, orchestrator_imaging + ] + ) + + patient_card_3.click( + fn=load_patient_3, + inputs=[], + outputs=[ + chat_view, patient_data, + deescalation_culture, deescalation_meds, + stewardship_site, stewardship_biofilm, stewardship_response, stewardship_crcl, stewardship_severity, stewardship_allergies, + empiric_age, empiric_allergies, empiric_labs, empiric_culture, empiric_meds, empiric_site, empiric_biofilm, empiric_response, empiric_crcl, empiric_severity, + ipc_facility_name, ipc_location, ipc_infection_type, ipc_onset_date, ipc_device_days, ipc_pathogen, ipc_resistance_pattern, ipc_isolation_status, ipc_compliance_issues, + clinical_chief_complaint, clinical_history_present, clinical_past_medical, clinical_medications, clinical_allergies, clinical_social_history, clinical_vital_signs, clinical_physical_exam, clinical_lab_results, clinical_imaging, + orchestrator_culture, orchestrator_meds, orchestrator_site, orchestrator_biofilm, orchestrator_response, orchestrator_crcl, orchestrator_severity, orchestrator_allergies, orchestrator_facility_name, orchestrator_location, orchestrator_infection_type, orchestrator_onset_date, orchestrator_device_days, orchestrator_pathogen, orchestrator_resistance_pattern, orchestrator_isolation_status, orchestrator_compliance_issues, orchestrator_chief_complaint, orchestrator_history_present, orchestrator_past_medical, orchestrator_medications, orchestrator_patient_allergies, orchestrator_social_history, orchestrator_vital_signs, orchestrator_physical_exam, orchestrator_lab_results, orchestrator_imaging + ] + ) + + patient_card_4.click( + fn=load_patient_4, + inputs=[], + outputs=[ + chat_view, patient_data, + deescalation_culture, deescalation_meds, + stewardship_site, stewardship_biofilm, stewardship_response, stewardship_crcl, stewardship_severity, stewardship_allergies, + empiric_age, empiric_allergies, empiric_labs, empiric_culture, empiric_meds, empiric_site, empiric_biofilm, empiric_response, empiric_crcl, empiric_severity, + ipc_facility_name, ipc_location, ipc_infection_type, ipc_onset_date, ipc_device_days, ipc_pathogen, ipc_resistance_pattern, ipc_isolation_status, ipc_compliance_issues, + clinical_chief_complaint, clinical_history_present, clinical_past_medical, clinical_medications, clinical_allergies, clinical_social_history, clinical_vital_signs, clinical_physical_exam, clinical_lab_results, clinical_imaging, + orchestrator_culture, orchestrator_meds, orchestrator_site, orchestrator_biofilm, orchestrator_response, orchestrator_crcl, orchestrator_severity, orchestrator_allergies, orchestrator_facility_name, orchestrator_location, orchestrator_infection_type, orchestrator_onset_date, orchestrator_device_days, orchestrator_pathogen, orchestrator_resistance_pattern, orchestrator_isolation_status, orchestrator_compliance_issues, orchestrator_chief_complaint, orchestrator_history_present, orchestrator_past_medical, orchestrator_medications, orchestrator_patient_allergies, orchestrator_social_history, orchestrator_vital_signs, orchestrator_physical_exam, orchestrator_lab_results, orchestrator_imaging + ] + ) + + patient_card_5.click( + fn=load_patient_5, + inputs=[], + outputs=[ + chat_view, patient_data, + deescalation_culture, deescalation_meds, + stewardship_site, stewardship_biofilm, stewardship_response, stewardship_crcl, stewardship_severity, stewardship_allergies, + empiric_age, empiric_allergies, empiric_labs, empiric_culture, empiric_meds, empiric_site, empiric_biofilm, empiric_response, empiric_crcl, empiric_severity, + ipc_facility_name, ipc_location, ipc_infection_type, ipc_onset_date, ipc_device_days, ipc_pathogen, ipc_resistance_pattern, ipc_isolation_status, ipc_compliance_issues, + clinical_chief_complaint, clinical_history_present, clinical_past_medical, clinical_medications, clinical_allergies, clinical_social_history, clinical_vital_signs, clinical_physical_exam, clinical_lab_results, clinical_imaging, + orchestrator_culture, orchestrator_meds, orchestrator_site, orchestrator_biofilm, orchestrator_response, orchestrator_crcl, orchestrator_severity, orchestrator_allergies, orchestrator_facility_name, orchestrator_location, orchestrator_infection_type, orchestrator_onset_date, orchestrator_device_days, orchestrator_pathogen, orchestrator_resistance_pattern, orchestrator_isolation_status, orchestrator_compliance_issues, orchestrator_chief_complaint, orchestrator_history_present, orchestrator_past_medical, orchestrator_medications, orchestrator_patient_allergies, orchestrator_social_history, orchestrator_vital_signs, orchestrator_physical_exam, orchestrator_lab_results, orchestrator_imaging + ] + ) + + patient_card_6.click( + fn=load_patient_6, + inputs=[], + outputs=[ + chat_view, patient_data, + deescalation_culture, deescalation_meds, + stewardship_site, stewardship_biofilm, stewardship_response, stewardship_crcl, stewardship_severity, stewardship_allergies, + empiric_age, empiric_allergies, empiric_labs, empiric_culture, empiric_meds, empiric_site, empiric_biofilm, empiric_response, empiric_crcl, empiric_severity, + ipc_facility_name, ipc_location, ipc_infection_type, ipc_onset_date, ipc_device_days, ipc_pathogen, ipc_resistance_pattern, ipc_isolation_status, ipc_compliance_issues, + clinical_chief_complaint, clinical_history_present, clinical_past_medical, clinical_medications, clinical_allergies, clinical_social_history, clinical_vital_signs, clinical_physical_exam, clinical_lab_results, clinical_imaging, + orchestrator_culture, orchestrator_meds, orchestrator_site, orchestrator_biofilm, orchestrator_response, orchestrator_crcl, orchestrator_severity, orchestrator_allergies, orchestrator_facility_name, orchestrator_location, orchestrator_infection_type, orchestrator_onset_date, orchestrator_device_days, orchestrator_pathogen, orchestrator_resistance_pattern, orchestrator_isolation_status, orchestrator_compliance_issues, orchestrator_chief_complaint, orchestrator_history_present, orchestrator_past_medical, orchestrator_medications, orchestrator_patient_allergies, orchestrator_social_history, orchestrator_vital_signs, orchestrator_physical_exam, orchestrator_lab_results, orchestrator_imaging + ] + ) + + # --- Show/hide stewardship fields based on agent selection --- + + def update_dynamic_vars_visibility(agent_name): + agent_json = agents_config.get(agent_name) + if agent_json: + agent_data = json.loads(agent_json) + skills = agent_data.get("skills", []) + agent_type = agent_data.get("agent_type", "") + + # Stewardship tools + if "recommend_deescalation" in skills or "alert_prolonged_antibiotic_use" in skills: + show_culture = "recommend_deescalation" in skills + show_meds = "recommend_deescalation" in skills + return ( + gr.update(visible=True), # stewardship_vars_section + gr.update(visible=show_culture), # deescalation_culture + gr.update(visible=show_meds), # deescalation_meds + gr.update(visible=False), # empiric_vars_section + gr.update(visible=False), # ipc_vars_section + gr.update(visible=False), # clinical_vars_section + gr.update(visible=False) # orchestrator_vars_section + ) + # Empiric therapy tool + elif "recommend_empiric_therapy" in skills: + return ( + gr.update(visible=False), # stewardship_vars_section + gr.update(visible=False), # deescalation_culture + gr.update(visible=False), # deescalation_meds + gr.update(visible=True), # empiric_vars_section + gr.update(visible=False), # ipc_vars_section + gr.update(visible=False), # clinical_vars_section + gr.update(visible=False) # orchestrator_vars_section + ) + # IPC tools + elif "IPC_reporting" in skills or "NHSN_criteria_evaluator" in skills or "recommend_isolation_precautions" in skills: + return ( + gr.update(visible=False), # stewardship_vars_section + gr.update(visible=False), # deescalation_culture + gr.update(visible=False), # deescalation_meds + gr.update(visible=False), # empiric_vars_section + gr.update(visible=True), # ipc_vars_section + gr.update(visible=False), # clinical_vars_section + gr.update(visible=False) # orchestrator_vars_section + ) + # Clinical Assistant tools + elif "retrieve_guidelines" in skills or "explain_in_layman_language" in skills or "history_taking" in skills: + return ( + gr.update(visible=False), # stewardship_vars_section + gr.update(visible=False), # deescalation_culture + gr.update(visible=False), # deescalation_meds + gr.update(visible=False), # empiric_vars_section + gr.update(visible=False), # ipc_vars_section + gr.update(visible=True), # clinical_vars_section + gr.update(visible=False) # orchestrator_vars_section + ) + # Orchestrator (check by agent type since it has no specific skills) + elif "🎼 Orchestrator" in agent_type: + return ( + gr.update(visible=False), # stewardship_vars_section + gr.update(visible=False), # deescalation_culture + gr.update(visible=False), # deescalation_meds + gr.update(visible=False), # empiric_vars_section + gr.update(visible=False), # ipc_vars_section + gr.update(visible=False), # clinical_vars_section + gr.update(visible=True) # orchestrator_vars_section + ) + + # Hide all + return ( + gr.update(visible=False), # stewardship_vars_section + gr.update(visible=False), # deescalation_culture + gr.update(visible=False), # deescalation_meds + gr.update(visible=False), # empiric_vars_section + gr.update(visible=False), # ipc_vars_section + gr.update(visible=False), # clinical_vars_section + gr.update(visible=False) # orchestrator_vars_section + ) + + agent_picker.change( + fn=update_dynamic_vars_visibility, + inputs=[agent_picker], + outputs=[ + stewardship_vars_section, deescalation_culture, deescalation_meds, empiric_vars_section, + ipc_vars_section, clinical_vars_section, orchestrator_vars_section + ] + ) + + + # Client-side script + gr.HTML(""" + + """) + + # --- Interactions --- + # Simple GPT-3.5 Chat callbacks (no skills, no internet) + def simple_send_handler(user_message, history): + updated_history, cleared = simple_chat_response(user_message, history) + return updated_history, cleared, updated_history + + simple_send.click( + simple_send_handler, + inputs=[simple_input, simple_chat_history], + outputs=[simple_chatbot, simple_input, simple_chat_history], + ) + simple_input.submit( + simple_send_handler, + inputs=[simple_input, simple_chat_history], + outputs=[simple_chatbot, simple_input, simple_chat_history], + ) + + def simple_reset_handler(): + return [], "", [] + + simple_reset.click( + simple_reset_handler, + inputs=[], + outputs=[simple_chatbot, simple_input, simple_chat_history], + ) + + start_button.click( + fn=lambda: (gr.update(visible=False), gr.update(visible=True), gr.update(visible=False), ""), + inputs=[], + outputs=[landing_panel, agent_form, chat_panel, challenger_debug_md], + ) + + back_button.click( + fn=show_landing, + inputs=[], + outputs=[landing_panel, agent_form, chat_panel], + ) + + to_chat_button.click( + fn=lambda: (gr.update(visible=False), gr.update(visible=False), gr.update(visible=True), ""), + outputs=[landing_panel, agent_form, chat_panel, challenger_debug_md] + ).then( + refresh_active_agents_widgets, + inputs=[], + outputs=[chat_active_agents, agent_picker] + ) + + # Only wire up chat_back for the agent_chat panel (third page) + chat_back.click( + fn=lambda: (gr.update(visible=False), gr.update(visible=True), gr.update(visible=False), ""), + outputs=[landing_panel, agent_form, chat_panel, challenger_debug_md] + ) + + # when you pick a new agent, reload its history into chat_view + agent_picker.change( + fn=load_history, + inputs=[agent_picker, deployed_chat_histories], + outputs=[chat_view] + ) + + + # when you click Send, append & re-render + def chatpanel_handle_with_dynamic_vars( + agent_name, user_text, histories, + deescalation_culture, deescalation_meds, + stewardship_site, stewardship_biofilm, stewardship_response, stewardship_crcl, stewardship_severity, stewardship_allergies, + empiric_age, empiric_allergies, empiric_labs, empiric_culture, empiric_meds, empiric_site, empiric_biofilm, empiric_response, empiric_crcl, empiric_severity + ): + agent_json = agents_config.get(agent_name) + # Prevent tool invocation on empty/whitespace input (e.g., initial greeting or agent selection) + if user_text is None or not str(user_text).strip(): + # Just return the current history, do not invoke any tool + return histories.get(agent_name, []), histories, user_text or "" + + # --- IPC dynamic requirements integration --- + ipc_tool_triggered = False + ipc_jurisdiction = None + ipc_info = None + if agent_json: + agent_data = json.loads(agent_json) + skills = agent_data.get("skills", []) + history = histories.get(agent_name, []) + # --- Trusted links wiring --- + trusted_links = [] + for k in ["trusted_links", "trusted_links_1", "trusted_links_2", "trusted_links_3", "trusted_links_4"]: + if isinstance(agent_data.get(k), list): + trusted_links.extend([l for l in agent_data[k] if l]) + elif isinstance(agent_data.get(k), str) and agent_data[k]: + trusted_links.append(agent_data[k]) + for k in ["link1", "link2", "link3", "link4"]: + if agent_data.get(k): + trusted_links.append(agent_data[k]) + trusted_links = [l for l in trusted_links if l] + # --- End trusted links wiring --- + + # IPC tool dynamic requirements fetch + if "ipc_reporting" in skills: + # TODO: Fix IPC requirements fetch + # fetch_ipc_requirements = tools.fetch_ipc_requirements + # Always extract the latest jurisdiction from the most recent user message + us_states = [ + "Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut", "Delaware", "Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa", "Kansas", "Kentucky", "Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota", "Mississippi", "Missouri", "Montana", "Nebraska", "Nevada", "New Hampshire", "New Jersey", "New Mexico", "New York", "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania", "Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont", "Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming" + ] + # Try to find a state name (case-insensitive) in the latest user message + state_match = None + for state in us_states: + if re.search(rf"\\b{state}\\b", user_text, re.IGNORECASE): + state_match = state + break + match = re.search(r"\b(CDC|WHO|United States|US|World Health Organization|global)\b", user_text, re.IGNORECASE) + if state_match: + ipc_jurisdiction = state_match + elif match: + ipc_jurisdiction = match.group(1) + else: + # If not in this message, look back in history for last mentioned jurisdiction + ipc_jurisdiction = None + for msg in reversed(history): + if msg["role"] == "user": + for state in us_states: + if re.search(rf"\\b{state}\\b", msg["content"], re.IGNORECASE): + ipc_jurisdiction = state + break + if not ipc_jurisdiction: + match2 = re.search(r"\b(CDC|WHO|United States|US|World Health Organization|global)\b", msg["content"], re.IGNORECASE) + if match2: + ipc_jurisdiction = match2.group(1) + if ipc_jurisdiction: + break + if not ipc_jurisdiction: + ipc_jurisdiction = "CDC" # Default + # Fetch requirements for the extracted jurisdiction + # ipc_info = fetch_ipc_requirements(ipc_jurisdiction) + ipc_info = None # TODO: Fix IPC requirements fetch + ipc_tool_triggered = True + # Prepend IPC requirements info to user_text for the agent + if ipc_info: + req_fields_list = ipc_info.get("fields", []) + req_fields = ", ".join(req_fields_list) + summary = ipc_info.get("summary", "") + source_url = ipc_info.get("source_url") + warning = ipc_info.get("warning") + else: + req_fields_list = [] + req_fields = "" + summary = "" + source_url = "" + warning = "" + + if ipc_info: # Only process if we have valid info + # --- Prevent repeated tool invocation logic --- + # Gather current field values from user_text/history (simple: just use user_text for now) + current_submission = { + "jurisdiction": ipc_jurisdiction, + "fields": req_fields_list, + "summary": summary, + "user_text": user_text.strip() + } + # Find last IPC_SUBMISSION in history + last_ipc_submission = None + for msg in reversed(history): + if msg["role"] == "system" and msg["content"].startswith("[IPC_SUBMISSION]"): + try: + last_ipc_submission = json.loads(msg["content"][len("[IPC_SUBMISSION]"):].strip()) + except Exception: + last_ipc_submission = None + break + # Only proceed if something changed + if last_ipc_submission == current_submission: + # Requirements and user input unchanged, do not re-invoke + return history, histories, "" + # --- Dynamic required fields logic --- + # Only prompt for missing fields if the live/static requirements specify them + missing_fields = [] + if req_fields_list: + # Try to extract field values from user_text (very basic: look for each field name in user_text, case-insensitive) + for field in req_fields_list: + if not re.search(rf"\b{re.escape(field)}\b", user_text, re.IGNORECASE): + missing_fields.append(field) + if missing_fields: + prompt = f"Please provide the following required information for IPC reporting: {', '.join(missing_fields)}." + history.append({"role": "assistant", "content": prompt}) + return history, histories, "" + # If no required fields or all are present, proceed + user_text = f"[IPC_REQUIREMENTS] Jurisdiction: {ipc_jurisdiction}\nRequired fields: {req_fields}\nSummary: {summary}\n" + user_text + # Always add a visible assistant message to the chat so the user sees which requirements are being used + visible_msg = f"πŸ“‹ Reporting requirements for {ipc_jurisdiction}:
" + if req_fields: + visible_msg += f"Required fields: {req_fields}. " + if summary: + visible_msg += f"Summary: {summary} " + if source_url: + visible_msg += f'
Source: {source_url}' + if warning: + visible_msg += f'
Warning: {warning}' + try: + with open("debug_log.txt", "a", encoding="utf-8") as f: + f.write(f"[DEBUG] IPC visible_msg: {visible_msg}\n") + except Exception as e: + pass + # Add the requirements message if it's new or the jurisdiction changed + if not (history and history[-1]["role"] == "assistant" and ipc_jurisdiction in history[-1]["content"]): + history.append({"role": "assistant", "content": visible_msg}) + # Store this submission in history as a hidden system message + history.append({"role": "system", "content": "[IPC_SUBMISSION] " + json.dumps(current_submission)}) + + # Deescalation tool + if "recommend_deescalation" in skills: + var_names = ["culture", "meds", "site_of_infection", "risk_of_biofilm", "current_response", "creatinine_clearance", "severity_of_infection", "known_allergies"] + user_vars = { + "culture": deescalation_culture, + "meds": deescalation_meds, + "site_of_infection": stewardship_site, + "risk_of_biofilm": stewardship_biofilm, + "current_response": stewardship_response, + "creatinine_clearance": stewardship_crcl, + "severity_of_infection": stewardship_severity, + "known_allergies": stewardship_allergies + } + extracted = extract_clinical_variables_from_history(history, var_names) + for k in var_names: + if not user_vars[k]: + user_vars[k] = extracted.get(k) or "" + # Only prepend if at least one field is non-empty + if any(user_vars[k] for k in var_names): + user_text = f"[DEESCALATION_TOOL_INPUT] {json.dumps(user_vars)}\n" + user_text + # Alert prolonged antibiotic use tool + elif "alert_prolonged_antibiotic_use" in skills: + var_names = ["site_of_infection", "risk_of_biofilm", "current_response", "creatinine_clearance", "severity_of_infection", "known_allergies"] + user_vars = { + "site_of_infection": stewardship_site, + "risk_of_biofilm": stewardship_biofilm, + "current_response": stewardship_response, + "creatinine_clearance": stewardship_crcl, + "severity_of_infection": stewardship_severity, + "known_allergies": stewardship_allergies + } + extracted = extract_clinical_variables_from_history(history, var_names) + for k in var_names: + if not user_vars[k]: + user_vars[k] = extracted.get(k) or "" + if any(user_vars[k] for k in var_names): + user_text = f"[ALERT_PROLONGED_ABX_INPUT] {json.dumps(user_vars)}\n" + user_text + # Empiric therapy tool + elif "recommend_empiric_therapy" in skills: + var_names = [ + "age", "allergies", "labs", "culture", "meds", "site_of_infection", + "risk_of_biofilm", "current_response", "creatinine_clearance", "severity_of_infection" + ] + user_vars = { + "age": empiric_age, + "allergies": empiric_allergies, + "labs": empiric_labs, + "culture": empiric_culture, + "meds": empiric_meds, + "site_of_infection": empiric_site, + "risk_of_biofilm": empiric_biofilm, + "current_response": empiric_response, + "creatinine_clearance": empiric_crcl, + "severity_of_infection": empiric_severity + } + extracted = extract_clinical_variables_from_history(history, var_names) + for k in var_names: + if not user_vars[k]: + user_vars[k] = extracted.get(k) or "" + # If any required field is missing, prompt = ... + missing = [k.replace('_', ' ').capitalize() for k in var_names if not user_vars[k].strip()] + if missing: + prompt = f"Please provide the following required information for empiric therapy: {', '.join(missing)}." + # Show this as an assistant message and do not call the tool + history.append({"role": "assistant", "content": prompt}) + return history, histories, "" + # All required fields present, prepend tool input + if any(user_vars[k] for k in var_names): + user_text = f"[EMPIRIC_THERAPY_INPUT] {json.dumps(user_vars)}\n" + user_text + # Use simulate_agent_response_stream for all agents to ensure challenger logic is applied + import asyncio + from core.agents.chat_orchestrator import simulate_agent_response_stream + agent_json_val = agents_config.get(agent_name) + history_val = histories.get(agent_name, []) + result = None + # Prevent repeated tool invocation: if the last assistant message is a tool request for the same required fields, do not re-invoke + if history_val and history_val[-1]["role"] == "assistant": + last_content = history_val[-1]["content"] + if "required fields" in last_content.lower() and "ipc_reporting" in last_content.lower(): + # Don't re-invoke, just return + return history_val, histories, "" + async def run_stream(): + final_history = history_val + final_invocation_log = "" + challenger_info = None + + gen = simulate_agent_response_stream( + agent_json=agent_json_val, + history=history_val, + user_input=user_text, + debug_flag=False, + active_children=[] + ) + + async for updated_history, _, invocation_log, _, challenger_info in gen: + final_history = updated_history + final_invocation_log = invocation_log + + return final_history, final_invocation_log, challenger_info + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + result = loop.run_until_complete(run_stream()) + if result is not None and len(result) == 3: + final_history, invocation_log, challenger_info = result + else: + final_history, updated_histories, cleared_input, invocation_log = chatpanel_handle(agent_name, user_text, histories) + challenger_info = None + return final_history, updated_histories, cleared_input, invocation_log + except Exception: + # fallback to old behavior if error + final_history, updated_histories, cleared_input, invocation_log = chatpanel_handle(agent_name, user_text, histories) + challenger_info = None + return final_history, updated_histories, cleared_input, invocation_log + # Update histories + updated_histories = histories.copy() + updated_histories[agent_name] = final_history + # Prepare challenger markdown (debug log for builder panel) + challenger_md = "" + challenger_enabled = False + try: + if agent_json_val: + agent_data = json.loads(agent_json_val) + challenger_enabled = agent_data.get("challenger_enabled", False) + except Exception: + pass + if isinstance(challenger_info, dict) and challenger_enabled: + orig = challenger_info.get("original_reply", "") + crit = challenger_info.get("challenger_critique", "") + final = challenger_info.get("final_reply", "") + challenger_md = f"**Original Agent Answer:**\n\n{orig}\n\n**Challenger Critique:**\n\n{crit}\n\n**Final Answer Shown to User:**\n\n{final}" + # Only show the final (challenger-approved) answer in the chatbox + if final and final_history and isinstance(final_history, list): + final_history[-1]["content"] = final + # If challenger is not enabled, do not show the markdown at all + elif not challenger_enabled: + challenger_md = "" + return final_history, updated_histories, "", invocation_log, challenger_md + + chat_send.click( + fn=chatpanel_handle_with_dynamic_vars, + inputs=[ + agent_picker, chat_panel_input, deployed_chat_histories, + deescalation_culture, deescalation_meds, + stewardship_site, stewardship_biofilm, stewardship_response, stewardship_crcl, stewardship_severity, stewardship_allergies, + empiric_age, empiric_allergies, empiric_labs, empiric_culture, empiric_meds, empiric_site, empiric_biofilm, empiric_response, empiric_crcl, empiric_severity + ], + outputs=[chat_view, deployed_chat_histories, chat_panel_input] + ) + + # --- Reset button for deployed agent chat panel --- + def reset_and_clear_deployed_history(agent_name, histories): + # Clear orchestrator state to prevent persistence across conversations + from core.agents.chat_orchestrator import orchestrators + orchestrators.clear() + + if not agent_name: + return ( + [], histories, "", + "", "", "", "", "", "", "", "", # stewardship fields cleared (8 fields) + "", "", "", "", "", "", "", "", "", "", # empiric fields cleared (10 fields) + "", "", "", "", "", "", "", "", "", # ipc fields cleared (9 fields) + "", "", "", "", "", "", "", "", "", "", # clinical fields cleared (10 fields) + "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "" # orchestrator fields cleared (27 fields) + ) + # Remove the agent's chat history and re-seed with greeting + if agent_name in histories: + del histories[agent_name] + chat_history = [{"role": "assistant", "content": f"πŸ‘‹ Hello! I'm {agent_name}. How can I help?"}] + histories[agent_name] = chat_history + return ( + chat_history, histories, "", + "", "", "", "", "", "", "", "", # stewardship fields cleared (8 fields) + "", "", "", "", "", "", "", "", "", "", # empiric fields cleared (10 fields) + "", "", "", "", "", "", "", "", "", # ipc fields cleared (9 fields) + "", "", "", "", "", "", "", "", "", "", # clinical fields cleared (10 fields) + "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "" # orchestrator fields cleared (27 fields) + ) + + chat_reset.click( + reset_and_clear_deployed_history, + inputs=[agent_picker, deployed_chat_histories], + outputs=[ + chat_view, deployed_chat_histories, chat_panel_input, + deescalation_culture, deescalation_meds, + stewardship_site, stewardship_biofilm, stewardship_response, stewardship_crcl, stewardship_severity, stewardship_allergies, + empiric_age, empiric_allergies, empiric_labs, empiric_culture, empiric_meds, empiric_site, empiric_biofilm, empiric_response, empiric_crcl, empiric_severity, + ipc_facility_name, ipc_location, ipc_infection_type, ipc_onset_date, ipc_device_days, ipc_pathogen, ipc_resistance_pattern, ipc_isolation_status, ipc_compliance_issues, + clinical_chief_complaint, clinical_history_present, clinical_past_medical, clinical_medications, clinical_allergies, clinical_social_history, clinical_vital_signs, clinical_physical_exam, clinical_lab_results, clinical_imaging, + orchestrator_culture, orchestrator_meds, orchestrator_site, orchestrator_biofilm, orchestrator_response, orchestrator_crcl, orchestrator_severity, orchestrator_allergies, orchestrator_facility_name, orchestrator_location, orchestrator_infection_type, orchestrator_onset_date, orchestrator_device_days, orchestrator_pathogen, orchestrator_resistance_pattern, orchestrator_isolation_status, orchestrator_compliance_issues, orchestrator_chief_complaint, orchestrator_history_present, orchestrator_past_medical, orchestrator_medications, orchestrator_patient_allergies, orchestrator_social_history, orchestrator_vital_signs, orchestrator_physical_exam, orchestrator_lab_results, orchestrator_imaging + ] + ) + + agent_type.change( + fn=on_agent_type_change, + inputs=[agent_type, prefill_flag], + outputs=[skills, agent_name, agent_mission, prefill_flag] + ) + + prefilled.change( + fn=populate_from_preset, + inputs=[prefilled], + outputs=[agent_type, agent_name, agent_mission, skills, prefill_flag] + ) + uploaded_files.upload(fn=handle_uploaded_files, inputs=[uploaded_files], outputs=[upload_alert, upload_alert]) + def handle_generate(agent_type, agent_name, agent_mission, selected_skills, web_access, allow_fallback, uploaded_files, link1, link2, link3, link4, challenger_toggle): + # Accept challenger_toggle as an argument + agent_json = build_agent(agent_type, agent_name, agent_mission, selected_skills, web_access, allow_fallback, uploaded_files, link1, link2, link3, link4) + # Add challenger_enabled to the agent config JSON + agent_data = json.loads(agent_json) + agent_data["challenger_enabled"] = challenger_toggle + agent_json = json.dumps(agent_data) + agents_config[agent_name] = agent_json + return agent_json + + generate_button.click( + lambda: gr.update(visible=True, value="⏳ Generating your agent..."), + inputs=[], outputs=[agent_loader] + ).then( # 1) add / save the agent + handle_generate, + inputs=[agent_type, agent_name, agent_mission, skills, + web_access_toggle, allow_fallback_toggle, + uploaded_files, link1, link2, link3, link4, challenger_toggle], + outputs=[agent_output] # <- only the JSON + ).then( # 2) show initial instruction instead of preload demo chat + lambda: show_initial_instruction_state(), + inputs=[], outputs=[builder_chatbot, chat_input, builder_send_button, reset_button, invocation_log, active_children] + ).then( # 2.5) auto-load agent fields into builder panel + lambda agent_json: ( + json.loads(agent_json).get("agent_type", ""), + json.loads(agent_json).get("agent_name", ""), + json.loads(agent_json).get("agent_mission", ""), + json.loads(agent_json).get("skills", []) + ), + inputs=[agent_output], + outputs=[agent_type, agent_name, agent_mission, skills] + ).then( # 3) refresh markdown & dropdown *atomically* + refresh_active_agents_widgets, + inputs=[], outputs=[builder_active_agents, agent_remove_dropdown] + ).then( # 4) done spinner + lambda: gr.update(visible=True, value="βœ… Agent generated successfully!"), + inputs=[], outputs=[agent_loader] + ).then( + refresh_chat_dropdown, + inputs=[], + outputs=[agent_picker] + ) - # Connect the function - test_btn.click( - fn=simple_test, - inputs=[message_input], - outputs=[result_output] - ) + edit_agent_button.click( + load_agent_to_builder, + inputs=[agent_remove_dropdown], + outputs=[agent_type, agent_name, agent_mission, skills] + ) + + chat_agent_button.click( + fn=enable_chat_controls_with_agent, + inputs=[agent_remove_dropdown], + outputs=[builder_chatbot, chat_input, builder_send_button, reset_button, invocation_log, active_children, agent_output] + ) + + remove_agent_button.click( + remove_selected_agent, + inputs=[agent_remove_dropdown], + outputs=[builder_active_agents, agent_remove_dropdown] + ).then( + refresh_chat_dropdown, + inputs=[], + outputs=[agent_picker] + ) - gr.Markdown(""" - ### βœ… If this interface loads and responds, the new Space deployment works! + download_button.click( + prepare_download, + inputs=[agent_output], + outputs=[download_button] + ) - This confirms whether the issue is: - - ❌ **Space-specific**: The original Space has a problem - - ⚠️ **Platform-wide**: HF Spaces has broader issues - """) + # Only keep reset for builder panel, and chat_send for chat panel + def reset_and_clear_builder_history(agent_json, histories): + # Clear orchestrator state to prevent persistence across conversations + from core.agents.chat_orchestrator import orchestrators + orchestrators.clear() + + if not agent_json or agent_json.strip() == "": + # No agent selected, return disabled state with instruction + instruction_state = show_initial_instruction_state() + return instruction_state[0], instruction_state[1], instruction_state[5], histories # chatbot, input, active_children, histories + + # Valid agent selected, show agent greeting with enabled controls + agent_data = json.loads(agent_json) + name = agent_data.get("agent_name", "Agent") + welcome = f"πŸ‘‹ Hello! I'm {name}. How can I assist you today?" + chat_history = [{"role":"assistant","content":welcome}] + if name in histories: + del histories[name] + return chat_history, gr.update(value="", interactive=True, placeholder="Type your question here…"), [], histories + + reset_button.click( + reset_and_clear_builder_history, + inputs=[agent_output, builder_chat_histories], + outputs=[builder_chatbot, chat_input, active_children, builder_chat_histories] + ) + + + return app if __name__ == "__main__": - demo.launch() + try: + # Check if running on Hugging Face Spaces + try: + from hf_config import configure_hf_environment, get_hf_launch_config + if configure_hf_environment(): + # Use HF Spaces configuration + launch_config = get_hf_launch_config() + print("πŸš€ Launching on Hugging Face Spaces...") + build_ui().launch(**launch_config) + else: + # Local development + print("πŸ”§ Launching locally...") + build_ui().launch() + except ImportError: + # Fallback if hf_config not available + print("πŸ”§ Launching with default configuration...") + build_ui().launch() + except Exception as e: + logger.error(f"Failed to launch Gradio app: {e}") + print(f"Failed to launch Gradio app: {e}") + print("πŸ’‘ Check your API keys and environment configuration") \ No newline at end of file diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/config.py b/config.py new file mode 100644 index 0000000000000000000000000000000000000000..a05eed0e8829e2a8825ecf0fc0b2ac72f7a556d0 --- /dev/null +++ b/config.py @@ -0,0 +1,102 @@ +""" +config.py +--------- +Global configuration, shared state, and constants for the modular AI agent system. +""" + +# Global configuration, shared state, and constants for the modular AI agent system. +import os + +agents_config = {} +rag_retriever = None + +skills_library = { + "πŸ›‘οΈ Antimicrobial Stewardship": [ + "recommend_deescalation", + "recommend_empiric_therapy", + "alert_prolonged_antibiotic_use" + ], + "🦠 Infection Prevention and Control": [ + "IPC_reporting", + "NHSN_criteria_evaluator", + "recommend_isolation_precautions" + ], + "πŸ”¬ Research Assistant": [ + "search_pubmed", + "suggest_journals_for_submission", + "format_references" + ], + "πŸ₯ Clinical Assistant": [ + "retrieve_guidelines", + "explain_in_layman_language", + "history_taking" + ], + "πŸ“š Education Assistant": [ + "generate_board_exam_question", + "generate_flash_cards", + "create_educational_presentation" + ], + "🎼 Orchestrator": [ + # Note: Orchestrator capabilities are built-in and don't require skill selection + ] +} + +prefilled_agents = { + "Example Stewardship Agent": { + "agent_name": "SmartSteward", + "agent_type": "πŸ›‘οΈ Antimicrobial Stewardship", + "agent_mission": "Assist stewardship team in optimizing antibiotic use.", + "skills": [ + "recommend_deescalation", + "alert_prolonged_antibiotic_use", + "recommend_empiric_therapy" + ] + }, + "Example Infection Control Agent": { + "agent_name": "InfectoGuard", + "agent_type": "🦠 Infection Prevention and Control", + "agent_mission": "Support IPC team in monitoring outbreaks and adherence to precautions. When users ask about reportable diseases or reporting requirements, offer to help with the specific reporting process using available tools.", + "skills": [ + "IPC_reporting", + "NHSN_criteria_evaluator", + "recommend_isolation_precautions" + ] + }, + "Example Research Assistant Agent": { + "agent_name": "ResearchRanger", + "agent_type": "πŸ”¬ Research Assistant", + "agent_mission": "Assist in literature search, article summarization, and citation formatting.", + "skills": [ + "search_pubmed", + "suggest_journals_for_submission", + "format_references" + ] + }, + "Example Clinical Assistant Agent": { + "agent_name": "ClinicoPilot", + "agent_type": "πŸ₯ Clinical Assistant", + "agent_mission": "Assist clinicians in diagnostic workflows and patient education by translating complex medical information into layman terms.", + "skills": [ + "retrieve_guidelines", + "explain_in_layman_language" + ] + }, + "Example Education Assistant Agent": { + "agent_name": "EduMedCoach", + "agent_type": "πŸ“š Education Assistant", + "agent_mission": "Generate educational materials like board exam questions, flash cards, and comprehensive presentations.", + "skills": [ + "generate_board_exam_question", + "generate_flash_cards", + "create_educational_presentation" + ] + }, + "Example Orchestrator Agent": { + "agent_name": "ID Maestro", + "agent_type": "🎼 Orchestrator", + "agent_mission": "Intelligently coordinate multiple ID agents to provide comprehensive analysis and recommendations.", + "skills": [ + # Orchestrator uses built-in coordination capabilities + ] + } +} diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3a22f1b1dbc310008c45ba3525d7086c6182d6b7 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +# Core module for ID Agents application diff --git a/core/agents/__init__.py b/core/agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6244cc83566da139ca81973daadb86d77ba214dc --- /dev/null +++ b/core/agents/__init__.py @@ -0,0 +1 @@ +# Agent orchestration and management components diff --git a/core/agents/agent_utils.py b/core/agents/agent_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..c6059550016078c0cf063c473b207f04fe910d87 --- /dev/null +++ b/core/agents/agent_utils.py @@ -0,0 +1,84 @@ +import json +import re +import gradio as gr + +def linkify_citations(answer_text: str, titles: dict) -> str: + """ + Replace every literal [n] in the answer with: + [n] + where titles[n] holds the hover text. + """ + def _sub(match): + n = match.group(1) # the number between brackets + title = titles.get(int(n), "") + return f'[{n}]' + + return re.sub(r"\[(\d+)\]", _sub, answer_text) + +def build_agent(agent_type, agent_name, agent_mission, selected_skills, web_access, allow_fallback, uploaded_files, link1, link2, link3, link4): + trusted_links = [link for link in [link1, link2, link3, link4] if link] + agent = { + "agent_name": agent_name, + "agent_type": agent_type, + "agent_mission": agent_mission, + "skills": selected_skills, + "web_access": web_access, + "allow_fallback": allow_fallback, + "grounded_files": [file.name for file in uploaded_files] if uploaded_files else [], + "trusted_links": trusted_links + } + return json.dumps(agent, indent=2) + +def load_prefilled(prefilled_name, prefilled_agents): + if prefilled_name in prefilled_agents: + data = prefilled_agents[prefilled_name] + return (data["agent_type"], data["agent_name"], data["agent_mission"], data["skills"]) + else: + return (None, "", "", []) + +def prepare_download(agent_json): + return ("agent_config.json", agent_json.encode()) + +def preload_demo_chat(agent_json): + """Show instruction message instead of agent greeting to avoid confusion""" + return [{"role": "assistant", "content": "οΏ½ **Agent generated successfully!**\n\nTo start chatting:\n1. Select an agent from the dropdown menu above\n2. Click 'πŸ’¬ Chat with Selected Agent'\n3. Then you can type your questions here\n\n*Please select an agent to begin testing.*"}] + +def _safe_title(text, limit=140): + txt = text.replace('"', "'").replace("\n", " ") + return (txt[:limit] + "…") if len(txt) > limit else txt + +def extract_clinical_variables_from_history(history, variable_names): + """ + Extract the most recent value for each variable from the chat history (list of dicts). + Returns a dict of variable_name: value. + """ + import re + result = {var: None for var in variable_names} + # Search from most recent to oldest + for message in reversed(history): + if message["role"] != "user" and message["role"] != "assistant": + continue + content = message.get("content", "") + for var in variable_names: + # Simple regex: look for 'var: value' or '- var: value' or 'var = value' + pattern = rf"(?:^|\n|\-)\s*{re.escape(var.replace('_', ' '))}\s*[:=]\s*(.+)" + match = re.search(pattern, content, re.IGNORECASE) + if match and not result[var]: + value = match.group(1).strip() + # Remove trailing punctuation + value = re.sub(r"[\.,;\n\r]+$", "", value) + result[var] = value + return result + +def validate_and_reference_recommendation(reply_text: str) -> str: + """ + Checks the reply for common clinical errors and attaches references/guidelines. + Returns the possibly modified reply with references appended. + """ + # No hardcoded clinical rules or references here. If clinical validation is needed, use a knowledge base or guideline-driven system. + return reply_text +""" +agent_utils.py +------------- +Utility functions for agent configuration, citation linking, and other helpers. +""" diff --git a/core/agents/chat_orchestrator.py b/core/agents/chat_orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..29bfdb617e9dd3318484207e9c0ec49b6fea6e5c --- /dev/null +++ b/core/agents/chat_orchestrator.py @@ -0,0 +1,352 @@ +""" +chat_orchestrator.py +------------------- +Agent orchestration, chat streaming, and related logic. +""" + +import json +from typing import Dict, cast +from core.utils.llm_connector import AgentLLMConnector +from core.utils.skills_registry import get_tool_by_name +from core.agents.agent_utils import validate_and_reference_recommendation + +# --- Orchestrator state --- +orchestrators: Dict[str, object] = {} + +# --- Constants --- +MAX_HISTORY = 20 + +# --- Build log utility --- +def build_log(conn): + """Return a formatted log of tool invocations if available.""" + if hasattr(conn, 'invocations'): + invocations = getattr(conn, 'invocations', []) + if not invocations: + return '' + log = '--- Tool Invocation Log ---\n' + for i, inv in enumerate(invocations, 1): + log += f"{i}. {inv}\n" + return log + return '' + +# --- Streaming to agent (for child agents) --- +def _stream_to_agent(cfg, history, user_input, debug_flag, active_children): + """Yield streaming responses for a child agent (sync generator).""" + skill_objs = [] + if cfg.get("web_access", False): + web_tool = get_tool_by_name("search_internet", {"user_query": user_input}) + if web_tool: + skill_objs.append(web_tool) + for skill_name in cfg.get("skills", []): + tool = get_tool_by_name(skill_name, {"user_query": user_input}) + if tool: + skill_objs.append(tool) + # Pass allow_fallback, trusted_links, grounded_files to AgentLLMConnector + allow_fallback = cfg.get("allow_fallback", True) + trusted_links = cfg.get("trusted_links", []) + grounded_files = cfg.get("grounded_files", []) + + # Get global RAG retriever if available + rag_retriever = None + try: + import sys + if 'app' in sys.modules: + app_module = sys.modules['app'] + rag_retriever = getattr(app_module, 'rag_retriever', None) + except: + pass # No RAG retriever available + + conn = AgentLLMConnector( + api_key=cast(str, cfg.get("api_key")), + skills=skill_objs, + allow_fallback=allow_fallback, + trusted_links=trusted_links, + grounded_files=grounded_files, + rag_retriever=rag_retriever + ) + model = conn.agent_model_mapping.get(cfg.get("agent_type", ""), "gpt-5-mini") + + # Build enhanced system message with tool-specific guidance + system_content = f"You are {cfg.get('agent_name', 'Agent')}. {cfg.get('agent_mission', '')}" + + # Add general concise response guidelines for child agents + system_content += ( + "\n\nRESPONSE GUIDELINES:\n" + "- Be concise and clinically focused\n" + "- Provide clear, actionable recommendations\n" + "- Avoid excessive reasoning or explanation unless specifically requested\n" + "- Structure responses with clear sections when appropriate\n" + "- Use bullet points or numbered lists for multiple recommendations" + ) + + # Add specific guidance for IPC reporting + has_ipc_reporting = any(skill.name == "IPC_reporting" for skill in skill_objs) + if has_ipc_reporting: + system_content += ( + "\n\nWhen users ask about reportable diseases, reporting requirements, or infection control reporting, " + "always offer to help with the specific reporting process. Use the IPC_reporting tool to provide " + "jurisdiction-specific requirements and generate formatted reports. " + "IMPORTANT: When calling IPC_reporting, include ALL conversation context in the case_summary parameter, " + "especially the specific organism/pathogen mentioned by the user (e.g., 'User asked about typhus fever reporting')." + ) + + system_msg = {"role": "system", "content": system_content} + from collections import deque + recent = deque(history, maxlen=MAX_HISTORY) + history_msgs = [ {"role": m["role"], "content": m["content"]} for m in recent if m["role"] in ("user", "assistant")] + messages = [system_msg] + history_msgs + history.append({"role": "assistant", "content": ""}) + buf = "" + # This is a sync generator for child agents; in real use, adapt to async if needed + # Fix: call the async generator and iterate with asyncio + import asyncio + async def run_stream(): + async for token in conn.chat_with_agent_stream(model_name=model, messages=messages): + yield token + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + gen = run_stream() + try: + while True: + token = loop.run_until_complete(gen.__anext__()) + buf += token + history[-1]["content"] = buf + invocation_log = build_log(conn) + yield history, "", invocation_log, active_children, None + except StopAsyncIteration: + pass + history[-1]["content"] = buf.strip() + invocation_log = build_log(conn) + yield history, "", invocation_log, active_children, None + +# --- Main async chat orchestrator --- +async def simulate_agent_response_stream(agent_json, history, user_input, debug_flag, active_children): + """ + Streams agent replies with sliding window, multi-agent routing, + and invocation logging under orchestrator flows. + Yields: history, cleared input, invocation log, active_children, challenger_info. + """ + if not agent_json or not agent_json.strip(): + history.append({"role": "assistant", "content": "⚠️ No agent configuration found. Please Generate or Load an agent first."}) + yield history, "", "", active_children, None + return + + try: + cfg = json.loads(agent_json) + except json.JSONDecodeError: + history.append({"role": "assistant", "content": "⚠️ Invalid agent configuration. Please regenerate or reload the agent."}) + yield history, "", "", active_children, None + return + + name = cfg.get("agent_name", "Agent") + mission = cfg.get("agent_mission", "") + agent_type = cfg.get("agent_type", "") + + if not history: + history.append({"role": "assistant", "content": f"πŸ‘‹ Hello! I'm {name}. How can I assist today?"}) + yield history, "", "", active_children, None + + # Add user message to history and display it immediately + history.append({"role": "user", "content": user_input}) + yield history, "", "", active_children, None + + if agent_type == "🎼 Orchestrator": + try: + from core.agents.orchestrator import OrchestratorAgent + global orchestrators + name = cfg.get("agent_name", "orchestrator") + + # Create a fresh orchestrator instance for new conversations, + # but reuse existing instance to maintain state for execution + orch = orchestrators.get(name) + if orch is None: + # Import the runtime agents_config from app.py where it's dynamically updated + import sys + if 'app' in sys.modules: + # Get the runtime agents_config that contains all deployed agents + app_module = sys.modules['app'] + runtime_agents_config = getattr(app_module, 'agents_config', {}) + else: + # Fallback to config if app module not available + from config import agents_config as runtime_agents_config + + # Create new orchestrator instance only if none exists + orch = OrchestratorAgent(runtime_agents_config, cast(str, cfg.get("api_key", ""))) + orchestrators[name] = orch + + history.append({"role": "assistant", "content": ""}) + orch_agent = cast(OrchestratorAgent, orch) + answer_gen = orch_agent.answer(history, user_input, debug=debug_flag) + async for msg in answer_gen: + if isinstance(msg, dict): + chunk = msg.get("content", "") + if chunk: + history[-1]["content"] += chunk + else: + history[-1]["content"] += str(msg) + invocation_log = build_log(orch) if hasattr(orch, 'invocations') and isinstance(orch, OrchestratorAgent) else "" + yield history, "", invocation_log, active_children, None + except ImportError: + history.append({"role": "assistant", "content": "Orchestrator not available."}) + yield history, "", "", active_children, None + return + + if active_children: + for child_json in active_children: + child_cfg = json.loads(child_json) + for output in _stream_to_agent(child_cfg, history, user_input, debug_flag, active_children): + yield output + return + + skill_objs = [] + if cfg.get("web_access", False): + web_tool = get_tool_by_name("search_internet", {"user_query": user_input}) + if web_tool: + skill_objs.append(web_tool) + for skill_name in cfg.get("skills", []): + tool = get_tool_by_name(skill_name, {"user_query": user_input}) + if tool: + skill_objs.append(tool) + + # Pass allow_fallback, trusted_links, grounded_files to AgentLLMConnector + allow_fallback = cfg.get("allow_fallback", True) + trusted_links = cfg.get("trusted_links", []) + grounded_files = cfg.get("grounded_files", []) + + # Get global RAG retriever if available + rag_retriever = None + try: + import sys + if 'app' in sys.modules: + app_module = sys.modules['app'] + rag_retriever = getattr(app_module, 'rag_retriever', None) + except: + pass # No RAG retriever available + + conn = AgentLLMConnector( + api_key=cast(str, cfg.get("api_key", "")), + skills=skill_objs, + allow_fallback=allow_fallback, + trusted_links=trusted_links, + grounded_files=grounded_files, + rag_retriever=rag_retriever + ) + model = conn.agent_model_mapping.get(agent_type, "gpt-5-mini") + has_history_tool = any(t.name == "history_taking" for t in skill_objs) + has_ipc_reporting = any(t.name == "IPC_reporting" for t in skill_objs) + + if agent_type == "πŸ₯ Clinical Assistant" and has_history_tool: + system_content = ( + f"You are {name}. {mission}\n\n" + "Before giving any advice, gather all necessary patient history by calling the " + "`history_taking` function. A JSON-schema has been provided with each question as a " + "parameter description. Ask wauestions, wait for the user's answer, and only " + "once every required field is filled will you then provide your final recommendation.\n\n" + "RESPONSE FORMAT: Keep responses concise and clinical. Avoid lengthy explanations unless specifically asked. " + "Focus on actionable recommendations and key clinical points." + ) + else: + system_content = f"You are {name}." + (f" {mission}" if mission else "") + + # Add general instruction to keep responses focused and concise + system_content += ( + "\n\nRESPONSE GUIDELINES:\n" + "- Be concise and clinically focused\n" + "- Provide clear, actionable recommendations\n" + "- Avoid excessive reasoning or explanation unless specifically requested\n" + "- Structure responses with clear sections when appropriate\n" + "- Use bullet points or numbered lists for multiple recommendations" + ) + + # Add specific guidance for IPC reporting + if has_ipc_reporting: + system_content += ( + "\n\nWhen users ask about reportable diseases, reporting requirements, or infection control reporting, " + "always offer to help with the specific reporting process. Use the IPC_reporting tool to provide " + "jurisdiction-specific requirements and generate formatted reports. " + "IMPORTANT: When calling IPC_reporting, include ALL conversation context in the case_summary parameter, " + "especially the specific organism/pathogen mentioned by the user (e.g., 'User asked about typhus fever reporting')." + ) + + system_msg = {"role": "system", "content": system_content} + from collections import deque + recent = deque(history, maxlen=MAX_HISTORY) + history_msgs = [ {"role": m["role"], "content": m["content"]} for m in recent if m["role"] in ("user", "assistant")] + messages = [system_msg] + history_msgs + history.append({"role": "assistant", "content": ""}) + buf = "" + tool_invoked = False + + async for token in conn.chat_with_agent_stream(model_name=model, messages=messages): + buf += token + history[-1]["content"] = buf + invocation_log = build_log(conn) + # Detect if a tool was invoked (by tool name in the reply) + for tool in skill_objs: + if tool.name in buf: + tool_invoked = True + yield history, "", invocation_log, active_children, None + + # Apply clinical validation and references to the final reply + original_reply = validate_and_reference_recommendation(buf.strip()) + + # --- Challenger step: adversarial critique if enabled --- + + challenger_enabled = cfg.get("challenger_enabled", False) + critique = None + final_reply = original_reply + + # --- Only run challenger if the required fields for the tool actually invoked are present, or if FORCE_CHALLENGE is present --- + def required_fields_present_for_invoked_tool(): + # Try to infer which tool was actually invoked (last tool in skill_objs with a matching name in the reply) + from core.utils.skills_registry import get_tool_by_name + invoked_tool = None + for skill_name in cfg.get("skills", []): + if skill_name in original_reply: + invoked_tool = get_tool_by_name(skill_name, {"user_query": user_input}) + break + if not invoked_tool and skill_objs: + invoked_tool = skill_objs[-1] # fallback: last tool + if not invoked_tool: + return True # fallback: allow + required_fields = invoked_tool.args_schema.get("required", []) + if not required_fields: + return True + for field in required_fields: + found = False + for m in history[::-1]: + if m["role"] == "user" and (field.replace("_", " ") in m["content"].lower() or field in m["content"].lower()): + found = True + break + if not found: + return False + return True + + force_challenge = "FORCE_CHALLENGE" in user_input or "FORCE_CHALLENGE" in original_reply + + # Always run challenger if enabled (or forced) + if challenger_enabled or force_challenge: + try: + from core.utils.llm_connector import challenge_agent_response, refine_final_answer + user_message = user_input + agent_reply = original_reply + # Pass conversation history to challenger for better context awareness + critique = await challenge_agent_response(user_message, agent_reply, history) + # If critique is None or empty, treat as OK + if not critique or critique.strip().upper() == "OK": + critique = "OK" + final_reply = original_reply + else: + # Use a refiner LLM to produce a clean, user-facing answer + final_reply = await refine_final_answer(user_message, original_reply, critique) + except Exception as e: + critique = f"[Challenger error: {e}]" + final_reply = original_reply + + history[-1]["content"] = final_reply + invocation_log = build_log(conn) + yield history, "", invocation_log, active_children, { + "original_reply": original_reply, + "challenger_critique": critique, + "final_reply": final_reply + } diff --git a/core/agents/orchestrator.py b/core/agents/orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..ccc4c406c0ceb87251d422c59230dd4a21694f95 --- /dev/null +++ b/core/agents/orchestrator.py @@ -0,0 +1,670 @@ +""" +orchestrator.py +--------------- + +Coordinates multi-agent planning, tool invocation, if context_results: + # Replanning m system = ( + "You are an orchestration assistant. A clinician's request may contain multiple distinct tasks " + "(e.g. antibiotic choice, duration, isolation, orders, literature search, quiz). " + "Here are your available agents:\n\n" + f"{json.dumps(avail, indent=2)}\n\n" + "Return *only* a JSON array of objects, each with exactly three keys:\n" + " β€’ action: always \"invoke\"\n" + " β€’ agent: one of the agent names above\n" + " β€’ prompt: the exact question to send to that agent\n" + "Do not include any extra text.\n\n" + "RESPONSE GUIDELINES:\n" + "- Be concise and clinically focused\n" + "- Provide clear, actionable recommendations\n" + "- Avoid excessive reasoning or explanation unless specifically requested" + ) system = ( + "You are an expert replanning agent. Based on intermediate results, create an updated " + "execution plan that adapts to new information while maintaining original objectives.\n\n" + "Focus on:\n" + "1. Incorporating lessons from completed phases\n" + "2. Adjusting remaining phases based on i summary = await call_llm( + [{"role": "user", "content": synthesis_prompt}], + model=SUMMARY_MODEL + )ediate findings\n" + "3. Optimizing parallel execution for efficiency\n" + "4. Setting new replanning conditions\n\n" + f"Available agents:\n{json.dumps(avail, indent=2)}\n\n" + f"Context from previous phases:\n{json.dumps(context_results, indent=2)}\n\n" + "Return updated plan in enhanced format (JSON):\n" + "{\n" + ' "execution_strategy": "description",\n' + ' "phases": [{"phase_id": "id", "description": "desc", "parallel_tasks": [{"task_id": "id", "agent": "name", "prompt": "task", "priority": "high", "dependencies": []}]}],\n' + ' "replanning_conditions": [{"condition": "desc", "trigger_phase": "id", "evaluation_prompt": "prompt"}]\n' + '}' + ) + else: + # Initial planning mode + system = ( + "You are an expert planning agent for an Infectious Disease consultation system. " + "Create sophisticated execution plans with parallel agent coordination and dynamic replanning.\n\n" + f"Available agents:\n{json.dumps(avail, indent=2)}\n\n" + "Output format (JSON):\n" + "{\n" + ' "execution_strategy": "description of approach",\n' + ' "phases": [\n' + ' {\n' + ' "phase_id": "phase_1",\n' + ' "description": "phase description",\n' + ' "parallel_tasks": [\n' + ' {\n' + ' "task_id": "task_1",\n' + ' "agent": "agent_name",\n' + ' "prompt": "specific task",\n' + ' "priority": "high|medium|low",\n' + ' "dependencies": ["task_id"]\n' + ' }\n' + ' ]\n' + ' }\n' + ' ],\n' + ' "replanning_conditions": [\n' + ' {\n' + ' "condition": "description",\n' + ' "trigger_phase": "phase_id",\n' + ' "evaluation_prompt": "LLM prompt for evaluation"\n' + ' }\n' + ' ]\n' + '}' + )se synthesis for the AI agent system. + +- Defines the OrchestratorAgent class, which manages agent selection, planning, and execution. +- Handles user input decomposition, tool invocation, and error handling. +- Integrates with OpenAI and the tool registry for dynamic agent workflows. +- Provides robust logging, request tracking, and user-friendly error messages. +- Designed for future compatibility with Model Context Protocol (MCP) and Agent-to-Agent (A2A) standards. + +""" + +# orchestrator.py + +import os +import json +import re +import asyncio +from typing import Any, Dict, List, Optional +from openai import OpenAI +from core.utils.llm_connector import AgentLLMConnector, call_llm +from core.utils.skills_registry import get_tool_by_name + +GREETING_RE = re.compile(r"^(hi|hello|hey|good\s(morning|afternoon|evening))\b", re.I) +PLANNER_MODEL = "gpt-5" # Use GPT-5 for planning +SUMMARY_MODEL = "gpt-5" # Use GPT-5 for summaries + +import uuid +import datetime + +class OrchestratorAgent: + """ + The OrchestratorAgent coordinates multiple AI agent tools to fulfill complex user requests. + + Attributes: + cfg (Dict[str, str]): Configuration for available agents. + client (OpenAI): OpenAI client for LLM calls. + state (dict): Tracks the current plan, subtask progress, and results. + invocations (List[Dict[str, Any]]): Audit log of tool invocations and results. + """ + def __init__(self, agents_cfg: Dict[str, str], api_key: str): + """ + Initialize the OrchestratorAgent. + + Args: + agents_cfg (Dict[str, str]): Configuration for available agents. + api_key (str): API key for OpenAI. + """ + self.cfg = agents_cfg + self.client = OpenAI(api_key=api_key) + self.state = { + "plan": [], # list of {"action","agent","prompt","priority","dependencies"} + "current_phase": 0, # which execution phase we're on + "subtask_convos": {}, # maps step index -> convo so far + "results": {}, # agent_key -> [final outputs] + "intermediate_results": {}, # step_id -> intermediate results for replanning + "execution_phases": [], # list of phases with parallel execution groups + "replanning_triggers": [] # conditions that trigger replanning + } + self.invocations: List[Dict[str,Any]] = [] + + async def planning_agent(self, user_input: str, context_results: Optional[Dict] = None) -> Dict: + """ + Enhanced planning agent that creates parallel execution plans with replanning conditions. + + Args: + user_input (str): The user's request or question. + context_results (Optional[Dict]): Context from previous phases for replanning. + + Returns: + Dict: Enhanced plan with phases, parallel tasks, and replanning conditions. + """ + agent_keys = list(self.cfg.keys()) + avail = [] + history_agent = None + for name in agent_keys: + cfg = json.loads(self.cfg[name]) + avail.append({ + "name": name, + "mission": cfg.get("agent_mission",""), + "skills": cfg.get("skills",[]) + }) + if history_agent is None and "history_taking" in cfg.get("skills", []): + history_agent = name + + system = ( + "You are an orchestration assistant. A clinician’s request may contain multiple distinct tasks " + "(e.g. antibiotic choice, duration, isolation, orders, literature search, quiz). " + "Here are your available agents:\n\n" + f"{json.dumps(avail, indent=2)}\n\n" + "Return *only* a JSON array of objects, each with exactly three keys:\n" + " β€’ action: always \"invoke\"\n" + " β€’ agent: one of the agent names above\n" + " β€’ prompt: the exact question to send to that agent\n" + "Do not include any extra text." + ) + # **critical fix**: pass the model name + plan_str = await call_llm( + [{"role":"system","content":system}, + {"role":"user","content":user_input}], + model=PLANNER_MODEL + ) + self.invocations.append({ + "tool":"enhanced_planning_agent", + "args":{"user_input":user_input, "context_results": context_results}, + "result":plan_str + }) + + try: + plan = json.loads(plan_str) + + # Validate and convert plan format if needed + if isinstance(plan, list): + # Old format: convert list to enhanced dictionary format + enhanced_plan = { + "execution_strategy": "sequential_legacy", + "phases": [{ + "phase_id": "phase_1", + "description": "Legacy sequential execution", + "parallel_tasks": [] + }], + "replanning_conditions": [] + } + + # Convert each list item to a parallel task + for i, task in enumerate(plan): + if isinstance(task, dict) and "agent" in task: + enhanced_plan["phases"][0]["parallel_tasks"].append({ + "task_id": f"task_{i+1}", + "agent": task.get("agent", "stewardship_agent"), + "prompt": task.get("prompt", user_input), + "priority": "medium", + "dependencies": [] + }) + + return enhanced_plan + + elif isinstance(plan, dict): + # Enhanced format: validate required keys + if "phases" not in plan: + plan["phases"] = [self._create_default_phase(user_input, avail, history_agent)] + if "execution_strategy" not in plan: + plan["execution_strategy"] = "enhanced_parallel" + if "replanning_conditions" not in plan: + plan["replanning_conditions"] = [] + + return plan + + else: + # Invalid format: use fallback + return self._create_fallback_plan(user_input, avail, history_agent) + + except json.JSONDecodeError: + # Fallback to simple plan + return self._create_fallback_plan(user_input, avail, history_agent) + + def _create_fallback_plan(self, user_input: str, avail: List[Dict], history_agent: Optional[str] = None) -> Dict[str, Any]: + """Create a simple fallback plan when advanced planning fails.""" + fallback_plan = { + "execution_strategy": "sequential", + "phases": [{ + "phase_id": 1, + "description": "Fallback sequential execution", + "parallel_tasks": [] + }], + "replanning_conditions": [], + "success_criteria": "Complete all agent responses" + } + + # Add history agent first if available + if history_agent: + fallback_plan["phases"][0]["parallel_tasks"].append({ + "task_id": "history_task", + "action": "invoke", + "agent": history_agent, + "prompt": "Gather necessary patient history before proceeding", + "priority": "high", + "estimated_duration": "medium", + "dependencies": [], + "replanning_triggers": [] + }) + + # Add a general agent task + if avail: + default_agent = avail[0]["name"] + fallback_plan["phases"][0]["parallel_tasks"].append({ + "task_id": "main_task", + "action": "invoke", + "agent": default_agent, + "prompt": user_input, + "priority": "high", + "estimated_duration": "medium", + "dependencies": ["history_task"] if history_agent else [], + "replanning_triggers": [] + }) + + return fallback_plan + + def _validate_and_organize_plan(self, plan: Dict[str, Any], agent_keys: List[str], history_agent: Optional[str] = None) -> Dict[str, Any]: + """Validate and organize the enhanced plan.""" + validated_plan = { + "execution_strategy": plan.get("execution_strategy", "sequential"), + "phases": [], + "replanning_conditions": plan.get("replanning_conditions", []), + "success_criteria": plan.get("success_criteria", "Complete all tasks successfully") + } + + # Validate each phase + for phase in plan.get("phases", []): + validated_phase = { + "phase_id": phase.get("phase_id", 1), + "description": phase.get("description", "Execution phase"), + "parallel_tasks": [] + } + + # Validate each task in the phase + for task in phase.get("parallel_tasks", []): + if (task.get("agent") in agent_keys and + task.get("action") == "invoke" and + isinstance(task.get("prompt"), str)): + + validated_task = { + "task_id": task.get("task_id", f"task_{len(validated_phase['parallel_tasks'])}"), + "action": "invoke", + "agent": task["agent"], + "prompt": task["prompt"], + "priority": task.get("priority", "medium"), + "estimated_duration": task.get("estimated_duration", "medium"), + "dependencies": task.get("dependencies", []), + "replanning_triggers": task.get("replanning_triggers", []) + } + validated_phase["parallel_tasks"].append(validated_task) + + if validated_phase["parallel_tasks"]: + validated_plan["phases"].append(validated_phase) + + # Ensure we have at least one phase + if not validated_plan["phases"]: + validated_plan = self._create_fallback_plan("", [{"name": list(agent_keys)[0] if agent_keys else "default"}], history_agent) + + return validated_plan + + async def _execute_parallel_tasks(self, tasks: List[Dict[str, Any]], history: List[Dict]) -> Dict[str, Any]: + """Execute a group of tasks in parallel.""" + import asyncio + from core.utils.llm_connector import AgentLLMConnector + from core.utils.skills_registry import get_tool_by_name + + # Create coroutines for each task + task_coroutines = [] + task_ids = [] + + for task in tasks: + # Check dependencies + dependencies_met = True + for dep in task.get("dependencies", []): + if dep not in self.state["intermediate_results"]: + dependencies_met = False + break + + if not dependencies_met: + continue # Skip this task for now + + task_ids.append(task["task_id"]) + task_coroutines.append(self._execute_single_task(task, history)) + + # Execute tasks in parallel + if task_coroutines: + results = await asyncio.gather(*task_coroutines, return_exceptions=True) + + # Process results + phase_results = {} + for i, result in enumerate(results): + task_id = task_ids[i] + if isinstance(result, Exception): + phase_results[task_id] = {"error": str(result), "success": False} + else: + phase_results[task_id] = {"result": result, "success": True} + # Store for potential replanning + self.state["intermediate_results"][task_id] = result + + return phase_results + + return {} + + async def _execute_single_task(self, task: Dict[str, Any], history: List[Dict]) -> str: + """Execute a single agent task.""" + from core.utils.llm_connector import AgentLLMConnector + from core.utils.skills_registry import get_tool_by_name + + agent_key = task["agent"] + prompt = task["prompt"] + + # Get agent configuration + cfg = json.loads(self.cfg[agent_key]) + skills = [get_tool_by_name(s, {"user_query": prompt}) for s in cfg.get("skills", [])] + skills = [t for t in skills if t] + + # Create agent connector + child = AgentLLMConnector(api_key=self.client.api_key, skills=skills) + + # Prepare messages + sys_msg = {"role": "system", "content": f"You are {cfg['agent_name']}, a {cfg['agent_type']} agent."} + user_msg = {"role": "user", "content": prompt} + + # Execute and collect response + response_buffer = "" + async for token in child.chat_with_agent_stream( + model_name=child.agent_model_mapping[cfg["agent_type"]], + messages=[sys_msg, user_msg] + ): + response_buffer += token + + return response_buffer.strip() + + async def _execute_single_task_with_timeout(self, task: Dict[str, Any], history: List[Dict], timeout: int = 45) -> Dict[str, Any]: + """Execute a single task with timeout and simplified error handling.""" + import asyncio + + try: + # Execute with timeout + result = await asyncio.wait_for( + self._execute_single_task_fast(task, history), + timeout=timeout + ) + return {"success": True, "result": result} + except asyncio.TimeoutError: + return {"success": False, "error": f"Task timed out after {timeout} seconds"} + except Exception as e: + return {"success": False, "error": str(e)} + + async def _execute_single_task_fast(self, task: Dict[str, Any], history: List[Dict]) -> str: + """Fast execution with minimal tool usage.""" + from core.utils.llm_connector import AgentLLMConnector + + agent_key = task["agent"] + prompt = task["prompt"] + + # Get agent configuration + cfg = json.loads(self.cfg[agent_key]) + + # Use simplified agent with minimal tools for speed + child = AgentLLMConnector(api_key=self.client.api_key, skills=[]) + + # Add concise response guidelines + system_content = f"You are {cfg['agent_name']}, a {cfg['agent_type']} agent. {cfg.get('agent_mission', '')}" + system_content += ( + "\n\nRESPONSE GUIDELINES:\n" + "- Be concise and clinically focused\n" + "- Provide clear, actionable recommendations\n" + "- Avoid excessive reasoning or explanation\n" + "- Structure responses with bullet points when appropriate\n" + "- Maximum 300 words" + ) + + sys_msg = {"role": "system", "content": system_content} + user_msg = {"role": "user", "content": prompt} + + # Execute and collect response + response_buffer = "" + async for token in child.chat_with_agent_stream( + model_name=child.agent_model_mapping.get(cfg["agent_type"], "gpt-4o-mini"), + messages=[sys_msg, user_msg] + ): + response_buffer += token + + return response_buffer.strip()[:2000] # Limit response length + + async def _generate_updated_synthesis(self, new_context: str, results: Dict) -> str: + """Generate updated synthesis incorporating new user context.""" + from core.utils.llm_connector import call_llm + + synthesis_prompt = ( + "You are an expert clinical synthesizer. The user has provided additional context to an existing clinical recommendation. " + "Create a focused update that incorporates this new information.\n\n" + f"New context from user: {new_context}\n\n" + f"Previous recommendations: {json.dumps(results, indent=2)}\n\n" + "RESPONSE GUIDELINES:\n" + "- Focus specifically on how the new context changes recommendations\n" + "- Be concise and clinically focused\n" + "- Provide clear, actionable updates\n" + "- Use bullet points for specific changes\n" + "- Maximum 400 words\n\n" + "Provide only the updated/modified recommendations based on the new context:" + ) + + try: + updated_response = await call_llm( + [{"role": "user", "content": synthesis_prompt}], + model="gpt-4o-mini" + ) + return updated_response + except Exception as e: + return f"Unable to generate updated synthesis: {str(e)}" + + async def _check_replanning_conditions(self, phase_results: Dict[str, Any], current_phase: Dict[str, Any]) -> bool: + """Check if replanning is needed based on intermediate results.""" + # Check global replanning conditions + for condition in self.state.get("replanning_conditions", []): + if await self._evaluate_condition(condition, phase_results): + return True + + # Check task-specific replanning triggers + for task in current_phase.get("parallel_tasks", []): + task_id = task["task_id"] + if task_id in phase_results: + result = phase_results[task_id] + for trigger in task.get("replanning_triggers", []): + if await self._evaluate_trigger(trigger, result): + return True + + return False + + async def _evaluate_condition(self, condition: str, results: Dict[str, Any]) -> bool: + """Evaluate a replanning condition using LLM.""" + evaluation_prompt = ( + f"Evaluate if this condition is met based on the results:\n" + f"Condition: {condition}\n" + f"Results: {json.dumps(results, indent=2)}\n" + f"Return only 'true' or 'false'." + ) + + response = await call_llm( + [{"role": "user", "content": evaluation_prompt}], + model="gpt-5-mini" + ) + + return response.strip().lower() == "true" + + async def _evaluate_trigger(self, trigger: str, result: Dict[str, Any]) -> bool: + """Evaluate a specific replanning trigger.""" + evaluation_prompt = ( + f"Evaluate if this trigger condition is met:\n" + f"Trigger: {trigger}\n" + f"Task Result: {json.dumps(result, indent=2)}\n" + f"Return only 'true' or 'false'." + ) + + response = await call_llm( + [{"role": "user", "content": evaluation_prompt}], + model="gpt-5-mini" + ) + + return response.strip().lower() == "true" + + def _create_default_phase(self, user_input, avail, history_agent): + """Create a default phase when validation fails.""" + return { + "phase_id": "default_phase", + "description": "Default sequential execution", + "parallel_tasks": [{ + "task_id": "default_task", + "agent": "stewardship_agent", + "prompt": user_input, + "priority": "medium", + "dependencies": [] + }] + } + + import uuid + import datetime + async def answer(self, history, user_input, debug=False): + """ + Enhanced main entry point with parallel execution and dynamic replanning. + + Args: + history (list): Conversation history for context. + user_input (str): The user's request or question. + debug (bool): If True, enables debug output. + + Yields: + dict: Assistant responses, including streaming output and error messages. + """ + from tools.utils import ToolExecutionError, logger + request_id = str(uuid.uuid4()) + request_time = datetime.datetime.utcnow().isoformat() + + try: + if GREETING_RE.match(user_input.strip()): + logger.info(f"[request_id={request_id}] Greeting detected. user_input={user_input!r}") + yield {"role":"assistant","content":"πŸ‘‹ Hello! How can I help?"} + return + + # Enhanced planning with parallel execution support + if not self.state.get("execution_phases"): + logger.info(f"[request_id={request_id}] Starting enhanced planning...") + enhanced_plan = await self.planning_agent(user_input) + self.state["execution_phases"] = enhanced_plan["phases"] + self.state["replanning_conditions"] = enhanced_plan["replanning_conditions"] + + logger.info(f"[request_id={request_id}] Enhanced plan created with {len(enhanced_plan['phases'])} phases") + yield {"role":"assistant", "content":f"🎯 **Enhanced Execution Plan ({enhanced_plan['execution_strategy']}):**\n```json\n{json.dumps(enhanced_plan, indent=2)}\n```\n\n**Ready to execute!** Say 'proceed' to start execution or provide additional context."} + return + + # Check if user wants to proceed with execution + proceed_keywords = ["proceed", "execute", "start", "go", "continue", "run"] + if any(keyword in user_input.lower() for keyword in proceed_keywords): + # Fast execution mode - execute tasks sequentially with timeout + yield {"role":"assistant", "content":"πŸš€ **Executing plan...**"} + + all_results = [] + total_tasks = sum(len(phase["parallel_tasks"]) for phase in self.state["execution_phases"]) + completed_tasks = 0 + + for phase in self.state["execution_phases"]: + for task in phase["parallel_tasks"]: + completed_tasks += 1 + yield {"role":"assistant", "content":f"⚑ **Task {completed_tasks}/{total_tasks}:** {task['agent']} - {task['prompt'][:100]}..."} + + # Execute task with timeout + try: + result = await self._execute_single_task_with_timeout(task, history, timeout=45) + if result.get("success"): + all_results.append({ + "agent": task["agent"], + "result": result["result"] + }) + yield {"role":"assistant", "content":f"βœ… **{task['agent']} completed**"} + else: + yield {"role":"assistant", "content":f"⚠️ **{task['agent']} failed** - {result.get('error', 'Unknown error')}"} + except Exception as e: + yield {"role":"assistant", "content":f"⚠️ **{task['agent']} timeout/error** - {str(e)[:100]}"} + + # Mark all phases as complete + self.state["current_phase"] = len(self.state["execution_phases"]) + self.state["results"] = {result["agent"]: [result["result"]] for result in all_results} + else: + # User provided additional context - update the plan to incorporate it + yield {"role":"assistant", "content":"πŸ“ **Processing additional context...**"} + + # Check if execution is complete and user wants updated synthesis + if self.state["current_phase"] >= len(self.state["execution_phases"]) and self.state.get("results"): + # Generate updated synthesis with new context + updated_synthesis = await self._generate_updated_synthesis(user_input, self.state["results"]) + yield {"role":"assistant", "content":f"οΏ½ **Updated recommendation incorporating your context:**\n\n{updated_synthesis}"} + return + + # If execution not started, update the plan with new context + if not self.state.get("results"): + # Add context to existing plan + self.state["additional_context"] = self.state.get("additional_context", []) + self.state["additional_context"].append(user_input) + + # Update task prompts to include new context + context_update = f"Additional context: {user_input}" + for phase in self.state["execution_phases"]: + for task in phase["parallel_tasks"]: + if not task["prompt"].endswith(context_update): + task["prompt"] += f"\n\n{context_update}" + + yield {"role":"assistant", "content":"βœ… **Context incorporated into execution plan.** Say 'proceed' to start execution with updated context."} + else: + yield {"role":"assistant", "content":"πŸ“ **Additional context noted.** Say 'proceed' to continue or provide more details."} + return + + # Enhanced synthesis with intermediate results + all_results = { + "agent_results": self.state["results"], + "intermediate_results": self.state["intermediate_results"], + "execution_summary": { + "total_phases": len(self.state["execution_phases"]), + "parallel_execution": True, + "replanning_occurred": len(self.state.get("replanning_conditions", [])) > 0 + } + } + + synthesis_prompt = ( + "You are an expert clinical synthesizer. Create a comprehensive, actionable response by combining:\n" + "1. All agent outputs into coherent clinical recommendations\n" + "2. Highlight any conflicting advice and provide resolution\n" + "3. Prioritize recommendations by clinical urgency\n" + "4. Include specific next steps for the clinician\n\n" + "RESPONSE GUIDELINES:\n" + "- Be concise and clinically focused\n" + "- Provide clear, actionable recommendations\n" + "- Avoid excessive reasoning or explanation unless specifically requested\n" + "- Structure responses with clear sections when appropriate\n" + "- Use bullet points or numbered lists for multiple recommendations\n\n" + f"Results to synthesize:\n{json.dumps(all_results, indent=2)}" + ) + + summary = await call_llm( + [{"role":"system","content": synthesis_prompt}], + model=SUMMARY_MODEL + ) + + logger.info(f"[request_id={request_id}] Enhanced synthesis complete. Summary length: {len(summary)}") + yield {"role":"assistant","content":f"🎯 **Comprehensive Clinical Recommendation:**\n\n{summary}"} + + except ToolExecutionError as te: + logger.error(f"[request_id={request_id}] ToolExecutionError [{te.code}]: {te.message} | user_input={user_input!r}") + yield { + "role": "assistant", + "content": f"❗ Error ({te.code}) [Request ID: {request_id} | {request_time}]: {te.user_message}" + } + except Exception as e: + logger.exception(f"[request_id={request_id}] Unexpected error in enhanced orchestrator | user_input={user_input!r}") + yield { + "role": "assistant", + "content": f"❗ An unexpected error occurred in enhanced orchestrator. [Request ID: {request_id} | {request_time}] Please try again or contact support." + } diff --git a/core/config/settings.py b/core/config/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/core/ui/__init__.py b/core/ui/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a22cb2fefea1a676548f11249defd81de96d174a --- /dev/null +++ b/core/ui/__init__.py @@ -0,0 +1 @@ +# UI components for the ID Agents application diff --git a/core/ui/ui.py b/core/ui/ui.py new file mode 100644 index 0000000000000000000000000000000000000000..baa325040beaf8131b9aeeac9ae24bdebe43d389 --- /dev/null +++ b/core/ui/ui.py @@ -0,0 +1,111 @@ +import gradio as gr +import json +from core.agents.agent_utils import build_agent, preload_demo_chat, prepare_download, load_prefilled +from core.agents.chat_orchestrator import simulate_agent_response_stream +import logging +from config import agents_config, skills_library, prefilled_agents + +def show_landing(): + return gr.update(visible=True), gr.update(visible=False), gr.update(visible=False) + +def show_builder(): + return gr.update(visible=False), gr.update(visible=True), gr.update(visible=False) + +def show_chat(): + return gr.update(visible=False), gr.update(visible=False), gr.update(visible=True) + +def build_active_agents_markdown_and_dropdown(): + if not agents_config: + return "### 🧠 Active Agents\n_(None yet)_", [] + output = "### 🧠 Active Agents\n" + dropdown_choices = [] + for name, agent_json in agents_config.items(): + agent_type = json.loads(agent_json)["agent_type"] + output += f"- {name} ({agent_type})\n" + dropdown_choices.append(name) + return output, dropdown_choices + +def refresh_active_agents_widgets(): + md, dd = build_active_agents_markdown_and_dropdown() + return gr.update(value=md), gr.update(choices=dd, value=None) + +# All other UI callback functions from app.py +def build_ui(): + def _convert_history_for_gradio(history): + # Convert OpenAI-style history to Gradio Chatbot format: [(user, assistant), ...] + result = [] + last_user = None + for msg in history: + if msg["role"] == "user": + last_user = msg["content"] + elif msg["role"] == "assistant": + if last_user is not None: + result.append((last_user, msg["content"])) + last_user = None + else: + # orphan assistant message (e.g. greeting) + result.append((None, msg["content"])) + if last_user is not None: + result.append((last_user, None)) + return result + + # --- App Layout --- + with gr.Blocks() as app: + chat_histories = gr.State({}) + with gr.Row(): + with gr.Column(scale=3): + # Use unique elem_id for each chatbox + builder_chatbox = gr.Chatbot(elem_id="builder-chatbox") + # ...other chat UI elements (inputs, buttons, etc.)... + with gr.Column(scale=3): + # If you have a deployed chatbox elsewhere, ensure it also uses a unique elem_id, e.g., "deployed-chatbox" + deployed_chatbox = gr.Chatbot(elem_id="deployed-chatbox") + with gr.Column(scale=1): + gr.HTML( + """ +
+ +
+ """, + elem_id="sidebar-html" + ) + + # Add a shared class to all chatboxes by elem_id, then intercept links for all with that class + gr.HTML( + """ + + """, + elem_id="sidebar-js" + ) + # ...rest of your UI and callback wiring... + return app +""" +ui.py +------ +Gradio UI layout, callbacks, and event wiring for the modular AI agent system. +""" diff --git a/core/utils/__init__.py b/core/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..72a585d8bd838be67d6ad263acef5052215149ce --- /dev/null +++ b/core/utils/__init__.py @@ -0,0 +1 @@ +# Utility modules for the ID Agents application diff --git a/core/utils/llm_connector.py b/core/utils/llm_connector.py new file mode 100644 index 0000000000000000000000000000000000000000..0956324c31708c007ce36873fcd04e95c876643b --- /dev/null +++ b/core/utils/llm_connector.py @@ -0,0 +1,467 @@ +async def challenge_agent_response(user_message: str, agent_reply: str, conversation_history: list = None, challenge_prompt: str = None, model: str = None) -> str: + """ + Uses an LLM to adversarially critique the agent's draft reply before it is shown to the user. + Returns either the original reply, a corrected reply, or a warning if unsafe/incomplete. + + Args: + user_message: The current user input + agent_reply: The agent's draft response + conversation_history: List of previous messages for context (optional) + challenge_prompt: Optional custom prompt template + model: LLM model to use + """ + + # Skip challenger if the agent is properly asking for required information for tool calling + # These are legitimate information-gathering responses that should not be critiqued + info_gathering_patterns = [ + "I need some additional information", + "I need some specific information", + "To recommend.*I need", + "To provide.*I need", + "Please provide.*required information", + "required information.*for.*therapy", + "following required information", + "need.*following.*information", + "Could you please provide", + "Patient age.*allergies.*laboratory.*results", # Specific empiric therapy pattern + "age.*known drug allergies.*laboratory results", # Alternative pattern + "provide.*information.*and.*assist.*determining", # Tool workflow pattern + "Patient age or age bracket.*allergies.*laboratory results.*Culture.*sensitivity", # Exact empiric therapy pattern + "Please provide this information.*assist.*determining.*treatment" # Tool conclusion pattern + ] + + # Additional patterns for ongoing tool workflows that should not be critiqued + ongoing_workflow_patterns = [ + "πŸ”” Tool.*invoked", # Tool invocation indicator + "still need.*following.*information", # Continuing info gathering + "appears.*issue.*reporting.*process", # IPC reporting continuation + "ensure.*report.*completed.*correctly", # Report completion workflow + "provide.*following.*details.*clear.*format", # Structured data collection + "formatted.*correctly.*proceed.*report", # Report finalization + "Sorry.*ran into.*issue.*tool", # Tool error messages that should not be critiqued + "Could you please clarify or try again", # Tool error recovery + "No current reporting requirements found", # IPC search failures + "Unable to find current reporting requirements" # IPC search failures + ] + + # Check conversation history for ongoing tool workflows + is_ongoing_workflow = False + if conversation_history: + recent_messages = conversation_history[-10:] # Check last 10 messages + for msg in recent_messages: + if msg.get("role") == "assistant" and msg.get("content"): + content = msg["content"] + # Check if recent conversation involved tool usage + if any(pattern in content for pattern in ["πŸ”” Tool", "invoked", "reporting", "provide the following"]): + is_ongoing_workflow = True + break + + # Skip challenger for ongoing workflows unless there's a clear safety issue + if is_ongoing_workflow: + # Only challenge if there are obvious safety concerns + safety_keywords = ["dosage", "contraindicated", "allergy", "toxic", "dangerous", "fatal"] + has_safety_concern = any(keyword in agent_reply.lower() for keyword in safety_keywords) + if not has_safety_concern: + with open("challenger_debug.log", "a", encoding="utf-8") as f: + f.write("\n--- Challenger SKIPPED (Ongoing Tool Workflow) ---\n") + f.write(f"Agent reply: {agent_reply[:200]}...\n") + return "OK" + + import re + for pattern in info_gathering_patterns + ongoing_workflow_patterns: + if re.search(pattern, agent_reply, re.IGNORECASE): + # This is a legitimate info-gathering response, don't critique it + # DEBUG: Log when challenger is skipped + with open("challenger_debug.log", "a", encoding="utf-8") as f: + f.write("\n--- Challenger SKIPPED (Info Gathering Response) ---\n") + f.write(f"Pattern matched: {pattern}\n") + f.write(f"Agent reply: {agent_reply[:200]}...\n") + return "OK" + + if not challenge_prompt: + # Build context from conversation history if available + context_str = "" + if conversation_history: + recent_context = conversation_history[-6:] # Last 6 messages for context + context_lines = [] + for msg in recent_context: + role = msg.get("role", "") + content = msg.get("content", "")[:200] # Limit length + if role and content: + context_lines.append(f"{role}: {content}") + if context_lines: + context_str = f"\n\nConversation context:\n" + "\n".join(context_lines) + "\n" + + challenge_prompt = ( + "You are a clinical safety and accuracy reviewer for an infectious diseases AI assistant. " + "Given the user's question and the agent's draft reply, identify any clinical errors, unsafe advice, missing references, or unsupported statements. " + "Consider the conversation context to understand if this is part of an ongoing workflow (like data collection for reporting or treatment planning). " + "If the reply is safe and accurate, or if it's part of a legitimate ongoing process, respond with 'OK'. " + "If you find a problem, respond with a short warning or correction, and suggest a safer or more accurate reply if possible. " + f"{context_str}" + "\n\nUser question: {user_message}\n\nAgent reply: {agent_reply}" + ) + prompt = challenge_prompt.format(user_message=user_message, agent_reply=agent_reply) + # --- FORCE a challenger suggestion for testing --- + # If the agent reply contains a special marker, always suggest a new answer + if "FORCE_CHALLENGE" in agent_reply or "FORCE_CHALLENGE" in user_message: + # Instead of a canned string, return a realistic sample answer for testing + return ( + "Micafungin is an effective antifungal for Candida species, including C. albicans, but it achieves low concentrations in urine. " + "For urinary tract infections caused by C. albicans, fluconazole is generally preferred due to its high urinary excretion. " + "If the isolate is susceptible, consider switching to fluconazole. If fluconazole cannot be used (e.g., resistance or intolerance), amphotericin B deoxycholate is an alternative. " + "Always tailor antifungal therapy to the patient's clinical status and susceptibility results." + ) + critique = await call_llm(prompt, model=model) + # DEBUG: Log the prompt and critique for challenger step + with open("challenger_debug.log", "a", encoding="utf-8") as f: + f.write("\n--- Challenger Call ---\n") + f.write(f"Prompt:\n{prompt}\n") + f.write(f"Critique:\n{critique}\n") + return critique + +async def refine_final_answer(user_message: str, original_reply: str, critique: str, model: str = None) -> str: + """ + Given the user message, original agent reply, and the challenger critique (which may include warnings and a suggested revision), + draft a clean, precise, and accurate reply for the user. If a suggested revision is present, use it. Do not include any warnings, + critique, or meta-commentaryβ€”just the best final answer. + """ + prompt = ( + "You are an expert clinical assistant. Given the user's question, the agent's original answer, and the critique (which may include warnings and a suggested revision), " + "draft a clean, precise, and accurate reply for the user. If a suggested revision is present, use it. " + "Do not include any warnings, critique, or meta-commentaryβ€”just the best final answer.\n\n" + f"User question: {user_message}\n\nAgent original answer: {original_reply}\n\nCritique: {critique}\n\nFinal user-facing answer:" + ) + return await call_llm(prompt, model=model) +""" +llm_connector.py +--------------- + +Abstraction layer for LLM (OpenAI) API calls, with retry logic, streaming, and agent skill integration. + +- Provides AgentLLMConnector for managing LLM chat, streaming, and function/tool calls. +- Implements robust retry/backoff for API reliability. +- Supports skill registration and OpenAI function-calling interface. +- Used by orchestrator and app for all LLM interactions. +- Designed for future integration with Model Context Protocol (MCP) and Agent-to-Agent (A2A) standards. + +""" + +import os +import json +import time +import random +import asyncio + + +# --- OpenAI Client Setup --- +import openai +from openai import RateLimitError, APIError, APIConnectionError, OpenAI +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +if not OPENAI_API_KEY: + raise RuntimeError("OPENAI_API_KEY environment variable is not set. Please set it before running the application.") +client = OpenAI(api_key=OPENAI_API_KEY) + + + +def call_with_backoff(fn, *args, max_retries=5, base_delay=1.0, **kwargs): + """ + Retry helper for OpenAI API calls. + """ + delay = base_delay + for _ in range(max_retries): + try: + return fn(*args, **kwargs) + except (RateLimitError, APIError, APIConnectionError) as e: + # Log the retry attempt + print(f"API call failed, retrying in {delay:.1f}s: {type(e).__name__}") + time.sleep(delay + random.random()) + delay *= 2 + + # Final attempt with proper error handling + try: + return fn(*args, **kwargs) + except APIConnectionError: + # Network connection error + raise ConnectionError("Unable to connect to OpenAI API. Please check your internet connection.") + except RateLimitError: + # Rate limit exceeded + raise ConnectionError("OpenAI API rate limit exceeded. Please wait a moment and try again.") + except APIError as e: + # Other API errors (invalid key, etc.) + raise ConnectionError(f"OpenAI API error: {str(e)}") + except Exception as e: + # Catch any other unexpected errors + raise ConnectionError(f"Unexpected API error: {str(e)}") + +class AgentLLMConnector: + def __init__(self, api_key: str, skills: list = None, allow_fallback: bool = True, trusted_links=None, grounded_files=None, rag_retriever=None): + """ + api_key: your OpenAI API key + skills: list of Tool instances (each with .name and .openai_spec()) + allow_fallback: if False, do not allow fallback to LLM general knowledge + trusted_links: list of trusted source URLs (if any) + grounded_files: list of uploaded RAG files (if any) + rag_retriever: SimpleRAGRetriever instance for knowledge retrieval + """ + # API key is set globally via client above; this arg is kept for compatibility + self.skills = skills or [] + self.invocations = [] + + self.allow_fallback = allow_fallback + self.trusted_links = trusted_links or [] + self.grounded_files = grounded_files or [] + self.rag_retriever = rag_retriever + + # map agent_type (from your builder) β†’ model name + self.agent_model_mapping = { + "πŸ›‘οΈ Antimicrobial Stewardship": "gpt-5-mini", + "🦠 Infection Prevention and Control":"gpt-5-mini", + "πŸ”¬ Research Assistant": "gpt-5-mini", + "πŸ₯ Clinical Assistant": "gpt-5-mini", + "πŸ“š Education Assistant": "gpt-5-mini", + "🎼 Orchestrator": "gpt-5" + } + + + def _chat_create(self, **kwargs): + """ + Internal wrapper around client.chat.completions.create with backoff. + """ + return call_with_backoff(client.chat.completions.create, **kwargs) + + async def chat_with_agent_stream(self, + model_name: str, + messages: list[dict]): + """ + Async generator: streams the assistant's response, handling function calls. + """ + try: + # 1) Gather function specs from skills + tools = [] + if self.skills: + for tool in self.skills: + spec = tool.openai_spec(legacy=False) + tools.append({ + "type": "function", + "function": spec + }) + + # 2) RAG retrieval if available + if self.rag_retriever and messages: + # Get the latest user message for RAG query + user_messages = [msg for msg in messages if msg.get("role") == "user"] + if user_messages: + latest_query = user_messages[-1].get("content", "") + relevant_chunks = self.rag_retriever.retrieve_relevant_chunks(latest_query) + + if relevant_chunks: + # Add knowledge context to the conversation + knowledge_context = "πŸ“š **Knowledge Base Context:**\n\n" + "\n\n---\n\n".join(relevant_chunks) + + # Insert knowledge before the latest user message + messages = list(messages[:-1]) # All messages except the last + messages.append({"role": "system", "content": knowledge_context}) + messages.append(user_messages[-1]) # Add back the latest user message + + # 3) Local copy of the conversation + convo = list(messages) + + # 4) Recursive loop: handle tool_calls until normal reply + while True: + # Prepare call arguments, including functions if any + call_args = { + "model": model_name, + "messages": convo, + "stream": False + } + if tools: + call_args["tools"] = tools + call_args["tool_choice"] = "auto" + + try: + # Call the API with error handling + resp = self._chat_create(**call_args) + choice = resp.choices[0] + msg = choice.message + except ConnectionError as e: + # Network or API connection error - yield user-friendly message + error_message = f"⚠️ Connection Error: {str(e)}\n\nPlease check your internet connection and try again." + for char in error_message: + yield char + await asyncio.sleep(0.01) # Small delay for visual effect + return + except Exception as e: + # Any other unexpected error + error_message = f"⚠️ Unexpected Error: {str(e)}\n\nPlease try again or contact support if the issue persists." + for char in error_message: + yield char + await asyncio.sleep(0.01) + return + + # a) If model wants to call a tool, execute it + if msg.tool_calls: + for tool_call in msg.tool_calls: + fname = tool_call.function.name + raw_args = tool_call.function.arguments or "{}" + try: + func_args = json.loads(raw_args) + except json.JSONDecodeError: + func_args = {} + + # Find and run the correct tool + tool = next((t for t in self.skills if t.name == fname), None) + if not tool: + err = f"[Error] Tool '{fname}' not found." + for ch in err: + yield ch + return + + # Execute tool (may be async) + result = await tool.run(**func_args) + + # Record both the call and its output for the invocation log + self.invocations.append({ + "tool": fname, + "args": func_args, + "result": result + }) + + # Handle tool-level errors gracefully + if isinstance(result, dict) and "error" in result: + apology = ( + f"⚠️ Sorry, I ran into an issue with the `{fname}` tool: {result['error']}. " + "Could you please clarify or try again?" + ) + for ch in apology: + yield ch + return + + # Surface partial tool results to the user + partial = f"πŸ”” Tool `{fname}` invoked\n" + for ch in partial: + yield ch + + # Inject the tool call message and its output using modern format + convo.append({ + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": tool_call.id, + "type": "function", + "function": { + "name": fname, + "arguments": json.dumps(func_args) + } + }] + }) + convo.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": json.dumps(result) + }) + + # After processing all tool calls, loop to let the model respond to the results + continue + + # b) Normal assistant reply: stream content then exit + content = msg.content or "" + # --- ENFORCE NO FALLBACK TO LLM IF NOT ALLOWED --- + # Allow clarifying questions and tool result summaries, only block unsupported final answers + if not self.allow_fallback and (self.trusted_links or self.grounded_files): + # Heuristic: allow clarifying questions (contains '?', 'please provide', 'missing', etc.) + clarifying_phrases = [ + '?', + 'please provide', + 'missing', + 'required information', + 'what is', + 'could you', + 'specify', + 'enter', + 'need to know', + 'tell me', + 'which', + 'select', + ] + content_lower = content.lower() + is_clarifying = any(phrase in content_lower for phrase in clarifying_phrases) + # Heuristic: allow tool result summaries (contains 'according to', 'the following', 'results found', etc.) + tool_summary_phrases = [ + 'according to', + 'the following', + 'results found', + 'based on', + 'search result', + 'tool', + 'invoked', + 'here are', + 'found at', + ] + is_tool_summary = any(phrase in content_lower for phrase in tool_summary_phrases) + # If content is empty, or is a clarifying question, or is a tool summary, allow it + if not content.strip() or is_clarifying or is_tool_summary: + for ch in content: + yield ch + break + # Otherwise, block with default message + default_msg = "⚠️ No information found in the provided sources. Please try rephrasing your question or upload more relevant documents." + for ch in default_msg: + yield ch + break + # Otherwise, allow normal LLM answer + for ch in content: + yield ch + break + + except Exception as e: + # Catch any remaining unhandled errors at the top level + error_message = f"⚠️ System Error: {str(e)}\n\nPlease try again or contact support if the issue persists." + for char in error_message: + yield char + await asyncio.sleep(0.01) + + + async def chat_with_agent(self, + model_name: str, + messages: list[dict]) -> str: + """ + Convenience: collect and return the full assistant response. + """ + out = "" + async for tok in self.chat_with_agent_stream(model_name, messages): + out += tok + return out + +async def call_llm(prompt: str | list[dict], + model: str = None) -> str: + """ + Helper for tools: run a simple prompt or message array through the LLM. + """ + # Normalize prompt to a messages list + if isinstance(prompt, list): + messages = prompt + else: + try: + # If prompt is a JSON-stringified message list + parsed = json.loads(prompt) + messages = parsed if isinstance(parsed, list) else [] + except Exception: + messages = [] + + if not messages: + # Fallback to a simple system+user framing + messages = [ + {"role": "system", "content": "You are an assistant."}, + {"role": "user", "content": prompt} + ] + + # Call the API + + response = call_with_backoff( + client.chat.completions.create, + model=model or os.getenv("OPENAI_MODEL", "gpt-5-mini"), + messages=messages + ) + + return response.choices[0].message.content diff --git a/core/utils/rag.py b/core/utils/rag.py new file mode 100644 index 0000000000000000000000000000000000000000..acb0ce1456e03665916f9fdc64d5f3ccb4f44174 --- /dev/null +++ b/core/utils/rag.py @@ -0,0 +1,85 @@ + +""" +rag.py +------ +KnowledgeLoader and SimpleRAGRetriever classes for document ingestion and retrieval. +""" + +import os +import pdfplumber +import docx +import numpy as np +from openai import OpenAI + +class KnowledgeLoader: + def load_text_from_file(self, file_obj): + ext = file_obj.name.split(".")[-1].lower() + if ext == "pdf": + return self._load_pdf(file_obj) + elif ext == "docx": + return self._load_docx(file_obj) + elif ext == "csv" or ext == "txt": + try: + return file_obj.read().decode("utf-8") + except UnicodeDecodeError: + try: + return file_obj.read().decode("latin-1") + except Exception as e: + return f"⚠️ Failed to decode file: {str(e)}" + else: + return "" + + def _load_pdf(self, file_obj): + try: + with pdfplumber.open(file_obj) as pdf: + text = "\n".join([page.extract_text() or "" for page in pdf.pages]) + return text + except Exception as e: + return f"⚠️ Failed to read PDF: {str(e)}" + + def _load_docx(self, file_obj): + try: + doc = docx.Document(file_obj) + text = "\n".join([p.text for p in doc.paragraphs]) + return text + except Exception as e: + return f"⚠️ Failed to read DOCX: {str(e)}" + +class SimpleRAGRetriever: + def __init__(self, openai_api_key, chunk_size=600): + self.chunk_size = chunk_size + self.embeddings = {} + self.text_chunks = {} + self.client = OpenAI(api_key=openai_api_key) + + def split_text(self, text): + return [text[i:i+self.chunk_size] for i in range(0, len(text), self.chunk_size)] + + def embed_text(self, texts): + if not texts: + return [] + response = self.client.embeddings.create( + input=texts, + model="text-embedding-ada-002" + ) + return [np.array(d.embedding) for d in response.data] + + def add_knowledge(self, file_obj): + loader = KnowledgeLoader() + full_text = loader.load_text_from_file(file_obj) + chunks = self.split_text(full_text) + chunk_embeddings = self.embed_text(chunks) + for idx, (chunk, emb) in enumerate(zip(chunks, chunk_embeddings)): + self.embeddings[len(self.embeddings)] = emb + self.text_chunks[len(self.text_chunks)] = chunk + + def retrieve_relevant_chunks(self, query, top_k=2): + if not self.embeddings: + return [] + query_emb = self.embed_text([query])[0] + similarities = {} + for idx, emb in self.embeddings.items(): + similarities[idx] = float(np.dot(query_emb, emb) / (np.linalg.norm(query_emb) * np.linalg.norm(emb))) + sorted_idxs = sorted(similarities, key=lambda idx: similarities[idx], reverse=True) + top_chunks = [self.text_chunks[idx] for idx in sorted_idxs[:top_k]] + return top_chunks diff --git a/core/utils/skills_registry.py b/core/utils/skills_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..603edf33ab1dddab25a75161c827acecc0bcc432 --- /dev/null +++ b/core/utils/skills_registry.py @@ -0,0 +1,132 @@ +import os +import json +import re +import math +from typing import Optional +from openai import OpenAI, OpenAIError + +from tools.registry import TOOL_REGISTRY + +from typing import Any, Callable, Dict, Optional, Type, Union +from tools.base import Tool + + + + +# Instantiate stateless tools once and store in a dict +STATIC_TOOL_INSTANCES = {name: cls() for name, cls in TOOL_REGISTRY.items() if name != "history_taking"} + +""" +skills_registry.py +------------------ +Registry and helper functions for tool instantiation, syndrome matching, and embedding lookup. +""" + +#match for KB +# 1) Initialize the OpenAI client +openai_api_key = os.getenv("OPENAI_API_KEY") +if not openai_api_key: + raise RuntimeError("OPENAI_API_KEY environment variable is not set. Please set it before running the application.") +_client = OpenAI(api_key=openai_api_key) + +# 2) Load your precomputed syndrome embeddings +# Make sure syndrome_embeddings.json is in your working dir +with open("syndrome_embeddings.json", "r") as f: + SYNDROME_EMBS = json.load(f) # { syndrome_key: [float, ...], … } + + +def _cosine_sim(a: list[float], b: list[float]) -> float: + """ + Compute the cosine similarity between two vectors. + + Args: + a (list[float]): First vector. + b (list[float]): Second vector. + + Returns: + float: Cosine similarity between a and b. + """ + dot = sum(x*y for x, y in zip(a, b)) + norm_a = math.sqrt(sum(x*x for x in a)) + norm_b = math.sqrt(sum(y*y for y in b)) + return dot / (norm_a * norm_b) if norm_a and norm_b else 0.0 + +def _match_syndrome(user_query: str, + model: str = "text-embedding-ada-002", + threshold: float = 0.7 + ) -> Optional[str]: + # TODO: implement syndrome matching logic or remove if unused + pass + # This function is now implemented at the end of the file (see below) + pass + """ + Semantically match the user_query to the best syndrome_key + via cosine similarity against precomputed embeddings. + + Args: + user_query (str): The user's query string. + model (str, optional): Embedding model to use. Defaults to "text-embedding-ada-002". + threshold (float, optional): Minimum similarity threshold. Defaults to 0.7. + + Returns: + Optional[str]: The best-matching syndrome key, or None if no match meets the threshold. + """ + # normalize + q = user_query.lower() + q = re.sub(r"[^a-z0-9\s]", " ", q) + q = re.sub(r"\s+", " ", q).strip() + + # embed the query + try: + resp = _client.embeddings.create(model=model, input=[q]) + q_emb = resp.data[0].embedding + except OpenAIError as e: + # if embedding fails, fall back to no match + print(f"[Embedding error] {e}") + return None + + # find best cosine similarity + best_key, best_score = None, -1.0 + for key, emb in SYNDROME_EMBS.items(): + score = _cosine_sim(q_emb, emb) + if score > best_score: + best_key, best_score = key, score + + return best_key if best_score >= threshold else None + + +# Unified tool registry: uses TOOL_REGISTRY for class references and STATIC_TOOL_INSTANCES for stateless tools +from tools.history_taking import HistoryTakingTool + +tool_registry: Dict[str, Dict[str, Any]] = {} +for name, cls in TOOL_REGISTRY.items(): + if name == "history_taking": + tool_registry[name] = {"fn": HistoryTakingTool} + else: + instance = STATIC_TOOL_INSTANCES[name] + tool_registry[name] = {"fn": instance, "args_schema": instance.args_schema} + +def get_tool_by_name( + name: str, + context: Dict[str, Any] +) -> Optional[Tool]: + """ + Retrieve a tool instance by name, optionally using context for dynamic instantiation. + + Args: + name (str): The tool key, e.g. "history_taking". + context (Dict[str, Any]): Must include "user_query" for dynamic tools. + + Returns: + Optional[Tool]: The tool instance, or None if not found or not instantiable. + """ + entry = tool_registry[name] + fn = entry["fn"] + if name == "history_taking": + syndrome_key = _match_syndrome(context["user_query"]) + if not syndrome_key: + return None + return fn(syndrome_key) # instantiate with dynamic key + else: + # static tools: fn is already an instance + return fn \ No newline at end of file diff --git a/prompts/alert_prolonged_antibiotic_use.j2 b/prompts/alert_prolonged_antibiotic_use.j2 new file mode 100644 index 0000000000000000000000000000000000000000..25dc192f415104139d604d60bc37ed93cb2b67bc --- /dev/null +++ b/prompts/alert_prolonged_antibiotic_use.j2 @@ -0,0 +1,9 @@ +{% raw %} +[ + {"role": "system", "content": + "You are an infectious diseases consultant. Based on all provided clinical data and guidelines, determine if the patient is at risk for prolonged or unnecessary antibiotic use and summarize recommended durations for the specified condition. Provide rationale and cite relevant guidelines if possible."}, + + {"role": "user", "content": + "Clinical condition: {{ condition }}\nSite of infection: {{ site_of_infection }}\nRisk or presence of biofilm: {{ risk_of_biofilm }}\nCurrent response to antibiotics: {{ current_response }}\nCreatinine clearance: {{ creatinine_clearance }}\nSeverity of infection: {{ severity_of_infection }}\nKnown drug allergies: {{ known_allergies }}\n\nPlease:\n1. Summarize recommended antibiotic duration for this condition.\n2. Indicate if current therapy may be prolonged.\n3. Provide rationale and cite guidelines if possible."} +] +{% endraw %} diff --git a/prompts/deescalation.j2 b/prompts/deescalation.j2 new file mode 100644 index 0000000000000000000000000000000000000000..ef46509431d6c6e07c3ec84a8b9bf72370342eca --- /dev/null +++ b/prompts/deescalation.j2 @@ -0,0 +1,9 @@ +{% raw %} +[ + {"role": "system", "content": + "You are an infectious diseases consultant. Based on all provided clinical data, recommend appropriate antibiotic de‐escalation."}, + + {"role": "user", "content": + "Patient culture results: {{ culture }}\nCurrent antibiotics: {{ meds }}\nSite of infection: {{ site_of_infection }}\nRisk or presence of biofilm: {{ risk_of_biofilm }}\nCurrent response to antibiotics: {{ current_response }}\nCreatinine clearance: {{ creatinine_clearance }}\nSeverity of infection: {{ severity_of_infection }}\nKnown drug allergies: {{ known_allergies }}\n\nPlease:\n1. Identify if de‐escalation is safe.\n2. Recommend the narrowest effective agent.\n3. Provide rationale in 2–3 sentences."} +] +{% endraw %} diff --git a/prompts/diagnostic_recommendation.j2 b/prompts/diagnostic_recommendation.j2 new file mode 100644 index 0000000000000000000000000000000000000000..3ad8ec518c04a755e18fc224239922f4adf50084 --- /dev/null +++ b/prompts/diagnostic_recommendation.j2 @@ -0,0 +1,12 @@ +{% raw %} +[ +{"role": "system", "content": +"You are an infectious diseases consultant who has gathered the patient’s clinical history and now will provide a diagnostic and management recommendation."}, + +{"role": "user", "content": +"The user’s concern: '{{ syndrome_query }}'.\n\n" +"Collected History:\n" +"{% for question_key, answer in history.items() %}- {{ question_key.replace('_', ' ').capitalize() }}: {{ answer }}\n{% endfor %}\n\n" +"Based on this information, please provide your diagnostic impression and recommended management plan, including appropriate empiric therapy, isolation precautions, and any other relevant considerations."} +] +{% endraw %} \ No newline at end of file diff --git a/prompts/draft_critique_enhance_board_exam.j2 b/prompts/draft_critique_enhance_board_exam.j2 new file mode 100644 index 0000000000000000000000000000000000000000..423605833576354152f136294c8eb9aaa657a07b --- /dev/null +++ b/prompts/draft_critique_enhance_board_exam.j2 @@ -0,0 +1,96 @@ +You are an expert Infectious Diseases fellowship board exam question writer and critic. + +Your task is to DRAFT an initial question, CRITIQUE it thoroughly, and then ENHANCE it to board-level excellence. + +**BLUEPRINT TO IMPLEMENT:** +{{ blueprint }} + +**TOPIC:** {{ topic }} +**DIFFICULTY LEVEL:** {{ difficulty_level }} +**QUESTION TYPE:** {{ question_type }} + +**YOUR 3-PHASE MISSION:** + +## PHASE 1: DRAFT INITIAL QUESTION +Based on the blueprint, create: +1. **Clinical Vignette:** Rich clinical scenario implementing the blueprint strategy +2. **Question Stem:** Clear, specific question +3. **Answer Choices:** 5 options matching the blueprint differential diagnoses +4. **Explanations:** Detailed explanations for correct and incorrect answers + +## PHASE 2: CRITIQUE THE DRAFT +Analyze your draft question for: +- **Diagnostic Giveaways:** Any obvious clues that make the answer too easy? +- **Wrong Answer Quality:** Are the distractors plausible for ID specialists? +- **Clinical Realism:** Does the vignette reflect real-world presentations? +- **Difficulty Level:** Will this challenge ID fellowship trainees? +- **Blueprint Adherence:** Does it follow the planned strategy? + +## PHASE 3: ENHANCE BASED ON CRITIQUE +Revise the question to: +- Eliminate any diagnostic giveaways identified +- Strengthen weak distractors +- Add clinical complexity and sophistication +- Ensure blueprint strategy is perfectly executed +- Achieve ID fellowship-level difficulty + +**ENHANCEMENT REQUIREMENTS:** + +1. **Vignette Excellence:** + - Include specific lab values, imaging findings + - Add clinical complexity (comorbidities, medications) + - Use sophisticated medical terminology + - Implement all blueprint clues naturally + +2. **Question Sophistication:** + - Avoid obvious diagnostic language + - Focus on clinical reasoning + - Require expert-level differentiation + +3. **Answer Choice Quality:** + - Each distractor must be plausible for ID specialists + - Implement the blueprint's wrong answer reasoning + - Ensure choices require clinical expertise to differentiate + +4. **Explanation Depth:** + - Detailed reasoning for correct answer + - Specific reasons why each wrong answer is incorrect + - Educational value for ID trainees + +**CRITICAL RULES:** +- NO classic travel + classic symptoms combinations that give away answers +- Each wrong answer must have the specific reasoning from the blueprint +- Include 3+ supporting clues for correct diagnosis as planned +- Add misleading clues to increase complexity +- Target ID fellowship-level difficulty + +**OUTPUT FORMAT:** +Return a JSON object with this exact structure: + +```json +{ + "vignette": "Enhanced clinical vignette with sophisticated details", + "question_stem": "Clear, specific question requiring ID expertise", + "answer_choices": [ + "Correct answer - first choice", + "Plausible distractor 2", + "Plausible distractor 3", + "Plausible distractor 4", + "Plausible distractor 5" + ], + "explanations": { + "correct": "Detailed explanation of why this is correct with ID-specific reasoning", + "incorrect": "Detailed explanation of why other choices are incorrect, implementing blueprint reasoning" + }, + "enhancement_notes": "Summary of key enhancements made during critique phase", + "critique_summary": "Brief summary of issues identified and how they were resolved" +} +``` + +**EXAMPLE OF BLUEPRINT IMPLEMENTATION:** + +If blueprint says "Blastomyces wrong because yeast description will be small 2-4 microns": +- Vignette should describe: "BAL fluid microscopy reveals small budding yeasts measuring 2-4 micrometers" +- Explanation should state: "Blastomyces dermatitidis typically shows broad-based budding yeasts that are 8-15 micrometers, not the small 2-4 micrometer yeasts described" + +**Remember:** Follow the blueprint strategy exactly while creating a sophisticated question that challenges ID specialists! diff --git a/prompts/empiric_therapy.j2 b/prompts/empiric_therapy.j2 new file mode 100644 index 0000000000000000000000000000000000000000..3d4afb7f03e86039f6e68101cccbc2f47fb5a1d4 --- /dev/null +++ b/prompts/empiric_therapy.j2 @@ -0,0 +1,13 @@ +{% raw %} +[ + { + "role": "system", + "content": "You are a clinical decision support agent. Suggest empiric antibiotic therapy per standard guidelines, considering all provided clinical variables." + }, + { + "role": "user", + "content": "Patient profile:\n- Age: {{ age }}\n- Allergies: {{ allergies }}\n- Recent labs: {{ labs }}\n- Culture & Sensitivity Results: {{ culture }}\n- Current Antibiotic Regimen: {{ meds }}\n- Site of Infection: {{ site_of_infection }}\n- Risk or Presence of Biofilm: {{ risk_of_biofilm }}\n- Current Response to Antibiotics: {{ current_response }}\n- Creatinine Clearance: {{ creatinine_clearance }}\n- Severity of Infection: {{ severity_of_infection }}\n- Known Drug Allergies: {{ known_allergies }}\n\nBased on this, recommend:\n1. One or two empiric antibiotic regimens.\n2. Dosing and route.\n3. Brief justification." + } +] + +{% endraw %} diff --git a/prompts/evaluate_nhsn_definition.j2 b/prompts/evaluate_nhsn_definition.j2 new file mode 100644 index 0000000000000000000000000000000000000000..8c99510e989d4c7f440c514d55f2bff13f18e7aa --- /dev/null +++ b/prompts/evaluate_nhsn_definition.j2 @@ -0,0 +1,19 @@ +[ + { + "role": "system", + "content": "You are an expert at applying NHSN surveillance definitions to patient cases. \n\n" + "Here is the formal logic extracted from the NHSN site:\n" + "{{ definition_logic | tojson }}\n\n" + "And here are the user-provided field values:\n" + "{{ values | tojson }}\n\n" + "Based on this logic and these values, decide whether the case **meets** the definition. \n" + "Return **only** a JSON object wrapped in … tags with exactly two keys:\n" + " β€’ `meets_definition`: true or false\n" + " β€’ `reasoning`: a brief justification referencing the logic.\n" + "Do not output any additional text." + }, + { + "role": "user", + "content": "Case: {{ case_description }}" + } +] diff --git a/prompts/extract_nhsn_fields.j2 b/prompts/extract_nhsn_fields.j2 new file mode 100644 index 0000000000000000000000000000000000000000..e8f6a4d893e655b523a60ea4c1023967316dfedf --- /dev/null +++ b/prompts/extract_nhsn_fields.j2 @@ -0,0 +1,12 @@ +[ + { + "role": "system", + "content": "You are an expert in NHSN surveillance definitions. \n" + "From the following webpage snippets, extract ONLY the list of required data elements (field names) that a user must collect to apply the definition. \n" + "Return **only** a JSON array wrapped in … tagsβ€”no extra text." + }, + { + "role": "user", + "content": "Snippets:\n\n{{ snippets }}" + } +] diff --git a/prompts/extract_nhsn_logic.j2 b/prompts/extract_nhsn_logic.j2 new file mode 100644 index 0000000000000000000000000000000000000000..45ec40cc5aa506ba5de31a77e4c3b9eceeb955c8 --- /dev/null +++ b/prompts/extract_nhsn_logic.j2 @@ -0,0 +1,12 @@ +[ + { + "role": "system", + "content": "You are an expert in NHSN surveillance definitions. \n" + "From the same webpage snippets, extract the core logic or rule text that describes how to decide the definition (e.g., β€œcentral line in place >2 calendar days…”, β€œno alternate source of infection…”, etc.). \n" + "Return **only** a JSON object wrapped in … tags with a single key `logic` whose value is that text." + }, + { + "role": "user", + "content": "Snippets:\n\n{{ snippets }}" + } +] diff --git a/prompts/extract_reporting_fields.j2 b/prompts/extract_reporting_fields.j2 new file mode 100644 index 0000000000000000000000000000000000000000..3645d831543453af882bc9d84f92f79c565f4e78 --- /dev/null +++ b/prompts/extract_reporting_fields.j2 @@ -0,0 +1,17 @@ +{% raw %} + +[ + { + "role": "system", + "content": "You are an expert at extracting field names from public health reporting requirement snippets." + }, + { + "role": "user", + "content": "From these snippets, extract only the list of required reporting fields. " + "Return **only** a JSON array wrapped between and tags, for example:\n\n" + "\n[\"Name\",\"Age\",…]\n\n\n" + "Snippets:\n\n{{ snippets }}" + } +] + +{% endraw %} \ No newline at end of file diff --git a/prompts/extract_reporting_format.j2 b/prompts/extract_reporting_format.j2 new file mode 100644 index 0000000000000000000000000000000000000000..b04db85a3976b4d45b0fc27c734f421d973fc4c0 --- /dev/null +++ b/prompts/extract_reporting_format.j2 @@ -0,0 +1,23 @@ +{% raw %} + +[ + { + "role": "system", + "content": "You are an expert at understanding state public health reporting templates." + }, + { + "role": "user", + "content": "From these snippets, extract only the JSON object describing the report format (columns, column_headers, file_name). " + "Return **only** that object wrapped between and tags, for example:\n\n" + "\n" + "{\n" + " \"columns\": [\"Name\",\"DOB\",…],\n" + " \"column_headers\": {\"Name\":\"Full Name\",…},\n" + " \"file_name\":\"Typhoid_Report_Dallas_TX.csv\"\n" + "}\n" + "\n\n" + "Snippets:\n\n{{ snippets }}" + } +] + +{% endraw %} \ No newline at end of file diff --git a/prompts/final_enhancement_board_exam.j2 b/prompts/final_enhancement_board_exam.j2 new file mode 100644 index 0000000000000000000000000000000000000000..60a80259140a58037b1bfb3bf5e558e06bffb195 --- /dev/null +++ b/prompts/final_enhancement_board_exam.j2 @@ -0,0 +1,143 @@ +You are an expert Infectious Diseases fellowship board exam question reviewer and enhancer. + +Your task is to take a drafted board exam question and enhance it to ensure it meets the highest standards for ID fellowship board examinations. + +**CURRENT DRAFTED QUESTION:** + +**Topic:** {{ topic }} + +**Vignette:** +{{ current_vignette }} + +**Question Stem:** +{{ current_question }} + +**Answer Choices:** +{{ current_choices }} + +**Explanations:** +{{ current_explanations }} + +**QUALITY REVIEW FEEDBACK:** +{{ quality_feedback }} + +**YOUR ENHANCEMENT MISSION:** + +🚨 **IMMEDIATE ACTION REQUIRED:** +The quality review shows this question FAILS board exam standards. You MUST follow these mandatory steps: + +**STEP 1: EMERGENCY QUALITY CHECK** +- Quality score: {{ quality_feedback.percentage_score }}% +- Board readiness: {{ quality_feedback.board_exam_readiness }} +- **TARGET REQUIRED: 80%+ score AND board_readiness = True** +- If score <80% OR board_readiness = False β†’ **EMERGENCY PROTOCOL ACTIVATED** + +**STEP 2: EMERGENCY PROTOCOL (MANDATORY)** +When quality review shows problems, you MUST: +1. **COMPLETELY DISCARD** the current vignette +2. **START OVER** with an entirely new clinical scenario +3. **REMOVE ALL** forbidden combinations identified in quality review +4. **CREATE** a genuinely difficult scenario requiring fellowship-level expertise +5. **ENSURE 3+ EQUALLY PLAUSIBLE diagnoses** with overlapping features + +**FORBIDDEN ACTIONS** (Will result in continued failure): +- ❌ Making minor modifications to existing vignette +- ❌ Keeping any elements that quality review flagged as "too obvious" +- ❌ Maintaining travel history that gives away diagnosis +- ❌ Using any combination identified as "strongly suggests" the correct answer + +**MANDATORY ACTIONS** (Required for passing): +- βœ… Completely new patient scenario +- βœ… Remove or mislead all geographic/exposure clues +- βœ… Create multiple equally plausible diagnoses +- βœ… Require expert-level ID reasoning to differentiate + +2. **CRITICAL: Eliminate All Diagnostic Giveaways** + - **Question stem**: NEVER mention suspected diagnosis, pathogen type, or disease category + - **Vignette**: REMOVE obvious combinations: + * Classic geographic exposure + classic symptoms = REMOVE one or both + * Specific travel history + pathognomonic findings = REMOVE or change + * Immunocompromised + classic opportunistic infection signs = ADD confounding factors + - **Create genuine uncertainty**: Each differential diagnosis should have 2-3 supporting clues + +3. **CRITICAL: Implement 3-Clue Rule** + - **Correct diagnosis**: Must have EXACTLY 3 clear but subtle diagnostic clues + - **Each distractor**: Should have 1-2 misleading clues suggesting that diagnosis + - **Ensure diagnostic confusion**: Clinical picture should suggest multiple possibilities + +4. **Maximize difficulty for ID specialists:** + - Create atypical presentations requiring expert reasoning + - Include overlapping features between differential diagnoses + - Add confounding clinical information that misleads + - Use complex clinical scenarios (immunocompromised, nosocomial, drug interactions) + +5. **Include ID-specific sophisticated details:** + - Antimicrobial resistance patterns + - Specific pathogens and their characteristics + - Complex clinical scenarios (immunocompromised patients, healthcare settings, etc.) + - Laboratory interpretations (cultures, molecular diagnostics, serology) + - Treatment decisions and drug interactions + +**ENHANCEMENT REQUIREMENTS:** + +- **Vignette Enhancement:** Add clinical complexity, specific lab values, imaging findings, patient history that increases difficulty +- **Question Refinement:** Make the question stem more sophisticated and ID-specific +- **Answer Choice Improvement:** Ensure all distractors are plausible for an ID specialist +- **Explanation Enhancement:** Provide detailed ID-specific reasoning that teaches key concepts + +**CRITICAL RULES for MAXIMUM DIFFICULTY:** +- **MANDATORY: If quality review score <75%** β†’ COMPLETELY REWRITE the vignette from scratch +- **MANDATORY: If "board_exam_readiness = False"** β†’ ABANDON current approach and create entirely new scenario +- **3-Clue Rule**: Correct answer has exactly 3 diagnostic clues, distractors have 1-2 clues each +- **NO obvious combinations**: Classic exposure + classic symptoms + confirmatory tests = TOO EASY +- **Geographic clues**: Either REMOVE entirely or make misleading (wrong endemic area) +- **Diagnostic confusion**: Include findings that could suggest 3+ different diagnoses +- **ID expertise required**: Should challenge infectious disease attendings +- **Atypical presentations**: Completely avoid textbook classic cases +- **Clinical complexity**: Multiple confounding factors that mislead from correct diagnosis + +**EXAMPLES OF PROPER DIFFICULTY:** + +❌ **FAILING APPROACH** (What you must NOT do): +- "Patient with HIV traveled to Southeast Asia and has skin lesions" β†’ Obviously Penicillium marneffei +- Minor tweaks: "traveled to multiple regions in Southeast Asia" β†’ Still obvious +- Cosmetic changes: "atypical skin findings" β†’ Diagnosis still clear + +βœ… **PASSING APPROACH** (What you MUST do): +- **Option A**: Completely different scenario: "Hospital-acquired infection in ICU patient with complex comorbidities and atypical presentation suggesting multiple resistant pathogens" +- **Option B**: Misleading scenario: "Recent travel to Europe, pneumonia-like symptoms, multiple possible bacterial/viral/fungal etiologies requiring expert differentiation" +- **Option C**: Complex presentation: "Immunocompromised patient with overlapping symptoms suggesting 3+ different opportunistic infections, requiring laboratory interpretation skills" + +**TRANSFORMATION EXAMPLES:** +- **Before**: HIV + Southeast Asia travel + skin lesions β†’ Penicillium marneffei +- **After**: HIV + Recent European travel + respiratory symptoms + complex lab findings β†’ Could be PCP, bacterial pneumonia, atypical mycobacteria, or resistant fungal infection + +**EMERGENCY REWRITE TRIGGERS:** +- Any mention of Southeast Asia in context of HIV + skin lesions +- Any "classic" exposure + symptoms combination +- Any scenario where quality review says "diagnosis too obvious" +- Quality score <75% or board_exam_readiness = False + +**OUTPUT FORMAT:** +Return a JSON object with this exact structure: + +```json +{ + "vignette": "Enhanced clinical vignette with ID-specific complexity", + "question_stem": "Enhanced question that requires ID expertise", + "answer_choices": [ + "Correct answer - first choice", + "Plausible distractor 2", + "Plausible distractor 3", + "Plausible distractor 4", + "Plausible distractor 5" + ], + "explanations": { + "correct": "Detailed explanation of why this is correct with ID-specific reasoning", + "incorrect": "Detailed explanation of why other choices are incorrect, with teaching points" + }, + "enhancement_notes": "Summary of key enhancements made to increase difficulty and ID-specificity" +} +``` + +**Remember:** This question should challenge an Infectious Diseases fellow or attending physician. Make it clinically sophisticated and ID-specialty specific! diff --git a/prompts/generate_board_exam_vignette.j2 b/prompts/generate_board_exam_vignette.j2 new file mode 100644 index 0000000000000000000000000000000000000000..3d7a08df50f33393a7b83d473369a86552cf236a --- /dev/null +++ b/prompts/generate_board_exam_vignette.j2 @@ -0,0 +1,75 @@ +You are an expert medical education specialist creating advanced board exam questions. + +Using the following comparison table, generate a complete board exam question including vignette, question stem, answer choices, and explanations. + +**Comparison Table:** +{{ comparison_table }} + +**Requirements for CHALLENGING Board Exam Questions:** + +1. **CRITICAL Diagnostic Clue Distribution:** + - **Correct diagnosis ONLY**: Must have exactly 3 clear but subtle diagnostic clues + - **Each distractor**: Should have 1-2 misleading clues suggesting that diagnosis + - **Create confusion**: Include overlapping symptoms, labs, imaging between diagnoses + - **Avoid giveaways**: NO obvious exposure + classic symptoms + confirmatory tests + +2. **Clinical Vignette Complexity**: Create a realistic, challenging clinical scenario that: + - Uses specific patient demographics, medical history, and presentation timeline + - Includes detailed vital signs, physical exam findings, and laboratory results + - Incorporates specific imaging findings and diagnostic test results + - **Includes confounding elements**: Red herrings from differential diagnoses + - **Atypical presentations**: Avoid textbook classic presentations + - **Expert-level reasoning required**: Should challenge ID specialists + - Uses realistic medical details (specific lab values, medication names, dosages) + - **Multiple comorbidities/factors** that complicate the clinical picture + +3. **Question Stem**: + - For diagnosis questions: "Which of the following is the most likely diagnosis?" + - For treatment questions: "Which of the following is the most appropriate next step in management?" + - For identification questions: "Which of the following is the most likely causative organism?" + +4. **Answer Choices**: Extract the condition names from the comparison table: + - A. [Correct answer condition] + - B. [Distractor 1 condition] + - C. [Distractor 2 condition] + - D. [Distractor 3 condition] + - E. [Distractor 4 condition] + +4. **Explanations**: + - **Correct Answer**: Explain why this diagnosis is most consistent with the clinical presentation, citing specific discriminating features + - **Each Distractor**: Explain why this diagnosis is less likely, highlighting key differences in presentation, lab findings, or clinical context + +**Quality Standards:** +- Board exam level complexity and clinical accuracy +- Specific medical details that demonstrate expertise +- Clear clinical reasoning that teaches key concepts +- Appropriate difficulty for advanced learners + +**Topic**: {{ topic }} +**Question Type**: {{ question_type }} +**Difficulty**: {{ difficulty_level }} + +**Output Format:** +Return a JSON object with this exact structure: +```json +{ + "vignette": "Detailed clinical scenario with specific medical details...", + "question_stem": "Complete question including vignette + question", + "answer_choices": [ + "A. Correct condition name", + "B. Distractor 1 condition name", + "C. Distractor 2 condition name", + "D. Distractor 3 condition name", + "E. Distractor 4 condition name" + ], + "explanations": { + "correct_explanation": "Why A is correct with specific clinical reasoning...", + "distractor_explanations": [ + "Why B is incorrect with specific differences...", + "Why C is incorrect with specific differences...", + "Why D is incorrect with specific differences...", + "Why E is incorrect with specific differences..." + ] + } +} +``` diff --git a/prompts/generate_comparison_table.j2 b/prompts/generate_comparison_table.j2 new file mode 100644 index 0000000000000000000000000000000000000000..21244d8232806a06398ae69aa97a94efecdb6d7f --- /dev/null +++ b/prompts/generate_comparison_table.j2 @@ -0,0 +1,77 @@ +You are an expert medical education specialist creating advanced board exam questions for healthcare professionals. + +Generate a comprehensive comparison table for differential diagnosis of: {{ topic }} + +{% if guideline_context and guideline_context != "No specific guidelines found. Use standard medical knowledge." %} +**EVIDENCE-BASED CONTEXT:** +{{ guideline_context }} + +**Important:** Base your comparison table on the above evidence-based guidelines and current medical literature. +{% else %} +**Context:** No specific guidelines available. Use current standard medical knowledge and evidence-based practice. +{% endif %} + +**Instructions:** +1. Create a comparison table with 5 conditions: + - 1 CORRECT answer (the actual condition) + - 4 plausible DISTRACTORS (similar conditions that could be confused) + +2. For each condition, provide specific clinical details: + - **Clinical Presentation**: Specific symptoms, timeline, severity + - **Epidemiology/Risk Factors**: Patient demographics, exposures, comorbidities + - **Laboratory Findings**: Specific lab values, patterns, biomarkers + - **Imaging Characteristics**: Specific radiologic findings, patterns + - **Diagnostic Tests**: Confirmatory tests, sensitivity/specificity + - **Treatment**: First-line therapy, dosing, duration + - **Clinical Reasoning**: Why this diagnosis fits or doesn't fit + +3. **Quality Requirements for DIFFICULT Board Questions:** + - Use specific medical details (lab values, imaging findings, medications) + - Include discriminating features that distinguish conditions + - **CRITICAL: Create diagnostic complexity** - include misleading information from differential diagnoses + - **Clue Distribution Rule**: Correct diagnosis should have exactly 3 clear diagnostic clues, distractors should have 1-2 misleading clues + - Ensure clinical accuracy and board exam relevance + - Make distractors plausible with overlapping presentations + - **Avoid obvious giveaways** (no classic exposure history + classic symptoms + confirmatory tests) + - Reference current guidelines and evidence-based practice when available + +4. **Difficulty Enhancement Requirements:** + - **Confusing Clinical Picture**: Include symptoms/findings that could suggest multiple diagnoses + - **Overlapping Presentations**: Each distractor should share some features with the correct answer + - **Subtle Distinguishing Features**: The correct diagnosis should be evident only through careful analysis + - **Expert-Level Reasoning Required**: Should challenge ID fellows/attendings, not be obvious to general internists + +4. **Difficulty Level**: {{ difficulty_level }} +5. **Question Type**: {{ question_type }} + +**Output Format:** +Return a JSON object with this exact structure: +```json +{ + "correct_answer": { + "condition": "Exact condition name", + "clinical_presentation": "Detailed clinical features", + "epidemiology_risk_factors": "Specific risk factors and demographics", + "laboratory_findings": "Specific lab values and patterns", + "imaging_characteristics": "Detailed imaging findings", + "diagnostic_tests": "Confirmatory diagnostic approaches", + "treatment": "Specific treatment recommendations", + "clinical_reasoning": "Why this is the most likely diagnosis" + }, + "distractor_1": { + "condition": "Alternative diagnosis 1", + "clinical_presentation": "How this condition typically presents", + "epidemiology_risk_factors": "Risk factors for this condition", + "laboratory_findings": "Lab pattern for this condition", + "imaging_characteristics": "Imaging findings for this condition", + "diagnostic_tests": "How to diagnose this condition", + "treatment": "Treatment for this condition", + "clinical_reasoning": "Why this could be considered but is less likely" + }, + "distractor_2": { ... }, + "distractor_3": { ... }, + "distractor_4": { ... } +} +``` + +Focus on creating clinically accurate, educationally valuable content that tests advanced medical reasoning. diff --git a/prompts/generate_presentation_slide.j2 b/prompts/generate_presentation_slide.j2 new file mode 100644 index 0000000000000000000000000000000000000000..46a6d8833679e61cd449dd594035127b88fd0b94 --- /dev/null +++ b/prompts/generate_presentation_slide.j2 @@ -0,0 +1,65 @@ +You are an expert medical educator creating comprehensive educational presentation slides. Your task is to generate detailed, educational content for a specific slide based on research information. + +**Presentation Context:** +- Topic: {{ topic }} +- Target Audience: {{ target_audience }} +- Slide Title: {{ slide_title }} +- Section: {{ section }} +- Content Description: {{ content_description }} + +**Research Information Available:** +{{ research_report }} + +**Slide Content Requirements:** + +1. **Educational Value**: Content should be appropriate for {{ target_audience }} level +2. **Clinical Relevance**: Include practical, clinically applicable information +3. **Evidence-Based**: Use the research information to support all claims +4. **Structured Format**: Organize content in clear, digestible bullet points +5. **Engagement**: Make content engaging and memorable + +**Content Guidelines:** +- Use 4-6 main bullet points per slide +- Each bullet point should be concise but informative (1-2 sentences) +- Include specific clinical details, dosages, guidelines when appropriate +- Add sub-bullets for important details or examples +- Ensure content flows logically and builds knowledge + +**Special Instructions by Slide Type:** +- **Learning Objectives**: Focus on measurable, achievable goals +- **Case Vignettes**: Create realistic, detailed clinical scenarios +- **Pathophysiology**: Explain mechanisms clearly with clinical correlation +- **Diagnosis**: Include specific tests, criteria, and interpretation +- **Treatment**: Provide evidence-based protocols, dosages, monitoring +- **Guidelines**: Reference current society recommendations +- **Clinical Pearls**: Share practical tips and expert insights + +**Output Format:** +Return a JSON object with this exact structure: +```json +{ + "slide_title": "{{ slide_title }}", + "main_content": [ + "First main bullet point with clinical detail", + "Second main bullet point with evidence-based information", + "Third main bullet point with practical application", + "Fourth main bullet point with specific examples", + "Fifth main bullet point with clinical correlation", + "Sixth main bullet point with key takeaways" + ], + "sub_bullets": { + "First main bullet point": [ + "Supporting detail or example", + "Additional clinical correlation" + ], + "Third main bullet point": [ + "Specific dosage or protocol", + "Monitoring parameters" + ] + }, + "clinical_notes": "Key clinical insights, warnings, or pearls for this slide content", + "references_used": "Brief mention of which research sources informed this content" +} +``` + +Generate comprehensive, educationally valuable content that advances the learner's understanding of {{ topic }}. diff --git a/prompts/generate_question_blueprint.j2 b/prompts/generate_question_blueprint.j2 new file mode 100644 index 0000000000000000000000000000000000000000..4dc9f33b655ee03c9dc959327f4c368be23780af --- /dev/null +++ b/prompts/generate_question_blueprint.j2 @@ -0,0 +1,87 @@ +You are an expert Infectious Diseases fellowship board exam question designer. + +Your task is to create a strategic blueprint for a challenging ID board exam question. + +**TOPIC:** {{ topic }} +**DIFFICULTY LEVEL:** {{ difficulty_level }} +**QUESTION TYPE:** {{ question_type }} + +**YOUR MISSION:** +Create a detailed blueprint that outlines the strategy for a challenging ID fellowship-level question. +Think like an expert test writer planning how to create a question that will challenge ID specialists. + +**BLUEPRINT REQUIREMENTS:** + +1. **Scenario Description:** Describe the clinical scenario you will use (e.g., "immunocompromised patient with new onset fever and pulmonary nodules") + +2. **Primary Diagnosis:** The correct answer you want to test + +3. **Differential Diagnoses:** List exactly 5 differential diagnoses that will become your answer choices: + - Position 1: The correct diagnosis + - Positions 2-5: Plausible but incorrect differential diagnoses + +4. **Diagnostic Clues Strategy:** Plan your clues carefully: + - **Supporting Primary:** List 3-4 clues that support the correct diagnosis + - **Misleading Clues:** List 2-3 clues that might initially mislead toward other diagnoses + - **Geographic/Exposure Clues:** If using travel/exposure, specify how they support or mislead + +5. **Wrong Answer Reasoning:** For each wrong answer, explain exactly why it will be wrong: + - Example: "Blastomyces will be wrong because the yeast description will be small 2-4 microns" + - Example: "Coccidioidomycosis will be wrong based on the patient living location" + - Example: "Aspergillus and mucor will be wrong because the BAL growing yeast not mold" + +**EXAMPLE BLUEPRINT FORMAT:** + +For the topic "Penicillium marneffei": +- **Scenario:** "Immunocompromised patient with new onset fever and pulmonary nodules" +- **Primary Diagnosis:** Histoplasma capsulatum +- **Differentials:** Histoplasma, Blastomyces, Coccidioidomycosis, Aspergillus, Mucormycosis +- **Supporting Clues:** Patient lives in Mississippi, has a parrot pet, BAL culture growing yeast +- **Wrong Answer Logic:** + - Blastomyces: Wrong because yeast description will be small 2-4 microns + - Cocci: Wrong based on patient living location + - Aspergillus/Mucor: Wrong because BAL growing yeast not mold + +**CRITICAL RULES:** +- NO obvious diagnostic giveaways (avoid classic travel + classic symptoms) +- Each wrong answer must have a clear, specific reason why it's wrong +- Include 3+ supporting clues for correct answer +- Add 1-2 misleading clues to increase difficulty +- Ensure ID fellowship-level sophistication + +**OUTPUT FORMAT:** +Return a JSON object with this exact structure: + +```json +{ + "scenario_description": "Brief description of the clinical scenario", + "primary_diagnosis": "The correct diagnosis", + "differential_diagnoses": [ + "Correct diagnosis (position 1)", + "Wrong diagnosis 2", + "Wrong diagnosis 3", + "Wrong diagnosis 4", + "Wrong diagnosis 5" + ], + "diagnostic_clues": { + "supporting_primary": [ + "Clue 1 supporting correct diagnosis", + "Clue 2 supporting correct diagnosis", + "Clue 3 supporting correct diagnosis" + ], + "misleading_clues": [ + "Misleading clue 1", + "Misleading clue 2" + ] + }, + "wrong_answer_reasoning": { + "wrong_diagnosis_2": "Specific reason why this diagnosis is wrong", + "wrong_diagnosis_3": "Specific reason why this diagnosis is wrong", + "wrong_diagnosis_4": "Specific reason why this diagnosis is wrong", + "wrong_diagnosis_5": "Specific reason why this diagnosis is wrong" + }, + "reasoning_strategy": "Overall strategy for how this question will challenge ID specialists" +} +``` + +Create a sophisticated blueprint that will result in a challenging ID fellowship-level question! diff --git a/prompts/history_taking.j2 b/prompts/history_taking.j2 new file mode 100644 index 0000000000000000000000000000000000000000..77f6293dac01f97f6451a6db76dde5ff895d891a --- /dev/null +++ b/prompts/history_taking.j2 @@ -0,0 +1,9 @@ +{% raw %} +[ + {"role": "system", "content": + "You are an infectious diseases consultant preparing to advise a clinician. But first, you must gather the appropriate clinical history."}, + + {"role": "user", "content": + "The user described the concern as: '{{ syndrome_query }}'.\n\nHere is a list of questions to ask the user based on your knowledge base:\n\n{{ questions | join('\\n') }}\n\nNow, ask the user these questions one at a time, in a conversational and efficient way. Do not give advice yet."} +] +{% endraw %} diff --git a/prompts/ipc_reporting.j2 b/prompts/ipc_reporting.j2 new file mode 100644 index 0000000000000000000000000000000000000000..e4dc710aa7f6d87cf9049512fc3fa062422efb4c --- /dev/null +++ b/prompts/ipc_reporting.j2 @@ -0,0 +1,14 @@ +{% raw %} + +[ + { + "role": "system", + "content": "You are an IPC reporting assistant. Your only way to get reporting requirements is by calling the `IPC_reporting` function. Do NOT free-text beyond gathering inputs for that function call." + }, + { + "role": "user", + "content": "Case summary: {{ case_summary }}\nJurisdiction: {{ jurisdiction }}" + } +] + +{% endraw %} \ No newline at end of file diff --git a/prompts/ipc_reporting_followup.j2 b/prompts/ipc_reporting_followup.j2 new file mode 100644 index 0000000000000000000000000000000000000000..2a15c579c8d6af7de8c185c0559e6cfbaecc0ef7 --- /dev/null +++ b/prompts/ipc_reporting_followup.j2 @@ -0,0 +1,18 @@ +{% raw %} +[ + { + "role": "system", + "content": "You have collected these fields: {{ collected_fields }}.\n\n" + "β€’ If any required fields are still missing, ask the user one at a time:\n" + " β€œPlease provide the :”\n\n" + "β€’ Once **all** fields are collected, output **only** a JSON object wrapped in … tags, for example:\n\n" + "\n" + "{\n" + " \"meets_definition\": true,\n" + " \"reasoning\": \"The patient had a central line in place for more than 2 days and no other sources identified.\"\n" + "}\n" + "\n\n" + "Do **not** output any additional text." + } +] +{% endraw %} diff --git a/prompts/isolation_precautions.j2 b/prompts/isolation_precautions.j2 new file mode 100644 index 0000000000000000000000000000000000000000..01293921e8eb299b27821e74c39c99bbd33ed3f6 --- /dev/null +++ b/prompts/isolation_precautions.j2 @@ -0,0 +1,13 @@ +{% raw %} +[ + { + "role": "system", + "content": "You are an infection prevention specialist. Interpret isolation precautions guidelines." + }, + { + "role": "user", + "content": "Patient diagnosis: {{ diagnosis }}\nSymptoms: {{ symptoms }}\nKnown pathogens: {{ pathogen_list }}\n\nUsing CDC/APIC recommendations:\n- List required precautions (e.g., Contact, Droplet, Airborne).\n- Mention any special room or PPE requirements." + } +] + +{% endraw %} diff --git a/prompts/nhsn_criteria_evaluator_followup.j2 b/prompts/nhsn_criteria_evaluator_followup.j2 new file mode 100644 index 0000000000000000000000000000000000000000..2ba9980c1054e80f9def9d4819001921076899da --- /dev/null +++ b/prompts/nhsn_criteria_evaluator_followup.j2 @@ -0,0 +1,17 @@ + +[ + { + "role": "system", + "content": "I have these required fields: {{ required_fields }} \n" + "Ask the user for **each** missing field one at a time, using exactly:\n\n" + β€œPlease provide the :”\n\n + "When you have all values, output a single JSON object wrapped in … with keys:\n" + ```json + { + "meets_definition": true|false, + "reasoning": "..." + } + ``` + "Do **not** output any other text." + } +] diff --git a/prompts/nhsn_criteria_evaluator_start.j2 b/prompts/nhsn_criteria_evaluator_start.j2 new file mode 100644 index 0000000000000000000000000000000000000000..c8dbf60d988ceb0faacfee9ee92607b2751a8b9a --- /dev/null +++ b/prompts/nhsn_criteria_evaluator_start.j2 @@ -0,0 +1,16 @@ + +[ + { + "role": "system", + "content": "You are an expert in NHSN surveillance definitions. \n" + "Your **only** output must be a JSON array of required data elements, wrapped in … tags. \n" + "Do **not** output any other text." + }, + { + "role": "user", + "content": "Patient case: {{ case_description }} \n" + "Extract and return only the array of required NHSN fields." + } +] + + diff --git a/prompts/quality_review_board_exam.j2 b/prompts/quality_review_board_exam.j2 new file mode 100644 index 0000000000000000000000000000000000000000..216b54462cc1cb66100147661cc27be10a421c5c --- /dev/null +++ b/prompts/quality_review_board_exam.j2 @@ -0,0 +1,83 @@ +You are a stringent medical education quality assurance expert with a focus on creating MAXIMUM DIFFICULTY board exam questions. Your job is to be highly critical and identify every flaw that makes questions too easy or gives away answers. + +**Question to Review:** +**Topic**: {{ topic }} +**Vignette**: {{ vignette }} +**Question**: {{ question_stem }} +**Answer Choices**: {{ answer_choices }} +**Explanations**: {{ explanations }} + +**CRITICAL FLAW DETECTION - BE RUTHLESS:** + +🚨 **ANSWER GIVEAWAYS IN VIGNETTE** (Automatic score reduction): +- Does vignette mention specific test results revealing diagnosis? (e.g., "positive Coccidioides serology", "fungal pathogen antibodies") +- Are there overly specific findings that make answer obvious? +- Does travel history + specific test results = obvious diagnosis? +- Are there unnecessary diagnostic details that eliminate differential? + +🚨 **ANSWER GIVEAWAYS IN QUESTION STEM** (Automatic score reduction): +- Does question mention suspected diagnosis? (e.g., "suspected coccidioidomycosis", "this TB patient") +- Does wording hint at correct answer category? +- Is question diagnostically neutral or biased? + +🚨 **INSUFFICIENT DISTRACTORS IN VIGNETTE** (Major weakness): +- Could misleading clinical findings support alternative diagnoses? +- Are there missing symptoms that would make other conditions plausible? +- Could lab results include red herrings or be more ambiguous? +- Does vignette need MORE confounding factors? + +🚨 **TOO EASY FOR BOARD LEVEL** (Difficulty failure): +- Can residents easily eliminate distractors? +- Does this require fellowship-level reasoning or just pattern recognition? +- Are distractors clinically implausible to experienced physicians? + +**Quality Assessment Criteria:** + +1. **Clinical Accuracy (0-5 points)**: Medical facts, dosages, procedures correct +2. **Educational Value (0-5 points)**: Tests important clinical reasoning +3. **Difficulty Appropriateness (0-5 points)**: Requires advanced clinical reasoning, fellowship-level knowledge +4. **Vignette Quality (0-5 points)**: Complex, realistic, includes distractors, NO giveaways +5. **Answer Choice Quality (0-5 points)**: All options plausible to experienced physicians + +**SCORING PENALTIES:** +- Automatic -2 points if vignette gives away diagnosis +- Automatic -2 points if question stem mentions suspected diagnosis +- Automatic -1 point if distractors are too easily eliminated +- Automatic -1 point if insufficient clinical complexity + +**Assessment Instructions:** +- BE HIGHLY CRITICAL - assume most questions are too easy +- Demand maximum difficulty appropriate for ID fellowship/board certification +- Suggest adding MORE distractors and confounding factors +- Eliminate ANY details that make diagnosis obvious + +**Output Format:** +Return a JSON object with this exact structure: +```json +{ + "clinical_accuracy_score": 4, + "educational_value_score": 5, + "difficulty_score": 3, + "vignette_quality_score": 4, + "answer_choice_quality_score": 4, + "total_score": 20, + "percentage_score": 80, + "quality_level": "EXCELLENT/GOOD/ADEQUATE/NEEDS_IMPROVEMENT/POOR", + "strengths": [ + "Specific strength 1", + "Specific strength 2" + ], + "weaknesses": [ + "Specific weakness 1", + "Specific weakness 2" + ], + "improvement_suggestions": [ + "Specific suggestion 1", + "Specific suggestion 2" + ], + "board_exam_readiness": true/false, + "overall_assessment": "Detailed summary of question quality and recommendations" +} +``` + +Provide honest, constructive feedback to ensure the highest quality medical education content. diff --git a/prompts/summarize_antibiotic_duration.j2 b/prompts/summarize_antibiotic_duration.j2 new file mode 100644 index 0000000000000000000000000000000000000000..26b2467b947f021d9fe02580b511d622d7f08be1 --- /dev/null +++ b/prompts/summarize_antibiotic_duration.j2 @@ -0,0 +1,13 @@ +{% raw %} +[ + { + "role": "system", + "content": "You are an expert in infectious diseases guidelines. Summarize the recommended antibiotic duration for {{ condition }} using the IDSA search results first, then any other society guidelines." + }, + { + "role": "user", + "content": "IDSA results:\n{{ idsa_results }}\n\nOther society results:\n{{ other_results }}\n\nPlease provide:\n1. The recommended duration(s) with your source for each.\n2. A brief rationale if durations differ." + } +] + +{% endraw %} diff --git a/requirements.txt b/requirements.txt index e87ffa0106e1565ff5ad7e1c9678424db892d07d..6d1ef9ddac99a9322b1315d32e6a0d7cbcc01ac0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,60 @@ +# ID Agents Production Requirements +# Updated: 2025-08-10 15:54:20 +# Python 3.12.7 +# +# This file contains pinned versions for reproducible deployments +# Tested and verified working in development environment + +# Core AI/ML Framework +openai>=1.3.0 gradio==4.40.0 + +# Data Processing & Scientific Computing +pandas>=2.0.0 +numpy>=1.24.0 + +# Document Processing +pdfplumber +python-docx + +# Web Requests & Search +requests>=2.31.0 +duckduckgo-search>=4.0.0 + +# Configuration & Environment Management +python-dotenv>=1.0.0 + +# Authentication & Security +bcrypt>=4.0.0 +PyJWT>=2.8.0 + +# Logging & Monitoring +structlog>=23.0.0 + +# Template Processing +jinja2 + +# Analytics & Visualization (optional) +matplotlib>=3.5.0 + +# Advanced AI Libraries (optional - enable for enhanced features) +# Uncomment the following if you need advanced AI capabilities: +# transformers +# langchain +# autogen +# llama_index +# farm-haystack +# faiss-cpu + +# Development & Testing (optional - remove for production) +# pytest + +# Load Testing Dependencies +aiohttp>=3.8.0 +psutil>=5.9.0 + +# Notes: +# - All core dependencies use minimum version pinning (>=) +# - Advanced AI libraries are commented out to reduce deployment size +# - Uncomment additional libraries as needed for specific features +# - For strict version pinning, replace >= with == and specific versions diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tools/alert_prolonged_antibiotic_use.py b/tools/alert_prolonged_antibiotic_use.py new file mode 100644 index 0000000000000000000000000000000000000000..2a1b270dad2478d689ccbd4929f0eadb9e8a8352 --- /dev/null +++ b/tools/alert_prolonged_antibiotic_use.py @@ -0,0 +1,119 @@ +from tools.base import Tool + + +from tools.utils import ToolExecutionError, logger +from typing import Any + +class AlertProlongedAntibioticUseTool(Tool): + + def openai_spec(self, legacy=False): + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + """ + Tool to summarize recommended antibiotic durations for a given condition based on guidelines. + + This tool searches IDSA and other society guidelines to provide a summary of recommended antibiotic durations for a specified clinical condition. + """ + def __init__(self) -> None: + """ + Initialize the AlertProlongedAntibioticUseTool with its name, description, and argument schema. + """ + super().__init__() + self.name = "alert_prolonged_antibiotic_use" + self.description = ( + "Search IDSA (and then other society) guidelines to summarize " + "recommended antibiotic durations for a given condition, considering all relevant clinical variables." + ) + self.args_schema = { + "type": "object", + "properties": { + "condition": { + "type": "string", + "description": "Clinical condition to lookup antibiotic duration for" + }, + "site_of_infection": { + "type": "string", + "description": "Site of infection (e.g., lung, urine, blood, etc.)" + }, + "risk_of_biofilm": { + "type": "string", + "description": "Risk or presence of biofilm (e.g., prosthetic material, indwelling devices)" + }, + "current_response": { + "type": "string", + "description": "Current response to antibiotics (e.g., improving, stable, worsening)" + }, + "creatinine_clearance": { + "type": "string", + "description": "Creatinine clearance or renal function (e.g., 60 mL/min, ESRD, etc.)" + }, + "severity_of_infection": { + "type": "string", + "description": "Severity of infection (e.g., mild, moderate, severe, septic shock)" + }, + "known_allergies": { + "type": "string", + "description": "Known drug allergies (e.g., penicillin, sulfa, none)" + } + }, + "required": [ + "condition", + "site_of_infection", + "risk_of_biofilm", + "current_response", + "creatinine_clearance", + "severity_of_infection", + "known_allergies" + ] + } + + async def run( + self, + condition: str, + site_of_infection: str, + risk_of_biofilm: str, + current_response: str, + creatinine_clearance: str, + severity_of_infection: str, + known_allergies: str + ) -> str: + """ + Summarize recommended antibiotic duration for a given clinical condition, considering all relevant clinical variables. + + Args: + condition (str): Clinical condition to lookup antibiotic duration for. + site_of_infection (str): Site of infection. + risk_of_biofilm (str): Risk or presence of biofilm. + current_response (str): Current response to antibiotics. + creatinine_clearance (str): Renal function. + severity_of_infection (str): Severity of infection. + known_allergies (str): Known drug allergies. + + Returns: + str: The antibiotic duration summary (placeholder). + + Raises: + ToolExecutionError: If the tool fails to summarize duration. + """ + try: + # Placeholder for actual search and summarization logic + return ( + f"Antibiotic duration summary for: {condition}\n" + f"Site of infection: {site_of_infection}\n" + f"Risk of biofilm: {risk_of_biofilm}\n" + f"Current response: {current_response}\n" + f"Creatinine clearance: {creatinine_clearance}\n" + f"Severity: {severity_of_infection}\n" + f"Allergies: {known_allergies}" + ) + except Exception as e: + logger.error(f"AlertProlongedAntibioticUseTool failed: {e}", exc_info=True) + raise ToolExecutionError( + message=f"AlertProlongedAntibioticUseTool failed: {e}", + code="ANTIBIOTIC_DURATION_ERROR", + user_message="Unable to summarize antibiotic duration. Please try again or contact support.", + original_exception=e + ) diff --git a/tools/base.py b/tools/base.py new file mode 100644 index 0000000000000000000000000000000000000000..7e284fd9ab468911b03c3cca7600e85030e95ca5 --- /dev/null +++ b/tools/base.py @@ -0,0 +1,56 @@ + +from typing import Any, Dict +from tools.utils import ToolExecutionError, logger + +class Tool: + def openai_spec(self, legacy=False): + """ + Return the OpenAI function calling spec for this tool. + Subclasses should override this if they support function calling. + """ + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + """ + Base class for all tools in the agent framework. + + Attributes: + name (str): The unique name of the tool. + description (str): A short description of the tool's purpose. + args_schema (dict): The schema describing the tool's input arguments. + """ + name: str + description: str + args_schema: Dict[str, Any] + + def __init__(self) -> None: + """ + Initialize the base Tool with default values. + Subclasses should override these attributes as needed. + """ + self.name = "tool" + self.description = "" + self.args_schema = {} + + async def run(self, **kwargs: Any) -> Any: + """ + Run the tool with the provided arguments. + Subclasses must implement this method. + + Args: + **kwargs: Arbitrary keyword arguments for the tool. + + Returns: + Any: The result of the tool's execution. + + Raises: + ToolExecutionError: If the method is not implemented by a subclass. + """ + logger.error(f"Tool '{self.name}' does not implement the run method.") + raise ToolExecutionError( + message=f"Tool '{self.name}' does not implement the run method.", + code="TOOL_NOT_IMPLEMENTED", + user_message="This tool is not available. Please contact support.", + ) diff --git a/tools/create_educational_presentation.py b/tools/create_educational_presentation.py new file mode 100644 index 0000000000000000000000000000000000000000..12cc28cca59244d9295bf5eedf6559ee305022e9 --- /dev/null +++ b/tools/create_educational_presentation.py @@ -0,0 +1,1308 @@ +""" +create_educational_presentation.py +--------------------------------- + +Tool for creating comprehensive educational presentations through iterative research. + +This tool conducts deep research on medical topics, creates detailed reports, and converts +them into structured slide presentations for educational purposes. It uses an iterative +research approach with user confirmation before finalizing the presentation. + +Key Features: +- Iterative internet research with 4-5 rounds of 3-5 pages each +- User clarification questions before research +- Comprehensive report generation +- Structured slide presentation creation +- Educational flow: objectives β†’ vignette β†’ education β†’ application β†’ Q&A +""" + +import asyncio +import json +from typing import Any, Dict, List, Union +from tools.base import Tool +from tools.utils import ToolExecutionError, logger, load_prompt +from core.utils.llm_connector import call_llm +from tools.internet_search import InternetSearchTool + +class CreateEducationalPresentationTool(Tool): + """ + Tool for creating comprehensive educational presentations through iterative research. + + This tool conducts deep research, creates detailed reports, and converts them into + structured slide presentations for educational purposes. + """ + + def __init__(self) -> None: + """Initialize the CreateEducationalPresentationTool.""" + super().__init__() + self.name = "create_educational_presentation" + self.description = "Create comprehensive educational presentations through AI-powered dynamic research and content generation." + self.internet_search = InternetSearchTool() + self.args_schema = { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "The medical topic for the educational presentation (e.g., 'sepsis management', 'heart failure diagnosis', 'antibiotic stewardship')" + }, + "target_audience": { + "type": "string", + "description": "The target audience for the presentation", + "enum": ["medical_students", "residents", "attendings", "nurses", "pharmacists", "multidisciplinary"], + "default": "medical_students" + }, + "presentation_duration": { + "type": "integer", + "description": "Expected duration of presentation in minutes", + "default": 45, + "minimum": 15, + "maximum": 120 + }, + "focus_area": { + "type": "string", + "description": "Specific focus area within the topic", + "default": "comprehensive_overview" + }, + "aspects_to_emphasize": { + "type": "string", + "description": "What specific aspects to emphasize (e.g., 'pathophysiology, diagnosis, treatment')" + }, + "guidelines_to_include": { + "type": "string", + "description": "Specific guidelines or evidence to include (e.g., 'IDSA guidelines')" + }, + "learning_objectives": { + "type": "string", + "description": "What should the audience learn (e.g., 'diagnostic skills, treatment decisions')" + }, + "clinical_scenarios": { + "type": "string", + "description": "Specific clinical scenarios to highlight (e.g., 'common presentations')" + }, + "takeaway_message": { + "type": "string", + "description": "Key clinical pearl or takeaway message (e.g., 'early recognition saves lives')" + } + }, + "required": ["topic"] + } + + def openai_spec(self, legacy=False): + """Return OpenAI function specification.""" + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + + async def run( + self, + topic: str, + target_audience: str = "medical_students", + presentation_duration: int = 45, + focus_area: str = "comprehensive_overview", + aspects_to_emphasize: Union[str, None] = None, + guidelines_to_include: Union[str, None] = None, + learning_objectives: Union[str, None] = None, + clinical_scenarios: Union[str, None] = None, + takeaway_message: Union[str, None] = None + ) -> Dict[str, Any]: + """ + Create a comprehensive educational presentation through iterative research. + + Args: + topic (str): The medical topic for the presentation + target_audience (str): The target audience + presentation_duration (int): Duration in minutes + focus_area (str): Specific focus area + aspects_to_emphasize (str): What specific aspects to emphasize + guidelines_to_include (str): Specific guidelines or evidence to include + learning_objectives (str): What should the audience learn + clinical_scenarios (str): Specific clinical scenarios to highlight + takeaway_message (str): Key clinical pearl or takeaway message + + Returns: + Dict[str, Any]: Complete presentation with research, report, and slides + """ + try: + logger.info(f"Starting educational presentation creation for topic: {topic}") + + # Build clarification responses from provided parameters + clarification_responses = {} + + # Check if we have enough information to proceed + if aspects_to_emphasize and guidelines_to_include and learning_objectives and clinical_scenarios and takeaway_message: + clarification_responses = { + "aspects": aspects_to_emphasize, + "guidelines": guidelines_to_include, + "learning_objectives": learning_objectives, + "clinical_scenarios": clinical_scenarios, + "takeaway_message": takeaway_message + } + else: + # Use intelligent defaults based on the topic and focus area + clarification_responses = self._generate_intelligent_defaults(topic, target_audience, focus_area) + logger.info(f"Using intelligent defaults for presentation creation") + + # Proceed with full presentation creation + logger.info(f"Proceeding with presentation creation using responses") + + # Step 2: Conduct iterative research + research_results = await self._conduct_iterative_research(topic, clarification_responses) + + # Step 3: Generate comprehensive report + research_report = self._generate_research_report(topic, research_results, clarification_responses) + + # Step 4: Create presentation structure + presentation_structure = self._create_presentation_structure( + topic, target_audience, presentation_duration, research_report + ) + + # Step 5: Create final presentation using existing method + final_presentation = await self.create_final_presentation( + topic, target_audience, presentation_duration, research_report, + presentation_structure, "" + ) + + logger.info(f"Successfully created educational presentation for {topic}") + return final_presentation + + except Exception as e: + logger.error(f"CreateEducationalPresentationTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"Failed to create educational presentation: {e}") + + async def continue_with_research( + self, + topic: str, + target_audience: str, + presentation_duration: int, + focus_area: str, + clarification_responses: Dict[str, str] + ) -> Dict[str, Any]: + """ + Continue with research phase after receiving clarification responses. + + Args: + topic (str): The medical topic + target_audience (str): Target audience + presentation_duration (int): Duration in minutes + focus_area (str): Focus area + clarification_responses (Dict[str, str]): User responses to clarification questions + + Returns: + Dict[str, Any]: Research results and next steps + """ + try: + logger.info(f"Continuing with research for topic: {topic}") + + # Step 2: Conduct iterative research + research_results = await self._conduct_iterative_research(topic, clarification_responses) + + # Step 3: Generate comprehensive report + research_report = self._generate_research_report(topic, research_results, clarification_responses) + + # Step 4: Create presentation structure + presentation_structure = self._create_presentation_structure( + topic, target_audience, presentation_duration, research_report + ) + + return { + "status": "research_complete", + "topic": topic, + "target_audience": target_audience, + "presentation_duration": presentation_duration, + "research_results": research_results, + "research_report": research_report, + "proposed_structure": presentation_structure, + "next_step": "Please review the research report and presentation structure. Confirm to proceed with slide creation." + } + + except Exception as e: + logger.error(f"Research phase failed: {e}", exc_info=True) + raise ToolExecutionError(f"Failed to complete research: {e}") + + async def create_final_presentation( + self, + topic: str, + target_audience: str, + presentation_duration: int, + research_report: str, + presentation_structure: Dict[str, Any], + user_feedback: str = "" + ) -> Dict[str, Any]: + """ + Create the final presentation slides. + + Args: + topic (str): The medical topic + target_audience (str): Target audience + presentation_duration (int): Duration in minutes + research_report (str): The research report + presentation_structure (Dict): Presentation structure + user_feedback (str): User feedback on structure + + Returns: + Dict[str, Any]: Complete presentation with slides + """ + try: + logger.info(f"Creating final presentation for topic: {topic}") + + # Adjust structure based on user feedback if provided + if user_feedback: + presentation_structure = self._adjust_structure_based_on_feedback( + presentation_structure, user_feedback + ) + + # Generate all slides + slides = await self._generate_all_slides( + topic, target_audience, research_report, presentation_structure + ) + + # Create speaker notes + speaker_notes = self._generate_speaker_notes(slides, research_report) + + # Generate presentation metadata + presentation_metadata = self._generate_presentation_metadata( + topic, target_audience, presentation_duration, len(slides) + ) + + return { + "status": "presentation_complete", + "topic": topic, + "target_audience": target_audience, + "presentation_duration": presentation_duration, + "total_slides": len(slides), + "slides": slides, + "speaker_notes": speaker_notes, + "metadata": presentation_metadata, + "research_report": research_report, + "created_date": "2025-07-18" + } + + except Exception as e: + logger.error(f"Final presentation creation failed: {e}", exc_info=True) + raise ToolExecutionError(f"Failed to create final presentation: {e}") + + def _generate_clarification_questions(self, topic: str, target_audience: str, focus_area: str) -> List[Dict[str, str]]: + """Generate 3-5 clarification questions for the user.""" + + questions = [ + { + "question": f"What specific aspects of {topic} would you like to emphasize in this presentation?", + "purpose": "To focus the research on the most relevant areas", + "examples": "e.g., pathophysiology, diagnosis, treatment, recent advances, guidelines" + }, + { + "question": f"Are there any specific guidelines, studies, or evidence you want to include?", + "purpose": "To ensure important references are included", + "examples": "e.g., specific society guidelines, landmark studies, recent publications" + }, + { + "question": f"What learning objectives should the {target_audience} achieve after this presentation?", + "purpose": "To structure the educational content appropriately", + "examples": "e.g., diagnostic skills, treatment decisions, understanding pathophysiology" + }, + { + "question": f"Are there any specific clinical scenarios or patient populations you want to highlight?", + "purpose": "To create relevant clinical vignettes", + "examples": "e.g., pediatric patients, elderly, specific comorbidities, severity levels" + }, + { + "question": f"What should be the takeaway message or key clinical pearl from this presentation?", + "purpose": "To ensure the presentation has a clear, memorable message", + "examples": "e.g., early recognition saves lives, personalized treatment approach, guideline adherence" + } + ] + + return questions + + def _generate_intelligent_defaults(self, topic: str, target_audience: str, focus_area: str) -> Dict[str, str]: + """ + Generate intelligent default responses based on topic and focus area. + + Args: + topic (str): The medical topic + target_audience (str): Target audience + focus_area (str): Focus area + + Returns: + Dict[str, str]: Intelligent default responses + """ + try: + # Topic-specific intelligent defaults + topic_lower = topic.lower() + + if "dimorphic fungi" in topic_lower or "fungal" in topic_lower: + return { + "aspects": "comprehensive coverage including pathophysiology, diagnosis, treatment, epidemiology, and clinical presentations", + "guidelines": "IDSA guidelines and recent evidence-based recommendations", + "learning_objectives": "comprehensive understanding of diagnosis, treatment, and key clinical presentations for board exam preparation", + "clinical_scenarios": "common clinical presentations of each dimorphic fungus including histoplasmosis, coccidioidomycosis, blastomycosis, and others", + "takeaway_message": "systematic approach to diagnosis and management with focus on board exam question patterns" + } + elif "sepsis" in topic_lower: + return { + "aspects": "pathophysiology, early recognition, diagnosis, management, and outcomes", + "guidelines": "Surviving Sepsis Campaign guidelines and recent updates", + "learning_objectives": "early recognition, appropriate management, and outcome improvement", + "clinical_scenarios": "emergency department presentations, ICU management, and complications", + "takeaway_message": "early recognition and prompt treatment save lives" + } + elif "heart failure" in topic_lower: + return { + "aspects": "pathophysiology, classification, diagnosis, management, and prognosis", + "guidelines": "ACC/AHA heart failure guidelines", + "learning_objectives": "diagnostic skills, treatment optimization, and guideline adherence", + "clinical_scenarios": "acute decompensated heart failure, chronic management, and comorbidities", + "takeaway_message": "guideline-directed medical therapy improves outcomes" + } + else: + # Generic intelligent defaults + return { + "aspects": "comprehensive coverage including pathophysiology, diagnosis, treatment, and recent advances", + "guidelines": "latest evidence-based guidelines from relevant professional societies", + "learning_objectives": "comprehensive understanding of diagnosis, treatment, and key clinical pearls", + "clinical_scenarios": "common clinical presentations and real-world case studies", + "takeaway_message": "evidence-based approach to diagnosis and management" + } + + except Exception as e: + logger.warning(f"Failed to generate intelligent defaults: {e}") + # Fallback to basic defaults + return { + "aspects": "comprehensive overview of the topic", + "guidelines": "current evidence-based guidelines", + "learning_objectives": "understanding of key concepts", + "clinical_scenarios": "common clinical presentations", + "takeaway_message": "evidence-based clinical approach" + } + + async def _conduct_iterative_research(self, topic: str, clarification_responses: Dict[str, str]) -> Dict[str, Any]: + """Conduct 4-5 rounds of iterative research.""" + + research_results = { + "rounds": [], + "total_sources": 0, + "key_themes": [], + "evidence_summary": {} + } + + # Import internet search tool + from tools.internet_search import InternetSearchTool + internet_tool = InternetSearchTool() + + # Round 1: General topic overview + round1_queries = [ + f"{topic} overview clinical guidelines", + f"{topic} pathophysiology mechanisms", + f"{topic} diagnosis treatment current evidence", + f"{topic} management recommendations 2024", + f"{topic} clinical practice guidelines" + ] + + round1_results = await self._conduct_research_round(internet_tool, round1_queries, 1, "General Overview") + research_results["rounds"].append(round1_results) + + # Round 2: Specific focus based on clarification + focus_keywords = self._extract_focus_keywords(clarification_responses) + round2_queries = [ + f"{topic} {focus_keywords[0]} latest research", + f"{topic} {focus_keywords[1]} clinical studies", + f"{topic} {focus_keywords[0]} best practices", + f"{topic} guidelines {focus_keywords[1]}", + f"{topic} evidence based {focus_keywords[0]}" + ] + + round2_results = await self._conduct_research_round(internet_tool, round2_queries, 2, "Focused Research") + research_results["rounds"].append(round2_results) + + # Round 3: Clinical evidence and studies + round3_queries = [ + f"{topic} randomized controlled trials", + f"{topic} systematic review meta-analysis", + f"{topic} clinical outcomes studies", + f"{topic} evidence quality assessment", + f"{topic} landmark studies" + ] + + round3_results = await self._conduct_research_round(internet_tool, round3_queries, 3, "Clinical Evidence") + research_results["rounds"].append(round3_results) + + # Round 4: Guidelines and recommendations + round4_queries = [ + f"{topic} society guidelines recommendations", + f"{topic} international consensus statements", + f"{topic} practice guidelines updates", + f"{topic} expert consensus recommendations", + f"{topic} clinical practice standards" + ] + + round4_results = await self._conduct_research_round(internet_tool, round4_queries, 4, "Guidelines & Recommendations") + research_results["rounds"].append(round4_results) + + # Calculate total sources + research_results["total_sources"] = sum(len(round_data["sources"]) for round_data in research_results["rounds"]) + + # Extract key themes + research_results["key_themes"] = self._extract_key_themes(research_results["rounds"]) + + return research_results + + async def _conduct_research_round(self, internet_tool, queries: List[str], round_number: int, round_focus: str) -> Dict[str, Any]: + """Conduct a single round of research.""" + + round_results = { + "round_number": round_number, + "focus": round_focus, + "queries": queries, + "sources": [], + "summary": "" + } + + for query in queries: + try: + search_results = await internet_tool.run(query) + if search_results: + # Parse and extract key information + parsed_sources = self._parse_search_results(search_results, query) + round_results["sources"].extend(parsed_sources) + + # Limit to 3-5 sources per round + if len(round_results["sources"]) >= 5: + break + + except Exception as e: + logger.warning(f"Search failed for query '{query}': {e}") + continue + + # Generate summary for this round + round_results["summary"] = self._generate_round_summary(round_results["sources"], round_focus) + + return round_results + + def _parse_search_results(self, search_results: str, query: str) -> List[Dict[str, str]]: + """Parse search results string into structured sources.""" + + sources = [] + + # Split by entries (each entry starts with **) + import re + entries = re.split(r'\*\*([^*]+)\*\*', search_results) + + for i in range(1, len(entries), 2): + if i + 1 < len(entries): + title = entries[i].strip() + content_and_link = entries[i + 1].strip() + + # Extract the link + link_match = re.search(r'\[Read more\]\(([^)]+)\)', content_and_link) + url = link_match.group(1) if link_match else "" + + # Extract the content + content = re.sub(r'\[Read more\]\([^)]+\)', '', content_and_link).strip() + + if title and content: + sources.append({ + "title": title, + "url": url, + "content": content, + "query": query, + "relevance": "high" # Could be improved with actual relevance scoring + }) + + return sources + + def _extract_focus_keywords(self, clarification_responses: Dict[str, str]) -> List[str]: + """Extract focus keywords from clarification responses.""" + + keywords = ["diagnosis", "treatment", "management", "pathophysiology", "guidelines"] + + # Extract keywords from user responses + for response in clarification_responses.values(): + if response: + # Simple keyword extraction - could be improved + if "diagnosis" in response.lower(): + keywords.insert(0, "diagnosis") + elif "treatment" in response.lower(): + keywords.insert(0, "treatment") + elif "management" in response.lower(): + keywords.insert(0, "management") + + return keywords[:2] # Return top 2 keywords + + def _generate_round_summary(self, sources: List[Dict], round_focus: str) -> str: + """Generate a summary for a research round.""" + + if not sources: + return f"No relevant sources found for {round_focus}." + + # Extract key points from sources + key_points = [] + for source in sources: + content = source.get("content", "") + if len(content) > 50: + # Extract first sentence or key point + first_sentence = content.split('.')[0] + if len(first_sentence) > 20: + key_points.append(first_sentence) + + summary = f"**{round_focus}** ({len(sources)} sources):\n" + for i, point in enumerate(key_points[:3], 1): + summary += f"{i}. {point}\n" + + return summary + + def _extract_key_themes(self, rounds: List[Dict]) -> List[str]: + """Extract key themes from all research rounds.""" + + themes = [] + + for round_data in rounds: + summary = round_data.get("summary", "") + if "diagnosis" in summary.lower(): + themes.append("Diagnostic Approach") + if "treatment" in summary.lower(): + themes.append("Treatment Strategies") + if "management" in summary.lower(): + themes.append("Clinical Management") + if "guidelines" in summary.lower(): + themes.append("Evidence-Based Guidelines") + if "pathophysiology" in summary.lower(): + themes.append("Pathophysiology") + + # Remove duplicates and return unique themes + return list(set(themes)) + + def _generate_research_report(self, topic: str, research_results: Dict, clarification_responses: Dict) -> str: + """Generate a comprehensive research report.""" + + report = f"# Comprehensive Research Report: {topic.title()}\n\n" + + # Executive summary + report += "## Executive Summary\n" + report += f"This report synthesizes findings from {research_results['total_sources']} sources across {len(research_results['rounds'])} research rounds.\n\n" + + # Key themes + report += "## Key Themes Identified\n" + for theme in research_results["key_themes"]: + report += f"- {theme}\n" + report += "\n" + + # Research rounds summary + report += "## Research Findings by Round\n" + for round_data in research_results["rounds"]: + report += f"### Round {round_data['round_number']}: {round_data['focus']}\n" + report += f"{round_data['summary']}\n\n" + + # Evidence synthesis + report += "## Evidence Synthesis\n" + report += f"Based on the research conducted, the following key points emerge about {topic}:\n\n" + + # Add synthesized content based on themes + for theme in research_results["key_themes"]: + report += f"**{theme}**: [Evidence-based summary for {theme}]\n\n" + + # Clinical implications + report += "## Clinical Implications\n" + report += f"The research findings have the following implications for clinical practice:\n" + report += "- [Key clinical implication 1]\n" + report += "- [Key clinical implication 2]\n" + report += "- [Key clinical implication 3]\n\n" + + # Recommendations + report += "## Recommendations\n" + report += "Based on the evidence review:\n" + report += "1. [Recommendation 1]\n" + report += "2. [Recommendation 2]\n" + report += "3. [Recommendation 3]\n\n" + + return report + + def _create_presentation_structure(self, topic: str, target_audience: str, duration: int, research_report: str) -> Dict[str, Any]: + """Create the presentation structure.""" + + # Calculate approximate slides based on duration + slides_estimate = max(10, duration // 3) # ~3 minutes per slide + + structure = { + "title": f"{topic.title()}: A Comprehensive Review", + "estimated_slides": slides_estimate, + "estimated_duration": duration, + "sections": [ + { + "section": "Introduction", + "slides": [ + {"title": "Title Slide", "content": f"{topic.title()}", "duration": 1}, + {"title": "Learning Objectives", "content": "What you will learn today", "duration": 2}, + {"title": "Case Vignette", "content": "Clinical scenario introduction", "duration": 3} + ] + }, + { + "section": "Educational Content", + "slides": [ + {"title": "Definition & Overview", "content": f"What is {topic}?", "duration": 5}, + {"title": "Pathophysiology", "content": "Understanding the mechanisms", "duration": 7}, + {"title": "Clinical Presentation", "content": "Recognition and diagnosis", "duration": 7}, + {"title": "Diagnostic Approach", "content": "Evidence-based diagnosis", "duration": 8}, + {"title": "Treatment Strategies", "content": "Management options", "duration": 8}, + {"title": "Guidelines & Evidence", "content": "Current recommendations", "duration": 5} + ] + }, + { + "section": "Application", + "slides": [ + {"title": "Case Application", "content": "Applying knowledge to the vignette", "duration": 5}, + {"title": "Clinical Pearls", "content": "Key takeaways", "duration": 3} + ] + }, + { + "section": "Assessment", + "slides": [ + {"title": "Rapid Fire Questions", "content": "Quick knowledge check", "duration": 5}, + {"title": "Discussion", "content": "Open discussion and Q&A", "duration": 5} + ] + } + ] + } + + return structure + + def _adjust_structure_based_on_feedback(self, structure: Dict, feedback: str) -> Dict: + """Adjust presentation structure based on user feedback.""" + + # Simple feedback processing - could be enhanced + if "more slides" in feedback.lower(): + # Add more detail slides + for section in structure["sections"]: + if section["section"] == "Educational Content": + section["slides"].append({ + "title": "Advanced Topics", + "content": "Additional detailed information", + "duration": 5 + }) + + if "shorter" in feedback.lower(): + # Remove some slides + for section in structure["sections"]: + if len(section["slides"]) > 2: + section["slides"] = section["slides"][:2] + + return structure + + async def _generate_all_slides(self, topic: str, target_audience: str, research_report: str, structure: Dict) -> List[Dict[str, Any]]: + """Generate all presentation slides using AI and research content.""" + + slides = [] + slide_number = 1 + + logger.info(f"Starting AI-powered slide generation for {topic}") + + for section in structure["sections"]: + for slide_template in section["slides"]: + try: + slide = await self._create_ai_slide( + slide_number, + slide_template["title"], + slide_template["content"], + topic, + target_audience, + research_report, + section["section"] + ) + slides.append(slide) + slide_number += 1 + logger.info(f"Generated slide {slide_number-1}: {slide_template['title']}") + + except Exception as e: + logger.error(f"Failed to generate slide {slide_number}: {e}") + # Fallback to basic slide structure + slide = self._create_fallback_slide(slide_number, slide_template["title"], section["section"]) + slides.append(slide) + slide_number += 1 + + logger.info(f"Completed slide generation: {len(slides)} slides created") + return slides + + async def _create_ai_slide(self, slide_number: int, title: str, content_desc: str, topic: str, + target_audience: str, research_report: str, section: str) -> Dict[str, Any]: + """Create an individual slide with AI-generated content based on research.""" + + try: + # Load the slide generation prompt + logger.info(f"Generating AI content for slide: {title}") + prompt = load_prompt('generate_presentation_slide.j2', + topic=topic, + target_audience=target_audience.replace('_', ' '), + slide_title=title, + section=section, + content_description=content_desc, + research_report=research_report[:3000] # Limit research content to avoid token limits + ) + + # Generate slide content with OpenAI + response = await asyncio.wait_for( + call_llm(prompt), + timeout=30.0 + ) + + # Parse AI response + if response.strip().startswith('```json'): + response = response.strip()[7:-3].strip() + elif response.strip().startswith('```'): + response = response.strip()[3:-3].strip() + + slide_content = json.loads(response) + + # Construct the slide with AI-generated content + slide = { + "slide_number": slide_number, + "title": slide_content.get("slide_title", title), + "section": section, + "content": { + "bullet_points": slide_content.get("main_content", []), + "sub_bullets": slide_content.get("sub_bullets", {}), + "clinical_notes": slide_content.get("clinical_notes", ""), + "references_used": slide_content.get("references_used", ""), + "generation_method": "AI-powered with research integration" + } + } + + logger.info(f"Successfully generated AI slide: {title} ({len(slide['content']['bullet_points'])} main points)") + return slide + + except Exception as e: + logger.error(f"AI slide generation failed for {title}: {e}") + # Return fallback slide + return self._create_fallback_slide(slide_number, title, section) + + def _create_fallback_slide(self, slide_number: int, title: str, section: str) -> Dict[str, Any]: + """Create a basic fallback slide if AI generation fails.""" + return { + "slide_number": slide_number, + "title": title, + "section": section, + "content": { + "bullet_points": [ + f"Content for {title} slide", + "Key points to be covered", + "Clinical applications", + "Important considerations" + ], + "sub_bullets": {}, + "clinical_notes": "Fallback content - consider manual review", + "generation_method": "Fallback template" + } + } + + def _create_slide(self, slide_number: int, title: str, content_desc: str, topic: str, target_audience: str, research_report: str, section: str) -> Dict[str, Any]: + """Create an individual slide with detailed, presentation-ready content.""" + + slide = { + "slide_number": slide_number, + "title": title, + "section": section, + "content": { + "bullet_points": [], + "images": [], + "notes": "" + } + } + + # Generate detailed content based on slide type and topic + if "Title Slide" in title: + slide["content"]["bullet_points"] = [ + f"{topic.title()}: A Comprehensive Review", + f"For {target_audience.replace('_', ' ').title()}", + f"Date: July 18, 2025" + ] + elif "Learning Objectives" in title: + slide["content"]["bullet_points"] = self._generate_learning_objectives_content(topic) + elif "Case Vignette" in title: + slide["content"]["bullet_points"] = self._generate_case_vignette_content(topic) + elif "Definition" in title or "Overview" in title: + slide["content"]["bullet_points"] = self._generate_definition_overview_content(topic) + elif "Pathophysiology" in title: + slide["content"]["bullet_points"] = self._generate_pathophysiology_content(topic) + elif "Clinical Presentation" in title: + slide["content"]["bullet_points"] = self._generate_clinical_presentation_content(topic) + elif "Diagnostic" in title: + slide["content"]["bullet_points"] = self._generate_diagnostic_content(topic) + elif "Treatment" in title: + slide["content"]["bullet_points"] = self._generate_treatment_content(topic) + elif "Guidelines" in title: + slide["content"]["bullet_points"] = self._generate_guidelines_content(topic) + elif "Case Application" in title: + slide["content"]["bullet_points"] = self._generate_case_application_content(topic) + elif "Clinical Pearls" in title: + slide["content"]["bullet_points"] = self._generate_clinical_pearls_content(topic) + elif "Rapid Fire" in title: + slide["content"]["bullet_points"] = self._generate_rapid_fire_content(topic) + elif "Discussion" in title: + slide["content"]["bullet_points"] = self._generate_discussion_content(topic) + else: + # Fallback for other slide types + slide["content"]["bullet_points"] = self._generate_generic_content(title, topic) + + return slide + + def _generate_learning_objectives_content(self, topic: str) -> List[str]: + """Generate specific learning objectives based on topic.""" + + if "dimorphic fungi" in topic.lower(): + return [ + "Identify the three major endemic dimorphic fungi in the United States", + "Describe the unique morphological characteristics of dimorphic fungi", + "Recognize geographic distribution patterns and epidemiologic risk factors", + "Differentiate clinical presentations of histoplasmosis, blastomycosis, and coccidioidomycosis", + "Apply appropriate diagnostic testing strategies and interpret results", + "Implement evidence-based antifungal treatment protocols per IDSA guidelines" + ] + elif "pneumonia" in topic.lower(): + return [ + "Classify pneumonia by etiology and clinical setting (CAP, HAP, VAP)", + "Recognize clinical presentation and physical examination findings", + "Select appropriate diagnostic tests and interpret chest imaging", + "Apply severity scoring systems (CURB-65, PSI) for risk stratification", + "Implement evidence-based antibiotic therapy based on guidelines", + "Identify complications and indications for hospitalization" + ] + else: + return [ + f"Define key concepts related to {topic}", + f"Recognize clinical manifestations of {topic}", + f"Apply diagnostic approaches for {topic}", + f"Implement evidence-based treatment strategies", + f"Integrate current guidelines into clinical practice" + ] + + def _generate_case_vignette_content(self, topic: str) -> List[str]: + """Generate specific case vignette based on topic.""" + + if "dimorphic fungi" in topic.lower(): + return [ + "45-year-old construction worker from Ohio River Valley", + "Recent spelunking activities in Kentucky caves (6 weeks ago)", + "3-week history: fever, nonproductive cough, 15-pound weight loss", + "Physical exam: erythema nodosum, bilateral hilar lymphadenopathy", + "Labs: lymphopenia, elevated ESR, positive Histoplasma urine antigen", + "Question: What is the most likely diagnosis and treatment?" + ] + elif "pneumonia" in topic.lower(): + return [ + "68-year-old man with COPD and diabetes", + "Recent cruise ship travel, acute onset (48 hours)", + "Productive cough with rust-colored sputum, pleuritic chest pain", + "Physical exam: dullness to percussion, bronchial breath sounds", + "Labs: elevated WBC with left shift, positive pneumococcal antigen", + "Question: What is the most appropriate treatment approach?" + ] + else: + return [ + f"Clinical scenario presenting with {topic}", + "Relevant patient history and risk factors", + "Physical examination findings", + "Initial diagnostic workup results", + "Clinical decision-making challenge" + ] + + def _generate_definition_overview_content(self, topic: str) -> List[str]: + """Generate definition and overview content.""" + + if "dimorphic fungi" in topic.lower(): + return [ + "Dimorphic fungi: organisms that exist in two distinct morphological forms", + "Yeast form at body temperature (37Β°C) - pathogenic phase", + "Mold form at room temperature (25Β°C) - environmental phase", + "Three major endemic fungi in US: Histoplasma, Blastomyces, Coccidioides", + "Cause significant morbidity in immunocompromised and healthy hosts", + "Geographic distribution correlates with environmental factors" + ] + elif "pneumonia" in topic.lower(): + return [ + "Pneumonia: infection of the lung parenchyma and alveolar spaces", + "Leading cause of infectious disease mortality worldwide", + "Classification: Community-acquired (CAP), Healthcare-associated (HAP/VAP)", + "Etiology: bacterial, viral, fungal, or atypical pathogens", + "Risk factors: age, comorbidities, immunosuppression, aspiration", + "Clinical spectrum: mild outpatient to severe septic shock" + ] + else: + return [ + f"Definition and key characteristics of {topic}", + f"Epidemiology and prevalence of {topic}", + f"Clinical significance in medical practice", + f"Risk factors and predisposing conditions" + ] + + def _generate_pathophysiology_content(self, topic: str) -> List[str]: + """Generate pathophysiology content.""" + + if "dimorphic fungi" in topic.lower(): + return [ + "Inhalation of microconidia from contaminated soil or bird/bat droppings", + "Conversion to yeast form in lung alveoli at body temperature", + "Phagocytosis by alveolar macrophages - intracellular survival", + "Hematogenous dissemination to reticuloendothelial system", + "Host immune response: cell-mediated immunity crucial for control", + "Granulomatous inflammation with potential for reactivation" + ] + elif "pneumonia" in topic.lower(): + return [ + "Pathogen invasion of lower respiratory tract via inhalation or aspiration", + "Overwhelm of normal host defense mechanisms (mucociliary clearance, alveolar macrophages)", + "Inflammatory response: neutrophil recruitment, cytokine release", + "Alveolar filling with inflammatory exudate and impaired gas exchange", + "Systemic inflammatory response syndrome (SIRS) in severe cases", + "Complications: pleural effusion, empyema, respiratory failure" + ] + else: + return [ + f"Underlying mechanisms of {topic}", + f"Pathophysiologic pathways involved", + f"Host response and immune system involvement", + f"Disease progression and complications" + ] + + def _generate_clinical_presentation_content(self, topic: str) -> List[str]: + """Generate clinical presentation content.""" + + if "dimorphic fungi" in topic.lower(): + return [ + "Histoplasmosis: fever, cough, weight loss, erythema nodosum", + "Blastomycosis: skin lesions, pulmonary symptoms, bone involvement", + "Coccidioidomycosis: Valley fever, arthralgias, desert rheumatism", + "Pulmonary manifestations: nodules, cavitation, hilar lymphadenopathy", + "Disseminated disease: CNS, skin, bone, adrenal involvement", + "Chronic forms: progressive pulmonary fibrosis, cavitary disease" + ] + elif "pneumonia" in topic.lower(): + return [ + "Classic triad: fever, cough, and dyspnea", + "Productive cough with purulent sputum (bacterial)", + "Pleuritic chest pain and decreased breath sounds", + "Physical signs: dullness to percussion, crackles, bronchial breath sounds", + "Systemic symptoms: malaise, myalgias, headache", + "Severe cases: sepsis, altered mental status, respiratory failure" + ] + else: + return [ + f"Common signs and symptoms of {topic}", + f"Physical examination findings", + f"Disease spectrum and severity variations", + f"Complications and warning signs" + ] + + def _generate_diagnostic_content(self, topic: str) -> List[str]: + """Generate diagnostic approach content.""" + + if "dimorphic fungi" in topic.lower(): + return [ + "Urine antigen testing: rapid, sensitive for Histoplasma", + "Serology: complement fixation, EIA antibodies (takes weeks)", + "Culture: gold standard but requires 2-6 weeks for growth", + "Histopathology: special stains (GMS, PAS) for tissue diagnosis", + "Molecular testing: PCR increasingly available", + "Imaging: chest CT for pulmonary nodules, lymphadenopathy" + ] + elif "pneumonia" in topic.lower(): + return [ + "Chest X-ray: first-line imaging for consolidation", + "Laboratory: CBC with differential, procalcitonin, blood cultures", + "Sputum culture: if good quality specimen available", + "Urinary antigens: pneumococcal and Legionella", + "Severity assessment: CURB-65, PSI scoring systems", + "Advanced imaging: chest CT if complicated or atypical" + ] + else: + return [ + f"Laboratory tests for {topic}", + f"Imaging studies and interpretation", + f"Differential diagnosis considerations", + f"Confirmatory diagnostic procedures" + ] + + def _generate_treatment_content(self, topic: str) -> List[str]: + """Generate treatment strategies content.""" + + if "dimorphic fungi" in topic.lower(): + return [ + "Mild-moderate disease: Itraconazole 200 mg BID Γ— 6-12 weeks", + "Severe disease: Amphotericin B 0.7-1.0 mg/kg/day Γ— 1-2 weeks", + "Step-down therapy: Itraconazole after amphotericin stabilization", + "CNS disease: Amphotericin B Γ— 4-6 weeks, then fluconazole", + "Duration: 6-12 months for pulmonary, 12-24 months for disseminated", + "Monitoring: drug levels, hepatic function, treatment response" + ] + elif "pneumonia" in topic.lower(): + return [ + "Outpatient CAP: Amoxicillin or macrolide monotherapy", + "Hospitalized CAP: Beta-lactam + macrolide or fluoroquinolone", + "Severe CAP: Broad-spectrum beta-lactam + macrolide", + "Duration: 5-7 days for most cases, longer if complications", + "Supportive care: oxygen, fluids, bronchodilators if needed", + "Prevention: pneumococcal and influenza vaccination" + ] + else: + return [ + f"First-line treatment options for {topic}", + f"Alternative therapies and second-line agents", + f"Treatment duration and monitoring parameters", + f"Management of complications" + ] + + def _generate_guidelines_content(self, topic: str) -> List[str]: + """Generate guidelines and evidence content.""" + + if "dimorphic fungi" in topic.lower(): + return [ + "IDSA 2007 Guidelines for Endemic Mycoses (updated recommendations)", + "Treatment recommendations based on disease severity and location", + "Antifungal drug selection considers penetration and efficacy", + "Monitoring guidelines for drug toxicity and therapeutic response", + "Prevention strategies for high-risk populations", + "Quality indicators for optimal clinical outcomes" + ] + elif "pneumonia" in topic.lower(): + return [ + "IDSA/ATS 2019 Guidelines for Community-Acquired Pneumonia", + "Antimicrobial selection based on severity and risk factors", + "Biomarker-guided therapy duration (procalcitonin)", + "Quality measures: appropriate antibiotic selection and timing", + "Prevention: vaccination recommendations and smoking cessation", + "Stewardship: narrow-spectrum therapy when possible" + ] + else: + return [ + f"Current clinical practice guidelines for {topic}", + f"Evidence-based recommendations and quality indicators", + f"Emerging research and future directions", + f"Implementation strategies in clinical practice" + ] + + def _generate_case_application_content(self, topic: str) -> List[str]: + """Generate case application content.""" + + if "dimorphic fungi" in topic.lower(): + return [ + "Case diagnosis: Acute pulmonary histoplasmosis", + "Rationale: Geographic exposure + clinical presentation + positive urine antigen", + "Treatment plan: Itraconazole 200 mg BID Γ— 6-12 weeks", + "Monitoring: Clinical response, itraconazole levels, hepatic function", + "Patient education: Prognosis, medication adherence, follow-up", + "Prevention: Avoid high-risk activities in endemic areas" + ] + elif "pneumonia" in topic.lower(): + return [ + "Case diagnosis: Community-acquired pneumonia, moderate severity", + "CURB-65 score: 2 points (age > 65, confusion absent)", + "Treatment: Ceftriaxone 2g IV daily + azithromycin 500mg IV daily", + "Expected response: Clinical improvement within 48-72 hours", + "Discharge criteria: Stable vital signs, tolerating oral therapy", + "Follow-up: Chest X-ray in 6 weeks if high-risk patient" + ] + else: + return [ + f"Application of diagnostic criteria for {topic}", + f"Treatment decision-making based on evidence", + f"Monitoring response and adjusting therapy", + f"Patient education and follow-up planning" + ] + + def _generate_clinical_pearls_content(self, topic: str) -> List[str]: + """Generate clinical pearls content.""" + + if "dimorphic fungi" in topic.lower(): + return [ + "Geographic history is crucial - ask about travel to endemic areas", + "Urine antigen testing provides rapid diagnosis for Histoplasma", + "Lymphopenia is characteristic of histoplasmosis vs. bacterial infections", + "Erythema nodosum suggests acute infection with good prognosis", + "Itraconazole levels should be checked after 2 weeks of therapy", + "Immunocompromised patients require longer, more intensive treatment" + ] + elif "pneumonia" in topic.lower(): + return [ + "Procalcitonin > 0.5 ng/mL suggests bacterial etiology", + "Positive urinary antigens guide targeted antibiotic therapy", + "CURB-65 score helps determine site of care (outpatient vs. hospital)", + "Atypical pathogens require macrolide or fluoroquinolone coverage", + "Clinical response expected within 48-72 hours of appropriate therapy", + "Chest X-ray may lag behind clinical improvement by several days" + ] + else: + return [ + f"Key clinical insights for {topic}", + f"Common pitfalls to avoid in diagnosis", + f"Practical tips for optimal patient management", + f"Important prognostic factors to consider" + ] + + def _generate_rapid_fire_content(self, topic: str) -> List[str]: + """Generate rapid fire questions content.""" + + if "dimorphic fungi" in topic.lower(): + return [ + "Q: Which dimorphic fungus is associated with spelunking? A: Histoplasma", + "Q: What is the most sensitive test for histoplasmosis? A: Urine antigen", + "Q: Which form is pathogenic at body temperature? A: Yeast form", + "Q: What skin finding suggests acute coccidioidomycosis? A: Erythema nodosum", + "Q: First-line treatment for mild histoplasmosis? A: Itraconazole", + "Q: How long should treatment continue? A: 6-12 weeks for pulmonary disease" + ] + elif "pneumonia" in topic.lower(): + return [ + "Q: What is the most common cause of CAP? A: Streptococcus pneumoniae", + "Q: Which score predicts 30-day mortality? A: CURB-65 or PSI", + "Q: When should blood cultures be obtained? A: Before antibiotics in hospitalized patients", + "Q: First-line outpatient treatment for CAP? A: Amoxicillin or macrolide", + "Q: What biomarker helps guide antibiotic duration? A: Procalcitonin", + "Q: How soon should clinical improvement occur? A: Within 48-72 hours" + ] + else: + return [ + f"Quick review questions about {topic}", + f"Key facts and figures to remember", + f"High-yield testing points", + f"Clinical scenarios for practice" + ] + + def _generate_discussion_content(self, topic: str) -> List[str]: + """Generate discussion content.""" + + return [ + "Questions and answers session", + "Case-based discussion and clinical experiences", + "Challenging scenarios and problem-solving", + "Summary of key learning points", + "Resources for further learning", + "Contact information for follow-up questions" + ] + + def _generate_generic_content(self, title: str, topic: str) -> List[str]: + """Generate generic content for unspecified slide types.""" + + return [ + f"Key concepts related to {title.lower()} in {topic}", + f"Clinical significance and practical applications", + f"Evidence-based approaches and recommendations", + f"Integration with current clinical practice" + ] + + def _generate_speaker_notes(self, slides: List[Dict], research_report: str) -> Dict[str, str]: + """Generate detailed speaker notes for each slide.""" + + speaker_notes = {} + + for slide in slides: + slide_number = slide["slide_number"] + title = slide["title"] + + # Generate specific speaker notes based on slide content + if "Title Slide" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Welcome the audience and introduce the topic.\n" + notes += "Mention the importance of understanding dimorphic fungi in clinical practice.\n" + notes += "Preview the learning objectives and interactive elements.\n" + notes += "Encourage questions throughout the presentation.\n" + elif "Learning Objectives" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Review each learning objective with the audience.\n" + notes += "Explain how these objectives relate to clinical practice.\n" + notes += "Ask: 'What is your current experience with diagnosing fungal infections?'\n" + notes += "Set expectations for active participation.\n" + elif "Case Vignette" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Present the case systematically, pausing for audience input.\n" + notes += "Ask: 'What additional history would you want to obtain?'\n" + notes += "Highlight key clinical clues that point to the diagnosis.\n" + notes += "Build suspense - we'll return to this case later.\n" + elif "Definition" in title or "Overview" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Explain the unique characteristics of dimorphic fungi.\n" + notes += "Use the temperature-dependent morphology as a key teaching point.\n" + notes += "Emphasize the geographic distribution and clinical significance.\n" + notes += "Ask: 'Which endemic areas are you familiar with?'\n" + elif "Pathophysiology" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Walk through the infection process step by step.\n" + notes += "Emphasize the importance of cell-mediated immunity.\n" + notes += "Explain why immunocompromised patients are at higher risk.\n" + notes += "Connect pathophysiology to clinical presentation.\n" + elif "Clinical Presentation" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Describe the spectrum of disease for each fungus.\n" + notes += "Highlight distinguishing features between organisms.\n" + notes += "Use clinical images if available to illustrate skin findings.\n" + notes += "Ask: 'What clinical clues help differentiate these infections?'\n" + elif "Diagnostic" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Discuss the pros and cons of each diagnostic method.\n" + notes += "Emphasize the rapid turnaround time of urine antigen testing.\n" + notes += "Explain when to use each test based on clinical scenario.\n" + notes += "Address common pitfalls in diagnosis.\n" + elif "Treatment" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Review IDSA guidelines for treatment recommendations.\n" + notes += "Explain rationale for drug selection and duration.\n" + notes += "Discuss monitoring parameters and side effects.\n" + notes += "Address when to consult infectious disease specialists.\n" + elif "Guidelines" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Highlight key recommendations from IDSA guidelines.\n" + notes += "Discuss recent updates and changes in recommendations.\n" + notes += "Emphasize evidence-based approach to treatment.\n" + notes += "Provide resources for accessing current guidelines.\n" + elif "Case Application" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Return to the opening case vignette.\n" + notes += "Walk through the diagnostic reasoning process.\n" + notes += "Explain treatment selection and monitoring plan.\n" + notes += "Ask: 'What would you do differently in this case?'\n" + elif "Clinical Pearls" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Emphasize practical tips for clinical practice.\n" + notes += "Share memorable mnemonics or decision aids.\n" + notes += "Highlight common mistakes to avoid.\n" + notes += "Encourage audience to share their own pearls.\n" + elif "Rapid Fire" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Engage the audience with quick questions.\n" + notes += "Encourage rapid responses to build confidence.\n" + notes += "Provide immediate feedback and explanations.\n" + notes += "Use this as a knowledge check before concluding.\n" + elif "Discussion" in title: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += "Open the floor for questions and discussion.\n" + notes += "Encourage sharing of clinical experiences.\n" + notes += "Address any remaining questions or concerns.\n" + notes += "Provide contact information and additional resources.\n" + notes += "Thank the audience for their participation.\n" + else: + notes = f"**Speaker Notes for Slide {slide_number}: {title}**\n\n" + notes += f"Key talking points for {title}.\n" + notes += "Connect to research findings and clinical evidence.\n" + notes += "Engage audience with relevant questions.\n" + notes += "Ensure smooth transition to next slide.\n" + + speaker_notes[str(slide_number)] = notes + + return speaker_notes + + def _generate_presentation_metadata(self, topic: str, target_audience: str, duration: int, total_slides: int) -> Dict[str, Any]: + """Generate presentation metadata.""" + + metadata = { + "topic": topic, + "target_audience": target_audience, + "duration_minutes": duration, + "total_slides": total_slides, + "created_date": "2025-07-18", + "presentation_type": "Educational", + "format": "PowerPoint/Slides", + "estimated_time_per_slide": duration / total_slides if total_slides > 0 else 3, + "learning_level": "Intermediate", + "prerequisites": f"Basic knowledge of {topic}", + "materials_needed": "Projector, handouts (optional)" + } + + return metadata diff --git a/tools/explain_in_layman_language.py b/tools/explain_in_layman_language.py new file mode 100644 index 0000000000000000000000000000000000000000..f1c83d22ea74110bca3a51861df7ce2edbe92228 --- /dev/null +++ b/tools/explain_in_layman_language.py @@ -0,0 +1,370 @@ +""" +explain_in_layman_language.py +----------------------------- + +Tool for explaining medical assessment and plans in patient-friendly language with educational resources. + +This tool takes complex medical terminology from assessments and plans and translates them into +easy-to-understand language for patients. It also searches for reliable educational resources +to help patients better understand their conditions and treatments. +""" + +import asyncio +from typing import Dict, List, Union, Any +from tools.base import Tool +from tools.utils import ToolExecutionError, logger +from tools.internet_search import InternetSearchTool + + +class ExplainInLaymanLanguageTool(Tool): + """ + Tool for explaining medical assessments and plans in patient-friendly language. + + This tool: + 1. Takes medical assessment and plan text + 2. Translates complex medical terminology into layman terms + 3. Searches for 2-3 reliable educational links on the topics + 4. Provides a comprehensive patient-friendly explanation + """ + + def __init__(self) -> None: + """Initialize the ExplainInLaymanLanguageTool.""" + super().__init__() + self.name = "explain_in_layman_language" + self.description = "Explain medical assessment and plan in patient-friendly language with educational resources" + self.args_schema = { + "type": "object", + "properties": { + "assessment_and_plan": { + "type": "string", + "description": "The medical assessment and plan text to explain in layman terms" + }, + "patient_context": { + "type": "string", + "description": "Additional context about the patient (age, relevant history, etc.)", + "default": "" + } + }, + "required": ["assessment_and_plan"] + } + + # Initialize internet search tool for finding educational resources + self.internet_search = InternetSearchTool() + + def openai_spec(self, legacy=False): + """Return OpenAI function specification for this tool.""" + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + + async def run( + self, + assessment_and_plan: str, + patient_context: str = "" + ) -> Dict[str, Any]: + """ + Explain medical assessment and plan in layman language with educational resources. + + Args: + assessment_and_plan (str): The medical assessment and plan to explain + patient_context (str): Additional patient context + + Returns: + Dict[str, Any]: Patient-friendly explanation with educational resources + """ + try: + logger.info(f"Explaining medical assessment in layman language") + + # Step 1: Extract key medical topics from the assessment and plan + key_topics = self._extract_medical_topics(assessment_and_plan) + + # Step 2: Create patient-friendly explanation + layman_explanation = self._create_layman_explanation( + assessment_and_plan, + patient_context, + key_topics + ) + + # Step 3: Search for educational resources + educational_links = await self._find_educational_resources(key_topics) + + # Step 4: Combine everything into a comprehensive response + result = { + "layman_explanation": layman_explanation, + "educational_resources": educational_links, + "key_topics_covered": key_topics, + "patient_context": patient_context + } + + logger.info(f"Successfully created layman explanation with {len(educational_links)} educational resources") + return result + + except Exception as e: + logger.error(f"ExplainInLaymanLanguageTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"Failed to explain assessment in layman language: {e}") + + def _extract_medical_topics(self, assessment_and_plan: str) -> List[str]: + """Extract key medical topics from the assessment and plan.""" + # Common medical terms and their categories + medical_terms = { + # Infections + "pneumonia": "lung infection", + "sepsis": "blood infection", + "cellulitis": "skin infection", + "uti": "urinary tract infection", + "urinary tract infection": "urinary tract infection", + "meningitis": "brain infection", + "endocarditis": "heart infection", + + # Antibiotics + "antibiotic": "antibiotic treatment", + "vancomycin": "antibiotic treatment", + "ceftriaxone": "antibiotic treatment", + "azithromycin": "antibiotic treatment", + "levofloxacin": "antibiotic treatment", + "piperacillin": "antibiotic treatment", + "meropenem": "antibiotic treatment", + + # Conditions + "diabetes": "diabetes", + "hypertension": "high blood pressure", + "copd": "chronic lung disease", + "asthma": "asthma", + "heart failure": "heart failure", + "kidney disease": "kidney disease", + "liver disease": "liver disease", + + # Procedures + "blood culture": "blood tests", + "chest x-ray": "chest imaging", + "ct scan": "CT scan", + "mri": "MRI scan", + "lumbar puncture": "spinal fluid test", + "biopsy": "tissue sample", + + # Symptoms + "fever": "fever", + "shortness of breath": "breathing difficulty", + "chest pain": "chest pain", + "abdominal pain": "stomach pain", + "nausea": "nausea", + "vomiting": "vomiting", + "diarrhea": "diarrhea" + } + + topics = [] + assessment_lower = assessment_and_plan.lower() + + for term, category in medical_terms.items(): + if term in assessment_lower: + if category not in topics: + topics.append(category) + + # If no specific topics found, extract general categories + if not topics: + if "infection" in assessment_lower: + topics.append("infection") + if "antibiotic" in assessment_lower: + topics.append("antibiotic treatment") + if "treatment" in assessment_lower: + topics.append("treatment") + + return topics[:5] # Limit to top 5 topics + + def _create_layman_explanation( + self, + assessment_and_plan: str, + patient_context: str, + key_topics: List[str] + ) -> str: + """Create a patient-friendly explanation of the assessment and plan.""" + + # Medical terminology translations + translations = { + # Infections + "pneumonia": "a lung infection", + "sepsis": "a serious blood infection", + "bacteremia": "bacteria in the blood", + "cellulitis": "a skin and soft tissue infection", + "uti": "a urinary tract infection (bladder infection)", + "urinary tract infection": "a bladder infection", + "pyelonephritis": "a kidney infection", + "meningitis": "an infection of the brain and spinal cord lining", + "endocarditis": "an infection of the heart valves", + + # Antibiotics + "vancomycin": "a strong antibiotic medication", + "ceftriaxone": "an antibiotic medication", + "azithromycin": "an antibiotic medication (Z-pack)", + "levofloxacin": "an antibiotic medication", + "piperacillin-tazobactam": "a combination antibiotic medication", + "meropenem": "a powerful antibiotic medication", + "clindamycin": "an antibiotic medication", + + # Medical procedures + "blood culture": "blood tests to check for bacteria", + "chest x-ray": "a picture of your lungs", + "ct scan": "a detailed scan using X-rays", + "mri": "a detailed scan using magnets", + "lumbar puncture": "a procedure to test spinal fluid", + "biopsy": "taking a small sample of tissue for testing", + + # Conditions + "copd": "chronic obstructive pulmonary disease (long-term lung condition)", + "chf": "congestive heart failure (heart not pumping well)", + "dm": "diabetes mellitus (diabetes)", + "htn": "hypertension (high blood pressure)", + "ckd": "chronic kidney disease (long-term kidney problems)", + + # Symptoms + "dyspnea": "shortness of breath", + "tachycardia": "fast heart rate", + "hypotension": "low blood pressure", + "hypertension": "high blood pressure", + "pyrexia": "fever", + "malaise": "feeling unwell", + "lethargy": "extreme tiredness" + } + + # Start with a friendly introduction + explanation = "Here's what your medical team has found and what the plan is, explained in simple terms:\n\n" + + # Add patient context if provided + if patient_context: + explanation += f"**About You:** {patient_context}\n\n" + + # Process the assessment and plan + explanation += "**What's Happening:**\n" + + # Simple word replacement for common medical terms + simplified_text = assessment_and_plan.lower() + for medical_term, layman_term in translations.items(): + simplified_text = simplified_text.replace(medical_term, layman_term) + + # Add the simplified explanation + explanation += simplified_text.capitalize() + "\n\n" + + # Add key points section + if key_topics: + explanation += "**Key Points to Remember:**\n" + for topic in key_topics: + explanation += f"β€’ {topic.capitalize()}\n" + explanation += "\n" + + # Add encouragement and next steps + explanation += "**What This Means for You:**\n" + explanation += "Your medical team is working to give you the best care possible. " + explanation += "It's important to follow the treatment plan and ask questions if anything is unclear. " + explanation += "Your health and understanding are our top priorities.\n\n" + + return explanation + + async def _find_educational_resources(self, key_topics: List[str]) -> List[Dict[str, str]]: + """Find 2-3 reliable educational resources about the key topics.""" + educational_links = [] + + # Trusted medical education websites + trusted_sites = [ + "mayoclinic.org", + "webmd.com", + "healthline.com", + "medlineplus.gov", + "cdc.gov", + "nih.gov", + "patient.info" + ] + + try: + # Search for educational resources for each topic + for topic in key_topics[:3]: # Limit to 3 topics + search_queries = [ + f"{topic} patient education site:mayoclinic.org", + f"{topic} patient information site:medlineplus.gov", + f"what is {topic} patient guide site:healthline.com" + ] + + for query in search_queries: + try: + search_results = await self.internet_search.run(q=query, max_results=3) + + # Parse the search results string to extract links + if search_results and isinstance(search_results, str): + # Extract links from the formatted string response + import re + # Pattern to match [Read more](url) links + link_pattern = r'\[Read more\]\(([^)]+)\)' + title_pattern = r'\*\*([^*]+)\*\*' + + links = re.findall(link_pattern, search_results) + titles = re.findall(title_pattern, search_results) + + # Match titles with links + for i, (title, link) in enumerate(zip(titles, links)): + if any(site in link.lower() for site in trusted_sites): + educational_links.append({ + "title": title, + "url": link, + "topic": topic, + "description": f"Learn more about {topic}" + }) + break # Found a good link for this topic + + if len(educational_links) >= 3: + break + + except Exception as e: + logger.warning(f"Failed to search for {topic}: {e}") + continue + + if len(educational_links) >= 3: + break + + # If we don't have enough links, add some general reliable resources + if len(educational_links) < 2: + general_resources = [ + { + "title": "MedlinePlus Health Information", + "url": "https://medlineplus.gov/", + "topic": "general health", + "description": "Reliable health information from the National Library of Medicine" + }, + { + "title": "Mayo Clinic Patient Education", + "url": "https://www.mayoclinic.org/patient-visitor-guide/patient-education", + "topic": "general health", + "description": "Comprehensive patient education resources from Mayo Clinic" + }, + { + "title": "CDC Health Information", + "url": "https://www.cdc.gov/healthypeople/", + "topic": "general health", + "description": "Health information and resources from the CDC" + } + ] + + # Add general resources to reach at least 2-3 links + for resource in general_resources: + if len(educational_links) < 3: + educational_links.append(resource) + + except Exception as e: + logger.warning(f"Failed to find educational resources: {e}") + # Provide fallback resources + educational_links = [ + { + "title": "MedlinePlus - Easy-to-Read Health Information", + "url": "https://medlineplus.gov/", + "topic": "general health", + "description": "Trusted health information from the National Library of Medicine" + }, + { + "title": "Mayo Clinic - Patient Education", + "url": "https://www.mayoclinic.org/diseases-conditions", + "topic": "general health", + "description": "Comprehensive information about diseases and conditions" + } + ] + + return educational_links[:3] # Return maximum 3 links diff --git a/tools/fhir_patient.py b/tools/fhir_patient.py new file mode 100644 index 0000000000000000000000000000000000000000..e22b3494a6561fd26859efe65361bbf4728cce1e --- /dev/null +++ b/tools/fhir_patient.py @@ -0,0 +1,50 @@ +from tools.base import Tool + + +from tools.utils import ToolExecutionError, logger +from typing import Any, Dict + +class FHIRPatientTool(Tool): + + def openai_spec(self, legacy=False): + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + """ + Tool to fetch synthetic patient labs and vitals from a FHIR server by patient ID. + + This tool queries a FHIR API for a given patient ID and returns labs and vitals (placeholder implementation). + """ + def __init__(self) -> None: + """ + Initialize the FHIRPatientTool with its name, description, and argument schema. + """ + super().__init__() + self.name = "synthetic_patient_lookup" + self.description = "Fetch synthetic patient labs / vitals from FHIR by patient_id." + self.args_schema = { + "type": "object", + "properties": { + "patient_id": {"type": "string", "description": "Patient ID to query"} + }, + "required": ["patient_id"] + } + + async def run(self, patient_id: str) -> Dict[str, Any]: + """ + Fetch synthetic patient labs and vitals for a given patient ID. + + Args: + patient_id (str): The patient ID to query. + + Returns: + Dict[str, Any]: The patient data (placeholder). + """ + try: + # Placeholder for actual FHIR API call + return {"patient_id": patient_id, "labs": [], "vitals": []} + except Exception as e: + logger.error(f"FHIRPatientTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"FHIRPatientTool failed: {e}") diff --git a/tools/format_references.py b/tools/format_references.py new file mode 100644 index 0000000000000000000000000000000000000000..5760f882a1f3d71c81b88c06ab2d6246bea18da8 --- /dev/null +++ b/tools/format_references.py @@ -0,0 +1,423 @@ +""" +Format References Tool +Smart reference formatting for any journal's requirements +""" + +import json +import re +import requests +import os +from tools.base import Tool + + +class FormatReferencesTool(Tool): + """ + Smart reference formatting tool that can format references according to any journal's specific requirements. + Asks for the target journal, looks up formatting instructions, and applies them to user-provided references. + """ + + def __init__(self): + super().__init__() + + def execute(self, references_text, target_journal=None, max_length=2000): + """ + Format references according to target journal requirements. + + Args: + references_text (str): Raw references to format + target_journal (str, optional): Target journal name + max_length (int): Maximum response length + + Returns: + dict: Formatted references and formatting guidelines + """ + try: + # Step 1: If no journal specified, ask for it + if not target_journal or not target_journal.strip(): + return { + "status": "journal_required", + "message": "Please specify the target journal for reference formatting. For example: 'Clinical Infectious Diseases', 'The Lancet', 'Nature Medicine', etc.", + "formatted_references": "", + "formatting_guidelines": "" + } + + # Step 2: Clean and validate input references + if not references_text or not references_text.strip(): + return { + "status": "references_required", + "message": "Please provide the references you'd like to format.", + "formatted_references": "", + "formatting_guidelines": "" + } + + # Step 3: Search for journal-specific formatting guidelines + formatting_guidelines = self._get_journal_formatting_guidelines(target_journal) + + # Step 4: Parse input references + parsed_references = self._parse_references(references_text) + + # Step 5: Apply journal-specific formatting + formatted_references = self._apply_journal_formatting( + parsed_references, + target_journal, + formatting_guidelines + ) + + # Step 6: Prepare response + response = { + "status": "success", + "journal": target_journal, + "formatted_references": formatted_references, + "formatting_guidelines": formatting_guidelines, + "reference_count": len(parsed_references), + "message": f"Successfully formatted {len(parsed_references)} references for {target_journal}" + } + + # Trim response if too long + response_str = json.dumps(response) + if len(response_str) > max_length: + # Truncate formatted references if needed + available_space = max_length - len(json.dumps({**response, "formatted_references": ""})) + if available_space > 100: + response["formatted_references"] = response["formatted_references"][:available_space-50] + "...[truncated]" + + return response + + except Exception as e: + return { + "status": "error", + "message": f"Error formatting references: {str(e)}", + "formatted_references": "", + "formatting_guidelines": "" + } + + def _get_journal_formatting_guidelines(self, journal_name): + """Search for journal-specific reference formatting guidelines""" + try: + # Search for journal reference formatting guidelines + search_queries = [ + f"{journal_name} reference format guidelines", + f"{journal_name} citation style requirements", + f"{journal_name} author guidelines references", + f"how to format references for {journal_name}" + ] + + guidelines = "" + for query in search_queries: + try: + # Use a simple synchronous approach with requests for now + import requests + import os + + print(f"Searching for: {query}") # Debug + + # Use Serper API directly + api_key = os.getenv("SERPER_API_KEY") + if api_key: + payload = {"q": query, "num": 3} + headers = {"X-API-KEY": api_key, "Content-Type": "application/json"} + resp = requests.post("https://google.serper.dev/search", + json=payload, headers=headers, timeout=5) + print(f"Search response status: {resp.status_code}") # Debug + + if resp.status_code == 200: + results = resp.json().get("organic", []) + print(f"Found {len(results)} results") # Debug + + for result in results: + snippet = result.get("snippet", "") + title = result.get("title", "") + content = f"{title} {snippet}" + if self._contains_formatting_info(content, journal_name): + guidelines += content + "\n\n" + print(f"Found relevant guidelines") # Debug + break + if guidelines: + break + except Exception: + continue + + if not guidelines: + guidelines = self._get_fallback_guidelines(journal_name) + + return guidelines[:1500] # Limit length + + except Exception as e: + return f"Error retrieving guidelines: {str(e)}" + + def _contains_formatting_info(self, content, journal_name): + """Check if content contains relevant formatting information""" + keywords = [ + "reference", "citation", "format", "style", "bibliography", + "author", "title", "journal", "volume", "page", "doi", + "vancouver", "ama", "chicago", "harvard", "numbered" + ] + + content_lower = content.lower() + journal_lower = journal_name.lower() + + # Must contain journal name and at least 3 formatting keywords + return (journal_lower in content_lower and + sum(1 for keyword in keywords if keyword in content_lower) >= 3) + + def _parse_references(self, references_text): + """Parse input references into structured format""" + # Split references by common delimiters + references = [] + + # Split by numbered patterns (1., 2., etc.) or line breaks + ref_parts = re.split(r'\n+|\d+\.\s+', references_text.strip()) + ref_parts = [part.strip() for part in ref_parts if part.strip()] + + for i, ref_text in enumerate(ref_parts, 1): + parsed_ref = self._extract_reference_components(ref_text) + parsed_ref["original"] = ref_text + parsed_ref["number"] = str(i) + references.append(parsed_ref) + + return references + + def _extract_reference_components(self, ref_text): + """Extract components from a single reference""" + components = { + "authors": "", + "title": "", + "journal": "", + "year": "", + "volume": "", + "issue": "", + "pages": "", + "doi": "", + "pmid": "", + "url": "" + } + + # Extract DOI + doi_match = re.search(r'doi:\s*([^\s,;]+)', ref_text, re.IGNORECASE) + if doi_match: + components["doi"] = doi_match.group(1) + + # Extract PMID + pmid_match = re.search(r'pmid:\s*(\d+)', ref_text, re.IGNORECASE) + if pmid_match: + components["pmid"] = pmid_match.group(1) + + # Extract year (4 digits) + year_match = re.search(r'\b(19|20)\d{2}\b', ref_text) + if year_match: + components["year"] = year_match.group(0) + + # Extract volume and pages pattern like "2023;45(3):123-130" + vol_pages_match = re.search(r'(\d+)\((\d+)\):(\d+[-–]\d+)', ref_text) + if vol_pages_match: + components["volume"] = vol_pages_match.group(1) + components["issue"] = vol_pages_match.group(2) + components["pages"] = vol_pages_match.group(3) + + # Extract URL + url_match = re.search(r'https?://[^\s,;]+', ref_text) + if url_match: + components["url"] = url_match.group(0) + + # Simple author extraction (everything before first period if present) + if '.' in ref_text: + potential_authors = ref_text.split('.')[0] + if len(potential_authors) < 100: # Reasonable author length + components["authors"] = potential_authors.strip() + + return components + + def _apply_journal_formatting(self, references, journal_name, guidelines): + """Apply journal-specific formatting to references""" + formatted_refs = [] + + # Determine formatting style based on journal and guidelines + style = self._determine_formatting_style(journal_name, guidelines) + + for ref in references: + if style == "vancouver": + formatted_ref = self._format_vancouver_style(ref) + elif style == "ama": + formatted_ref = self._format_ama_style(ref) + elif style == "chicago": + formatted_ref = self._format_chicago_style(ref) + else: + formatted_ref = self._format_generic_style(ref) + + formatted_refs.append(f"{ref['number']}. {formatted_ref}") + + return "\n\n".join(formatted_refs) + + def _determine_formatting_style(self, journal_name, guidelines): + """Determine the appropriate formatting style for the journal""" + journal_lower = journal_name.lower() + guidelines_lower = guidelines.lower() + + # Medical journals often use Vancouver or AMA + medical_journals = [ + "clinical infectious diseases", "journal of infectious diseases", + "the lancet", "new england journal of medicine", "jama", + "nature medicine", "bmj", "plos" + ] + + if any(j in journal_lower for j in medical_journals): + if "vancouver" in guidelines_lower: + return "vancouver" + elif "ama" in guidelines_lower: + return "ama" + else: + return "vancouver" # Default for medical journals + + # Check guidelines for specific style mentions + if "vancouver" in guidelines_lower: + return "vancouver" + elif "ama" in guidelines_lower: + return "ama" + elif "chicago" in guidelines_lower: + return "chicago" + + return "generic" + + def _format_vancouver_style(self, ref): + """Format reference in Vancouver style""" + parts = [] + + if ref["authors"]: + # Format authors (last name, first initial) + authors = self._format_authors_vancouver(ref["authors"]) + parts.append(authors) + + if ref["title"]: + title = ref["title"].strip().rstrip('.') + parts.append(title + ".") + + if ref["journal"]: + journal_part = ref["journal"] + if ref["year"]: + journal_part += f" {ref['year']}" + if ref["volume"]: + journal_part += f";{ref['volume']}" + if ref["issue"]: + journal_part += f"({ref['issue']})" + if ref["pages"]: + journal_part += f":{ref['pages']}" + parts.append(journal_part + ".") + + if ref["doi"]: + parts.append(f"doi:{ref['doi']}") + + return " ".join(parts) + + def _format_ama_style(self, ref): + """Format reference in AMA style""" + parts = [] + + if ref["authors"]: + authors = self._format_authors_ama(ref["authors"]) + parts.append(authors) + + if ref["title"]: + title = ref["title"].strip().rstrip('.') + parts.append(title + ".") + + if ref["journal"]: + journal_part = f"{ref['journal']}." + if ref["year"] and ref["volume"]: + journal_part += f" {ref['year']};{ref['volume']}" + if ref["issue"]: + journal_part += f"({ref['issue']})" + if ref["pages"]: + journal_part += f":{ref['pages']}" + parts.append(journal_part) + + if ref["doi"]: + parts.append(f"doi:{ref['doi']}") + + return " ".join(parts) + + def _format_chicago_style(self, ref): + """Format reference in Chicago style""" + parts = [] + + if ref["authors"]: + authors = self._format_authors_chicago(ref["authors"]) + parts.append(authors) + + if ref["title"]: + title = f'"{ref["title"].strip().rstrip(".")}"' + parts.append(title) + + if ref["journal"]: + journal_part = ref["journal"] + if ref["volume"]: + journal_part += f" {ref['volume']}" + if ref["issue"]: + journal_part += f", no. {ref['issue']}" + if ref["year"]: + journal_part += f" ({ref['year']})" + if ref["pages"]: + journal_part += f": {ref['pages']}" + parts.append(journal_part + ".") + + return " ".join(parts) + + def _format_generic_style(self, ref): + """Format reference in generic academic style""" + return self._format_vancouver_style(ref) # Default to Vancouver + + def _format_authors_vancouver(self, authors_str): + """Format authors in Vancouver style""" + # Simple formatting - could be enhanced + if "," in authors_str: + return authors_str.strip().rstrip('.') + "." + return authors_str.strip() + "." + + def _format_authors_ama(self, authors_str): + """Format authors in AMA style""" + return self._format_authors_vancouver(authors_str) + + def _format_authors_chicago(self, authors_str): + """Format authors in Chicago style""" + return authors_str.strip().rstrip('.') + "." + + def _get_fallback_guidelines(self, journal_name): + """Provide fallback formatting guidelines when internet search fails""" + journal_lower = journal_name.lower() + + # Known guidelines for major medical journals + known_guidelines = { + "clinical infectious diseases": "Uses numbered Vancouver style: Author(s). Title. Journal Name. Year;Volume(Issue):Pages. doi:xxx", + "the lancet": "Uses numbered references in Vancouver style with specific formatting requirements", + "nature medicine": "Uses numbered references with Nature style formatting", + "jama": "Uses AMA style numbered references with author-year format", + "new england journal of medicine": "Uses numbered Vancouver style references", + "plos one": "Uses numbered references with specific PLOS formatting requirements", + "infection control": "Uses Vancouver style for infection control journals", + "antimicrobial": "Uses medical journal Vancouver style formatting" + } + + # Check for exact or partial matches + for known_journal, guideline in known_guidelines.items(): + if known_journal in journal_lower: + return f"Formatting guidelines for {journal_name}: {guideline}" + + # Generic medical journal guidelines + return f"Standard medical journal formatting for {journal_name}: Uses numbered Vancouver style references with Author(s). Title. Journal Name. Year;Volume(Issue):Pages. doi:xxx format." + + +# Tool metadata +TOOL_METADATA = { + "name": "format_references", + "description": "Smart reference formatting tool that formats citations according to any journal's specific requirements", + "parameters": { + "references_text": { + "type": "string", + "description": "The references to format (can be in any format)" + }, + "target_journal": { + "type": "string", + "description": "The target journal name (e.g., 'Clinical Infectious Diseases', 'Nature Medicine')" + } + }, + "category": "research" +} diff --git a/tools/generate_board_exam_question.py b/tools/generate_board_exam_question.py new file mode 100644 index 0000000000000000000000000000000000000000..8fd0078fd106032c427957199b0a507b9a63f7e5 --- /dev/null +++ b/tools/generate_board_exam_question.py @@ -0,0 +1,3116 @@ +""" +generate_board_exam_question.py +------------------------------- + +Tool for generating comprehensive board exam style questions with detailed explanations. + +This tool creates long vignette-style multiple choice questions similar to those found in medical +board exams, complete with detailed explanations for each answer choice that teach key concepts. + +Key Features: +- Generates realistic clinical vignettes using OpenAI +- Creates 4-5 multiple choice options +- Provides detailed explanations for why each wrong answer is incorrect +- Explains why the correct answer is right with educational content +- Focuses on high-yield board exam topics +- Uses AI-powered quality review for educational excellence +""" + +import asyncio +import json +from typing import Any, Dict, List, Union, Optional +from tools.base import Tool +from tools.utils import ToolExecutionError, logger, load_prompt + +class GenerateBoardExamQuestionTool(Tool): + """ + Tool for generating comprehensive board exam style questions with detailed explanations. + + This tool creates realistic clinical vignettes with multiple choice questions and + provides educational explanations for each answer choice. + """ + + def __init__(self) -> None: + """Initialize the GenerateBoardExamQuestionTool.""" + super().__init__() + self.name = "generate_board_exam_question" + self.description = "Generate comprehensive board exam style questions with detailed explanations for medical education." + self.args_schema = { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "The medical topic or condition to create a board exam question about (e.g., 'pneumonia', 'heart failure', 'diabetes management')" + }, + "difficulty_level": { + "type": "string", + "description": "The difficulty level of the question", + "enum": ["medical_student", "resident", "board_exam", "advanced"], + "default": "board_exam" + }, + "question_type": { + "type": "string", + "description": "The type of question to generate", + "enum": ["diagnosis", "management", "pathophysiology", "pharmacology", "complications"], + "default": "diagnosis" + } + }, + "required": ["topic"] + } + + def openai_spec(self, legacy=False): + """Return OpenAI function specification.""" + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + + async def run( + self, + topic: str, + difficulty_level: str = "board_exam", + question_type: str = "diagnosis" + ) -> Dict[str, Any]: + """ + Generate a comprehensive board exam question using a streamlined 2-step AI pipeline. + + Step 1: Generate question blueprint (differential, clues, reasoning strategy) + Step 2: Draft initial question + critique + enhance in one combined step + + Args: + topic (str): The medical topic to create a question about + difficulty_level (str): The difficulty level (medical_student, resident, board_exam, advanced) + question_type (str): The type of question (diagnosis, management, pathophysiology, pharmacology, complications) + + Returns: + Dict[str, Any]: Complete board exam question with vignette, options, and explanations + """ + try: + logger.info(f"Generating streamlined 2-step board exam question for topic: {topic}") + + # Step 1: Generate Question Blueprint (differential strategy and clues) + blueprint = await self._generate_question_blueprint(topic, difficulty_level, question_type) + + # Step 2: Draft + Critique + Enhance in one combined step + final_result = await self._draft_critique_enhance(blueprint, topic, difficulty_level, question_type) + + return final_result + + # Format the complete question + complete_question = { + "topic": topic, + "difficulty_level": difficulty_level, + "question_type": question_type, + "blueprint": blueprint, + "vignette": final_result["vignette"], + "question_stem": final_result["question_stem"], + "answer_choices": final_result["answer_choices"], + "correct_answer": "A", # First choice is always correct in our format + "explanations": final_result["explanations"], + "enhancement_notes": final_result.get("enhancement_notes", ""), + "clinical_reasoning_notes": "Streamlined 2-step AI generation with blueprint + critique", + "question_level": "ID Fellowship Board-Level Difficulty", + "generation_method": "2-step OpenAI pipeline: Blueprint + Draft/Critique/Enhance" + } + + logger.info(f"Successfully generated 2-step board exam question for {topic}") + return complete_question + + except Exception as e: + logger.error(f"AI-powered GenerateBoardExamQuestionTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"Failed to generate AI board exam question: {e}") + + async def _generate_question_blueprint(self, topic: str, difficulty_level: str, question_type: str) -> Dict[str, Any]: + """ + Step 1: Generate the strategic blueprint for the question. + Creates the differential diagnosis strategy, clues, and reasoning approach. + """ + try: + logger.info(f"Generating question blueprint for {topic}") + + from core.utils.llm_connector import call_llm + + # Load the blueprint prompt template + prompt = load_prompt("generate_question_blueprint.j2", + topic=topic, + difficulty_level=difficulty_level, + question_type=question_type + ) + + # Call OpenAI to generate the blueprint + try: + response = await asyncio.wait_for( + call_llm(prompt), + timeout=30.0 + ) + logger.info(f"Blueprint generated: {len(response) if response else 0} characters") + except asyncio.TimeoutError: + logger.warning("Blueprint generation timed out") + return self._fallback_blueprint(topic) + + # Parse JSON response + if not response or response.strip() == "": + logger.warning("Empty blueprint response") + return self._fallback_blueprint(topic) + + # Clean and parse response + response = response.strip() + if response.startswith("```json"): + response = response[7:] + if response.endswith("```"): + response = response[:-3] + + blueprint = json.loads(response.strip()) + return blueprint + + except Exception as e: + logger.error(f"Error generating question blueprint: {str(e)}") + return self._fallback_blueprint(topic) + + async def _draft_critique_enhance(self, blueprint: Dict[str, Any], topic: str, difficulty_level: str, question_type: str) -> Dict[str, Any]: + """ + Step 2: Draft the initial question, critique it, and enhance in one combined step. + """ + try: + logger.info(f"Drafting, critiquing, and enhancing question for {topic}") + + from core.utils.llm_connector import call_llm + + # Load the combined draft+critique+enhance prompt template + prompt = load_prompt("draft_critique_enhance_board_exam.j2", + blueprint=blueprint, + topic=topic, + difficulty_level=difficulty_level, + question_type=question_type + ) + + # Call OpenAI for the combined draft+critique+enhance step + try: + response = await asyncio.wait_for( + call_llm(prompt), + timeout=45.0 # Longer timeout for combined step + ) + logger.info(f"Draft+Critique+Enhance completed: {len(response) if response else 0} characters") + except asyncio.TimeoutError: + logger.warning("Draft+Critique+Enhance timed out") + return await self._fallback_question_generation(topic, difficulty_level, question_type) + + # Parse JSON response + if not response or response.strip() == "": + logger.warning("Empty draft+critique+enhance response") + return await self._fallback_question_generation(topic, difficulty_level, question_type) + + # Clean and parse response + response = response.strip() + if response.startswith("```json"): + response = response[7:] + if response.endswith("```"): + response = response[:-3] + + final_result = json.loads(response.strip()) + return final_result + + except Exception as e: + logger.error(f"Error in draft+critique+enhance: {str(e)}") + return await self._fallback_question_generation(topic, difficulty_level, question_type) + + def _fallback_blueprint(self, topic: str) -> Dict[str, Any]: + """Fallback blueprint if AI generation fails""" + return { + "scenario_description": f"Clinical presentation involving {topic}", + "primary_diagnosis": topic, + "differential_diagnoses": [ + "Primary target condition", + "Common alternative 1", + "Common alternative 2", + "Less likely option 1", + "Less likely option 2" + ], + "diagnostic_clues": { + "supporting_primary": ["Clinical finding 1", "Clinical finding 2", "Lab finding"], + "misleading_clues": ["Distracting finding 1", "Distracting finding 2"] + }, + "reasoning_strategy": f"Question will test differential diagnosis skills for {topic}" + } + + async def _fallback_question_generation(self, topic: str, difficulty_level: str, question_type: str) -> Dict[str, Any]: + """Fallback question generation if AI steps fail""" + return { + "topic": topic, + "vignette": f"A patient presents with clinical findings consistent with {topic}. Further evaluation is needed to determine the most appropriate diagnosis and management.", + "question_stem": "What is the most likely diagnosis?", + "answer_choices": [ + topic, + "Alternative diagnosis 1", + "Alternative diagnosis 2", + "Alternative diagnosis 3", + "Alternative diagnosis 4" + ], + "explanations": { + "correct": f"The clinical presentation is most consistent with {topic}.", + "incorrect": "The other options are less likely given the clinical presentation." + }, + "enhancement_notes": "Fallback question generated due to AI processing error" + } + + async def _search_medical_guidelines(self, topic: str, question_type: str) -> Dict[str, Any]: + """ + NEW Step 0: Search medical guidelines and evidence-based sources for the topic. + This provides rich, current medical knowledge to inform question generation. + """ + try: + logger.info(f"Searching medical guidelines for topic: {topic}") + + # Use internet search tool to find current medical guidelines + from tools.internet_search import InternetSearchTool + search_tool = InternetSearchTool() + + # Create focused search queries for medical guidelines + guideline_queries = [ + f"{topic} clinical practice guidelines 2024 2023", + f"{topic} diagnosis guidelines AHA ACC ESC", + f"{topic} management protocol evidence based medicine", + f"{topic} differential diagnosis clinical criteria" + ] + + guideline_findings = { + "references": [], + "summary": "", + "key_findings": [], + "diagnostic_criteria": [], + "differential_points": [] + } + + # Search for guidelines and evidence + for query in guideline_queries[:2]: # Limit to 2 searches to avoid timeout + try: + logger.info(f"Searching: {query}") + search_results = await search_tool.run(q=query, max_results=3) + + if search_results and isinstance(search_results, dict) and 'results' in search_results: + for result in search_results['results'][:2]: # Top 2 results per query + if any(source in result.get('href', '').lower() for source in + ['guidelines', 'aha.org', 'acc.org', 'esc.org', 'uptodate', 'nejm', 'cochrane']): + guideline_findings["references"].append({ + "title": result.get('title', ''), + "url": result.get('href', ''), + "snippet": result.get('snippet', '') + }) + + # Extract key findings from snippet + snippet = result.get('snippet', '') + if snippet: + guideline_findings["key_findings"].append(snippet) + + except Exception as e: + logger.warning(f"Search query failed: {query} - {e}") + continue + + # Summarize findings + if guideline_findings["key_findings"]: + guideline_findings["summary"] = f"Found {len(guideline_findings['references'])} guideline sources for {topic}" + logger.info(f"Successfully found {len(guideline_findings['references'])} guideline references") + else: + guideline_findings["summary"] = f"No specific guidelines found, using general medical knowledge for {topic}" + logger.warning("No guideline sources found, will use general medical knowledge") + + return guideline_findings + + except Exception as e: + logger.warning(f"Guideline search failed: {e}") + # Return empty findings - the system will work with general knowledge + return { + "references": [], + "summary": f"Guideline search unavailable, using general medical knowledge for {topic}", + "key_findings": [], + "diagnostic_criteria": [], + "differential_points": [] + } + + async def _generate_ai_comparison_table(self, topic: str, difficulty_level: str, question_type: str, guideline_findings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Step 1: Use OpenAI to generate a comprehensive comparison differential table. + Now enhanced with evidence-based guideline findings. + """ + try: + from core.utils.llm_connector import call_llm + import asyncio + + logger.info(f"Loading prompt template for comparison table...") + + # Prepare guideline context for the prompt + guideline_context = "" + if guideline_findings and guideline_findings.get("key_findings"): + guideline_context = f""" +EVIDENCE-BASED CONTEXT from Medical Guidelines: +{chr(10).join(guideline_findings["key_findings"][:3])} + +GUIDELINE SOURCES: +{chr(10).join([f"- {ref.get('title', 'Unknown')}" for ref in guideline_findings.get("references", [])[:3]])} +""" + else: + guideline_context = "No specific guidelines found. Use standard medical knowledge." + + prompt = load_prompt( + "generate_comparison_table.j2", + topic=topic, + difficulty_level=difficulty_level, + question_type=question_type, + guideline_context=guideline_context + ) + logger.info(f"Prompt loaded successfully, making OpenAI API call...") + + # Call OpenAI with timeout to generate the comparison table + try: + response = await asyncio.wait_for( + call_llm(prompt), + timeout=30.0 # 30 second timeout + ) + logger.info(f"OpenAI response received: {len(response) if response else 0} characters") + except asyncio.TimeoutError: + logger.warning("OpenAI API call timed out after 30 seconds") + return await self._fallback_comparison_table(topic, difficulty_level, question_type, guideline_findings) + + # Validate and parse the JSON response + if not response or response.strip() == "": + logger.warning("OpenAI returned empty response") + return await self._fallback_comparison_table(topic, difficulty_level, question_type, guideline_findings) + + # Clean the response - remove markdown code blocks if present + cleaned_response = response.strip() + if cleaned_response.startswith("```json"): + cleaned_response = cleaned_response[7:] # Remove ```json + if cleaned_response.endswith("```"): + cleaned_response = cleaned_response[:-3] # Remove ``` + cleaned_response = cleaned_response.strip() + + try: + comparison_data = json.loads(cleaned_response) + logger.info("Successfully parsed OpenAI JSON response") + return { + "table": comparison_data, + "generation_method": "AI-powered differential reasoning with evidence-based guidelines", + "model_used": "OpenAI", + "guideline_sources": guideline_findings.get("references", []) if guideline_findings else [], + "evidence_summary": guideline_findings.get("summary", "") if guideline_findings else "" + } + except json.JSONDecodeError as e: + logger.error(f"Failed to parse comparison table JSON: {e}") + logger.error(f"Raw response: {response[:200]}...") + logger.error(f"Cleaned response: {cleaned_response[:200]}...") + # Fallback to deterministic method if JSON parsing fails + return await self._fallback_comparison_table(topic, difficulty_level, question_type, guideline_findings) + + except Exception as e: + logger.error(f"AI comparison table generation failed: {e}") + # Fallback to deterministic method + return await self._fallback_comparison_table(topic, difficulty_level, question_type, guideline_findings) + + async def _generate_ai_vignette_and_question(self, topic: str, difficulty_level: str, question_type: str, comparison_table: Dict[str, Any]) -> Dict[str, Any]: + """ + Step 2: Use OpenAI to generate complete vignette, question stem, answer choices, and explanations. + Replaces the old deterministic steps 4-6 with AI-powered clinical scenario creation. + """ + try: + from core.utils.llm_connector import call_llm + import asyncio + + # Convert comparison table to JSON string for the prompt + table_json = json.dumps(comparison_table.get("table", {}), indent=2) + + logger.info(f"Loading vignette prompt template...") + prompt = load_prompt( + "generate_board_exam_vignette.j2", + topic=topic, + difficulty_level=difficulty_level, + question_type=question_type, + comparison_table=table_json + ) + logger.info(f"Making OpenAI API call for vignette generation...") + + # Call OpenAI with timeout to generate the complete question + try: + response = await asyncio.wait_for( + call_llm(prompt), + timeout=45.0 # 45 second timeout for more complex generation + ) + logger.info(f"OpenAI vignette response received: {len(response) if response else 0} characters") + except asyncio.TimeoutError: + logger.warning("OpenAI vignette API call timed out after 45 seconds") + return await self._fallback_vignette_generation(topic, comparison_table) + + # Validate and parse the JSON response + if not response or response.strip() == "": + logger.warning("OpenAI returned empty vignette response") + return await self._fallback_vignette_generation(topic, comparison_table) + + # Clean the response - remove markdown code blocks if present + cleaned_response = response.strip() + if cleaned_response.startswith("```json"): + cleaned_response = cleaned_response[7:] # Remove ```json + if cleaned_response.endswith("```"): + cleaned_response = cleaned_response[:-3] # Remove ``` + cleaned_response = cleaned_response.strip() + + try: + vignette_data = json.loads(cleaned_response) + logger.info("Successfully parsed OpenAI vignette JSON response") + return { + **vignette_data, + "generation_method": "AI-powered clinical scenario creation", + "model_used": "OpenAI" + } + except json.JSONDecodeError as e: + logger.error(f"Failed to parse vignette JSON: {e}") + logger.error(f"Raw response: {response[:200]}...") + logger.error(f"Cleaned response: {cleaned_response[:200]}...") + # Fallback to deterministic method if JSON parsing fails + return await self._fallback_vignette_generation(topic, comparison_table) + + except Exception as e: + logger.error(f"AI vignette generation failed: {e}") + # Fallback to deterministic method + return await self._fallback_vignette_generation(topic, comparison_table) + + async def _ai_quality_review(self, topic: str, vignette_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Step 3: Use OpenAI to perform comprehensive quality review of the generated question. + Provides AI-powered assessment of clinical accuracy, educational value, and appropriateness. + """ + try: + from core.utils.llm_connector import call_llm + import asyncio + + logger.info(f"Loading quality review prompt template...") + prompt = load_prompt( + "quality_review_board_exam.j2", + topic=topic, + vignette=vignette_data.get("vignette", ""), + question_stem=vignette_data.get("question_stem", ""), + answer_choices=json.dumps(vignette_data.get("answer_choices", [])), + explanations=json.dumps(vignette_data.get("explanations", {})) + ) + logger.info(f"Making OpenAI API call for quality review...") + + # Call OpenAI for quality assessment with timeout + try: + response = await asyncio.wait_for( + call_llm(prompt), + timeout=30.0 # 30 second timeout + ) + logger.info(f"OpenAI quality review response received: {len(response) if response else 0} characters") + except asyncio.TimeoutError: + logger.warning("OpenAI quality review API call timed out after 30 seconds") + return self._fallback_quality_review(vignette_data) + + # Validate and parse the JSON response + if not response or response.strip() == "": + logger.warning("OpenAI returned empty quality review response") + return self._fallback_quality_review(vignette_data) + + # Clean the response - remove markdown code blocks if present + cleaned_response = response.strip() + if cleaned_response.startswith("```json"): + cleaned_response = cleaned_response[7:] # Remove ```json + if cleaned_response.endswith("```"): + cleaned_response = cleaned_response[:-3] # Remove ``` + cleaned_response = cleaned_response.strip() + + try: + quality_data = json.loads(cleaned_response) + logger.info("Successfully parsed OpenAI quality review JSON response") + return { + **quality_data, + "review_method": "AI-powered quality assessment", + "model_used": "OpenAI" + } + except json.JSONDecodeError as e: + logger.error(f"Failed to parse quality review JSON: {e}") + logger.error(f"Raw response: {response[:200]}...") + logger.error(f"Cleaned response: {cleaned_response[:200]}...") + # Fallback to deterministic quality scoring + return self._fallback_quality_review(vignette_data) + + except Exception as e: + logger.error(f"AI quality review failed: {e}") + # Fallback to deterministic quality scoring + return self._fallback_quality_review(vignette_data) + + async def _ai_final_enhancement(self, topic: str, vignette_data: Dict[str, Any], quality_review: Dict[str, Any]) -> Dict[str, Any]: + """ + NEW Step 4: Final AI enhancement - Apply critique and ensure board-level ID fellowship difficulty. + This step takes the quality review feedback and enhances the question to ensure: + 1. Board-level difficulty appropriate for ID fellowship + 2. Correct answer is not given away in the vignette + 3. All critique points from quality review are addressed + """ + try: + from core.utils.llm_connector import call_llm + import asyncio + + logger.info(f"Loading final enhancement prompt template...") + + # Prepare quality review context + quality_feedback = json.dumps(quality_review, indent=2) + current_vignette = vignette_data.get("vignette", "") + current_question = vignette_data.get("question_stem", "") + current_choices = json.dumps(vignette_data.get("answer_choices", [])) + current_explanations = json.dumps(vignette_data.get("explanations", {})) + + prompt = load_prompt( + "final_enhancement_board_exam.j2", + topic=topic, + current_vignette=current_vignette, + current_question=current_question, + current_choices=current_choices, + current_explanations=current_explanations, + quality_feedback=quality_feedback + ) + logger.info(f"Making OpenAI API call for final enhancement...") + + # Call OpenAI for final enhancement with timeout + try: + response = await asyncio.wait_for( + call_llm(prompt), + timeout=45.0 # 45 second timeout for complex enhancement + ) + logger.info(f"OpenAI final enhancement response received: {len(response) if response else 0} characters") + except asyncio.TimeoutError: + logger.warning("OpenAI final enhancement API call timed out after 45 seconds") + return self._fallback_final_enhancement(vignette_data, quality_review) + + # Validate and parse the JSON response + if not response or response.strip() == "": + logger.warning("OpenAI returned empty final enhancement response") + return self._fallback_final_enhancement(vignette_data, quality_review) + + # Clean the response - remove markdown code blocks if present + cleaned_response = response.strip() + if cleaned_response.startswith("```json"): + cleaned_response = cleaned_response[7:] # Remove ```json + if cleaned_response.endswith("```"): + cleaned_response = cleaned_response[:-3] # Remove ``` + cleaned_response = cleaned_response.strip() + + try: + enhanced_data = json.loads(cleaned_response) + logger.info("Successfully parsed OpenAI final enhancement JSON response") + return { + **enhanced_data, + "enhancement_method": "AI-powered final enhancement for ID board difficulty", + "model_used": "OpenAI" + } + except json.JSONDecodeError as e: + logger.error(f"Failed to parse final enhancement JSON: {e}") + logger.error(f"Raw response: {response[:200]}...") + logger.error(f"Cleaned response: {cleaned_response[:200]}...") + # Fallback to original vignette if JSON parsing fails + return self._fallback_final_enhancement(vignette_data, quality_review) + + except Exception as e: + logger.error(f"AI final enhancement failed: {e}") + # Fallback to original vignette + return self._fallback_final_enhancement(vignette_data, quality_review) + + def _fallback_final_enhancement(self, vignette_data: Dict[str, Any], quality_review: Dict[str, Any]) -> Dict[str, Any]: + """Fallback method for final enhancement when AI fails.""" + logger.warning("Using fallback for final enhancement - returning original question with minor adjustments") + + # Return original with minor enhancement notes + enhanced_data = { + "vignette": vignette_data.get("vignette", ""), + "question_stem": vignette_data.get("question_stem", ""), + "answer_choices": vignette_data.get("answer_choices", []), + "explanations": vignette_data.get("explanations", {}), + "enhancement_notes": "Fallback enhancement: Original question maintained due to API limitations", + "enhancement_method": "Fallback - no enhancement applied", + "model_used": "Deterministic" + } + + return enhanced_data + + async def _fallback_comparison_table(self, topic: str, difficulty_level: str, question_type: str, guideline_findings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Fallback method for comparison table generation when AI fails.""" + logger.warning("Using fallback deterministic comparison table generation") + + # Simple fallback - create a basic comparison table + fallback_table = { + "correct_diagnosis": topic, + "differential_diagnoses": ["Alternative diagnosis 1", "Alternative diagnosis 2", "Alternative diagnosis 3", "Alternative diagnosis 4"], + "comparison_criteria": ["Clinical presentation", "Laboratory findings", "Imaging findings", "Treatment response"] + } + + # Include guideline references if available + references = [] + if guideline_findings and guideline_findings.get("references"): + references = guideline_findings["references"] + + return { + "table": fallback_table, + "generation_method": "Fallback deterministic method", + "model_used": "Deterministic", + "guideline_sources": references, + "evidence_summary": guideline_findings.get("summary", "") if guideline_findings else "" + } + + async def _fallback_vignette_generation(self, topic: str, comparison_table: Dict[str, Any]) -> Dict[str, Any]: + """Fallback method for vignette generation when AI fails.""" + logger.warning("Using fallback deterministic vignette generation") + + # Simple fallback vignette + fallback_vignette = { + "vignette": f"A patient presents with symptoms consistent with {topic}. Clinical evaluation reveals relevant findings.", + "question_stem": f"What is the most likely diagnosis?", + "answer_choices": [ + topic, # Correct answer + "Alternative diagnosis 1", + "Alternative diagnosis 2", + "Alternative diagnosis 3", + "Alternative diagnosis 4" + ], + "explanations": { + "correct": f"The clinical presentation is most consistent with {topic}.", + "incorrect": "The other options are less likely given the clinical presentation." + } + } + + return { + **fallback_vignette, + "generation_method": "Fallback deterministic method", + "model_used": "Deterministic" + } + + def _fallback_quality_review(self, vignette_data: Dict[str, Any]) -> Dict[str, Any]: + """Fallback method for quality review when AI fails.""" + logger.warning("Using fallback deterministic quality review") + + # Simple quality scoring + return { + "percentage_score": 75, + "clinical_accuracy": "Adequate", + "educational_value": "Moderate", + "improvements_needed": ["Enhance clinical specificity", "Add more detailed explanations"], + "review_method": "Fallback deterministic scoring", + "model_used": "Deterministic" + } + + def _identify_discriminating_features(self, topic: str, question_type: str) -> Dict[str, List[str]]: + """ + Identify 3 key discriminating features that establish the diagnosis. + + This ensures the question tests true clinical reasoning rather than pattern recognition. + """ + + discriminating_features = { + "diagnostic_clues": [], + "differentials_ruled_out": [], + "reasoning_pathway": [] + } + + if "histoplasmosis" in topic.lower() or ("dimorphic" in topic.lower() and "histoplasma" in topic.lower()): + discriminating_features = { + "diagnostic_clues": [ + "Geographic exposure to Ohio River Valley with cave/soil activities", + "Bilateral hilar lymphadenopathy with multiple small pulmonary nodules", + "Lymphopenia (characteristic vs. neutrophilia in bacterial infections)" + ], + "differentials_ruled_out": [ + "Coccidioidomycosis (wrong geographic region, different antigen)", + "Blastomycosis (typically unilateral, skin lesions, different morphology)", + "Sarcoidosis (no geographic exposure, different antigen pattern)" + ], + "reasoning_pathway": [ + "Endemic mycosis in appropriate geographic region", + "Characteristic imaging pattern and immune response", + "Specific laboratory confirmation with urine antigen" + ] + } + + elif "coccidioidomycosis" in topic.lower(): + discriminating_features = { + "diagnostic_clues": [ + "Geographic exposure to southwestern US (Arizona, California)", + "Erythema nodosum with arthralgias (Valley fever syndrome)", + "Peripheral eosinophilia and elevated ESR" + ], + "differentials_ruled_out": [ + "Histoplasmosis (wrong geographic region, no cave exposure)", + "Community-acquired pneumonia (no eosinophilia, different imaging)", + "Sarcoidosis (geographic exposure history, different serology)" + ], + "reasoning_pathway": [ + "Desert southwest exposure with dust inhalation", + "Characteristic immune-mediated manifestations", + "Specific serologic and antigen testing" + ] + } + + elif "blastomycosis" in topic.lower(): + discriminating_features = { + "diagnostic_clues": [ + "Geographic exposure to Great Lakes region or southeastern US", + "Verrucous skin lesions with central ulceration", + "Broad-based budding yeast on histology" + ], + "differentials_ruled_out": [ + "Histoplasmosis (skin lesions rare, different morphology)", + "Sporotrichosis (different lesion pattern, occupational exposure)", + "Squamous cell carcinoma (histology shows organisms)" + ], + "reasoning_pathway": [ + "Endemic region with outdoor recreational activities", + "Characteristic cutaneous manifestations", + "Distinctive microscopic morphology" + ] + } + + elif "candida auris" in topic.lower(): + discriminating_features = { + "diagnostic_clues": [ + "Healthcare exposure with invasive devices (central lines, ventilators)", + "Multi-drug resistance to fluconazole, amphotericin B, and echinocandins", + "Rapid transmission in healthcare settings with environmental persistence" + ], + "differentials_ruled_out": [ + "Candida albicans (typically fluconazole-sensitive, different MALDI-TOF pattern)", + "Candida parapsilosis (usually echinocandin-sensitive, lower MIC patterns)", + "Candida glabrata (different resistance profile, distinct molecular identification)" + ], + "reasoning_pathway": [ + "High-risk healthcare environment with device exposure", + "Distinctive antifungal resistance pattern requiring molecular ID", + "Infection control implications requiring immediate isolation" + ] + } + + elif "pneumonia" in topic.lower(): + discriminating_features = { + "diagnostic_clues": [ + "Positive urinary pneumococcal antigen with lobar consolidation", + "Procalcitonin >2.0 ng/mL indicating bacterial etiology", + "CURB-65 score elements for severity assessment" + ], + "differentials_ruled_out": [ + "Atypical pneumonia (different imaging, lower procalcitonin)", + "Viral pneumonia (no positive bacterial antigen)", + "Pulmonary embolism (no consolidation, different biomarkers)" + ], + "reasoning_pathway": [ + "Bacterial vs. atypical vs. viral etiology determination", + "Severity assessment for treatment location", + "Targeted antibiotic selection based on pathogen" + ] + } + + else: + # Generic discriminating features + discriminating_features = { + "diagnostic_clues": [ + f"Specific laboratory finding unique to {topic}", + f"Characteristic imaging pattern for {topic}", + f"Epidemiologic factor supporting {topic}" + ], + "differentials_ruled_out": [ + f"Common differential #1 with different laboratory pattern", + f"Common differential #2 with different imaging findings", + f"Common differential #3 with different epidemiology" + ], + "reasoning_pathway": [ + f"Recognition of {topic} pattern", + f"Systematic differential diagnosis approach", + f"Integration of clinical and laboratory data" + ] + } + + return discriminating_features + + def _analyze_clinical_context(self, topic: str, question_type: str, difficulty_level: str) -> Dict[str, Any]: + """ + Step 1: Medical Knowledge Reasoning + Analyze the topic to understand its clinical context, pathophysiology, and key characteristics. + This provides the foundation for creating clinically accurate content. + """ + + clinical_context = { + "condition_category": "", + "pathophysiology": "", + "key_clinical_features": [], + "diagnostic_approach": "", + "treatment_principles": "", + "complications": [], + "epidemiology": "", + "board_exam_focus": "" + } + + # Analyze specific conditions with medical reasoning + if "aspergillus" in topic.lower(): + if "niger" in topic.lower(): + clinical_context = { + "condition_category": "Invasive fungal infection - Aspergillosis", + "pathophysiology": "Aspergillus niger causes invasive pulmonary aspergillosis in immunocompromised hosts, with angioinvasion leading to tissue necrosis and hemorrhage", + "key_clinical_features": [ + "Fever unresponsive to antibiotics in neutropenic patients", + "Hemoptysis and pleuritic chest pain", + "Rapid progression with tissue necrosis", + "Elevated galactomannan antigen" + ], + "diagnostic_approach": "CT chest showing nodules with halo sign, galactomannan testing, tissue biopsy with septate hyphae", + "treatment_principles": "Voriconazole or amphotericin B for invasive disease, requires prolonged therapy", + "complications": ["Massive hemoptysis", "Disseminated infection", "CNS invasion"], + "epidemiology": "Immunocompromised patients, particularly neutropenic patients and stem cell transplant recipients", + "board_exam_focus": "Recognition in immunocompromised host, differentiation from other molds, antifungal selection" + } + else: + # Generic Aspergillus + clinical_context = { + "condition_category": "Invasive fungal infection - Aspergillosis", + "pathophysiology": "Aspergillus species cause spectrum from allergic reactions to invasive disease with angioinvasion", + "key_clinical_features": [ + "Varies by host: ABPA in asthmatics, aspergilloma in cavitary disease, invasive in immunocompromised", + "Hemoptysis, cough, dyspnea", + "Fever and systemic symptoms in invasive disease" + ], + "diagnostic_approach": "Imaging, galactomannan antigen, tissue diagnosis", + "treatment_principles": "Voriconazole first-line for invasive, surgical resection for aspergilloma", + "complications": ["Massive bleeding", "Respiratory failure", "Dissemination"], + "epidemiology": "Ubiquitous environmental mold, opportunistic pathogen", + "board_exam_focus": "Clinical syndrome recognition, diagnostic approach, treatment selection" + } + + elif "candida auris" in topic.lower(): + clinical_context = { + "condition_category": "Emerging multidrug-resistant fungal pathogen", + "pathophysiology": "C. auris causes invasive candidiasis with unique resistance mechanisms and environmental persistence", + "key_clinical_features": [ + "Healthcare-associated bloodstream infection", + "Fever, hypotension, organ dysfunction", + "Multi-drug resistance pattern", + "Environmental contamination and transmission" + ], + "diagnostic_approach": "Blood cultures, molecular identification (MALDI-TOF often misidentifies), susceptibility testing", + "treatment_principles": "Echinocandin first-line, infection control isolation mandatory", + "complications": ["Endocarditis", "Endophthalmitis", "Healthcare outbreaks"], + "epidemiology": "Healthcare settings, ICU patients with devices, international spread", + "board_exam_focus": "Recognition of resistance pattern, infection control implications, treatment challenges" + } + + elif "histoplasmosis" in topic.lower(): + clinical_context = { + "condition_category": "Endemic dimorphic fungal infection", + "pathophysiology": "Histoplasma capsulatum causes pulmonary infection via inhalation, can disseminate in immunocompromised", + "key_clinical_features": [ + "Geographic exposure to Ohio/Mississippi River Valley", + "Cave or soil exposure with bird/bat droppings", + "Acute: fever, cough, weight loss, bilateral hilar lymphadenopathy", + "Chronic: progressive pulmonary disease" + ], + "diagnostic_approach": "Urine histoplasma antigen, complement fixation, tissue diagnosis", + "treatment_principles": "Itraconazole for moderate disease, amphotericin B for severe", + "complications": ["Disseminated histoplasmosis", "Chronic pulmonary disease", "Mediastinal fibrosis"], + "epidemiology": "Endemic to central US, spelunkers, construction workers", + "board_exam_focus": "Geographic correlation, diagnostic testing, treatment duration" + } + + elif "pneumonia" in topic.lower(): + clinical_context = { + "condition_category": "Community-acquired bacterial pneumonia", + "pathophysiology": "Bacterial invasion of alveolar space causing inflammatory response and consolidation", + "key_clinical_features": [ + "Acute onset fever, productive cough, pleuritic pain", + "Lobar consolidation on imaging", + "Elevated inflammatory markers" + ], + "diagnostic_approach": "Clinical presentation, chest imaging, urinary antigens, cultures", + "treatment_principles": "Empirical antibiotics based on severity and risk factors", + "complications": ["Respiratory failure", "Sepsis", "Pleural effusion"], + "epidemiology": "Common community infection, seasonal variation", + "board_exam_focus": "Severity assessment, pathogen prediction, antibiotic selection" + } + + else: + # Generic analysis for unknown conditions + clinical_context = { + "condition_category": f"Medical condition: {topic}", + "pathophysiology": f"Underlying disease mechanisms of {topic}", + "key_clinical_features": [f"Primary manifestations of {topic}"], + "diagnostic_approach": f"Diagnostic workup for {topic}", + "treatment_principles": f"Evidence-based management of {topic}", + "complications": [f"Potential complications of {topic}"], + "epidemiology": f"Population and risk factors for {topic}", + "board_exam_focus": f"Key clinical decision points for {topic}" + } + + return clinical_context + + def _reason_differential_diagnoses(self, clinical_context: Dict[str, Any]) -> Dict[str, Any]: + """ + Step 2: Differential Diagnosis Reasoning + Systematically identify the most clinically relevant differential diagnoses + based on the condition category and clinical features. + """ + + condition_category = clinical_context["condition_category"] + key_features = clinical_context["key_clinical_features"] + + # Reasoning-based differential selection + if "aspergillosis" in condition_category.lower(): + differential_analysis = { + "primary_differentials": [ + { + "condition": "Invasive pulmonary aspergillosis", + "discriminating_features": ["Galactomannan positive", "Halo sign on CT", "Septate hyphae on biopsy"], + "reasoning": "Most likely in neutropenic patient with characteristic imaging" + }, + { + "condition": "Mucormycosis (Rhizopus)", + "discriminating_features": ["Non-septate hyphae", "Tissue necrosis", "Diabetic ketoacidosis risk"], + "reasoning": "Key differential in immunocompromised with angioinvasive mold infection" + }, + { + "condition": "Bacterial pneumonia with lung abscess", + "discriminating_features": ["Positive bacterial cultures", "Response to antibiotics", "Different imaging pattern"], + "reasoning": "Must exclude bacterial cause before antifungal therapy" + }, + { + "condition": "Pulmonary tuberculosis", + "discriminating_features": ["AFB positive", "Cavitary lesions", "Different epidemiology"], + "reasoning": "Important differential in endemic areas with cavitary disease" + }, + { + "condition": "Lung cancer with secondary infection", + "discriminating_features": ["Mass lesion", "Malignant cells on biopsy", "Progressive disease"], + "reasoning": "Must consider malignancy in differential of pulmonary nodules" + } + ], + "reasoning_framework": "Focus on immunocompromised host with angioinvasive infection patterns" + } + + elif "candida auris" in condition_category.lower(): + differential_analysis = { + "primary_differentials": [ + { + "condition": "Candida auris candidemia", + "discriminating_features": ["Multi-drug resistance", "MALDI-TOF misidentification", "Healthcare transmission"], + "reasoning": "Emerging pathogen with unique resistance and transmission characteristics" + }, + { + "condition": "Candida albicans candidemia", + "discriminating_features": ["Fluconazole sensitivity", "Correct MALDI-TOF ID", "Standard resistance pattern"], + "reasoning": "Most common Candida species, typically more susceptible" + }, + { + "condition": "Candida glabrata candidemia", + "discriminating_features": ["Fluconazole resistance", "Echinocandin sensitivity", "Predictable pattern"], + "reasoning": "Known for azole resistance but different from C. auris" + }, + { + "condition": "Bacterial sepsis (MRSA)", + "discriminating_features": ["Gram-positive cocci", "Different antibiotic resistance", "No environmental persistence"], + "reasoning": "Similar clinical presentation but different pathogen class" + }, + { + "condition": "Central line-associated bloodstream infection", + "discriminating_features": ["Line-related organism", "Responds to line removal", "Less organ dysfunction"], + "reasoning": "Device-related infection with different management approach" + } + ], + "reasoning_framework": "Focus on healthcare-associated resistant organisms with infection control implications" + } + + elif "histoplasmosis" in condition_category.lower(): + differential_analysis = { + "primary_differentials": [ + { + "condition": "Acute pulmonary histoplasmosis", + "discriminating_features": ["Ohio River Valley exposure", "Positive urine antigen", "Bilateral hilar LAD"], + "reasoning": "Geographic and exposure history key to diagnosis" + }, + { + "condition": "Coccidioidomycosis", + "discriminating_features": ["Southwest US exposure", "Eosinophilia", "Spherules on histology"], + "reasoning": "Different endemic mycosis with distinct geography" + }, + { + "condition": "Blastomycosis", + "discriminating_features": ["Great Lakes region", "Skin lesions", "Broad-based budding"], + "reasoning": "Endemic mycosis with characteristic morphology and geography" + }, + { + "condition": "Sarcoidosis", + "discriminating_features": ["No geographic exposure", "Elevated ACE", "Non-caseating granulomas"], + "reasoning": "Similar imaging pattern but different pathophysiology" + }, + { + "condition": "Tuberculosis", + "discriminating_features": ["AFB positive", "Different epidemiology", "Caseating granulomas"], + "reasoning": "Important granulomatous disease differential" + } + ], + "reasoning_framework": "Focus on endemic mycoses with geographic discrimination" + } + + elif "pneumonia" in condition_category.lower(): + differential_analysis = { + "primary_differentials": [ + { + "condition": "Community-acquired pneumonia (S. pneumoniae)", + "discriminating_features": ["Lobar consolidation", "Positive urinary antigen", "High procalcitonin"], + "reasoning": "Most common CAP pathogen with characteristic presentation" + }, + { + "condition": "Atypical pneumonia (Legionella)", + "discriminating_features": ["Patchy infiltrates", "Hyponatremia", "Travel exposure"], + "reasoning": "Different clinical syndrome and epidemiology" + }, + { + "condition": "Viral pneumonia", + "discriminating_features": ["Bilateral infiltrates", "Low procalcitonin", "Viral PCR positive"], + "reasoning": "Non-bacterial etiology with different treatment" + }, + { + "condition": "Healthcare-associated pneumonia", + "discriminating_features": ["Resistant organisms", "Healthcare exposure", "Multilobar disease"], + "reasoning": "Different risk factor profile and pathogen spectrum" + }, + { + "condition": "Pulmonary embolism", + "discriminating_features": ["No consolidation", "D-dimer elevated", "Travel/immobilization"], + "reasoning": "Non-infectious cause of acute dyspnea and chest pain" + } + ], + "reasoning_framework": "Focus on bacterial vs atypical vs viral etiologies with severity assessment" + } + + else: + # Generic differential reasoning + differential_analysis = { + "primary_differentials": [ + {"condition": f"Primary diagnosis: {clinical_context['condition_category']}", "discriminating_features": ["Specific diagnostic features"], "reasoning": "Primary condition based on clinical context"}, + {"condition": "Common differential #1", "discriminating_features": ["Alternative features"], "reasoning": "Important alternative diagnosis"}, + {"condition": "Common differential #2", "discriminating_features": ["Different pattern"], "reasoning": "Must-consider differential"}, + {"condition": "Common differential #3", "discriminating_features": ["Distinct characteristics"], "reasoning": "Key distinguishing diagnosis"}, + {"condition": "Common differential #4", "discriminating_features": ["Alternative findings"], "reasoning": "Additional consideration"} + ], + "reasoning_framework": f"Systematic differential approach for {clinical_context['condition_category']}" + } + + return differential_analysis + + def _generate_reasoned_comparison_table(self, clinical_context: Dict[str, Any], differential_analysis: Dict[str, Any]) -> Dict[str, Any]: + """ + Step 3: Generate a clinically sophisticated comparison table based on medical reasoning. + Uses the clinical context and differential analysis to create specific, accurate comparisons. + """ + + # Extract the primary differentials from reasoning + differentials = differential_analysis["primary_differentials"] + + # Create detailed comparison table with clinical specificity + comparison_table = {} + + for i, differential in enumerate(differentials): + key = "correct_answer" if i == 0 else f"distractor_{i}" + + condition = differential["condition"] + discriminating_features = differential["discriminating_features"] + reasoning = differential["reasoning"] + + # Generate detailed clinical characteristics based on condition type + if "aspergillus" in condition.lower(): + if "invasive" in condition.lower(): + comparison_table[key] = { + "condition": condition, + "clinical_presentation": "Fever unresponsive to antibiotics, hemoptysis, pleuritic chest pain in neutropenic patient", + "epidemiology_risk_factors": "Neutropenia, hematologic malignancy, stem cell transplant, prolonged corticosteroids", + "laboratory_findings": "Neutrophil count <500/ΞΌL, elevated galactomannan (>0.5), negative bacterial cultures", + "imaging_characteristics": "Pulmonary nodules with halo sign on CT, cavitation in later stages", + "diagnostic_tests": "Serum galactomannan positive, tissue biopsy shows septate hyphae with dichotomous branching", + "treatment": "Voriconazole 6mg/kg IV q12h x2 then 4mg/kg q12h, or amphotericin B 5mg/kg/day", + "reasoning": reasoning + } + else: + comparison_table[key] = { + "condition": condition, + "clinical_presentation": "Variable based on syndrome - allergic, chronic, or invasive manifestations", + "epidemiology_risk_factors": "Depends on host status and environmental exposure", + "laboratory_findings": "May have eosinophilia in ABPA, galactomannan variable", + "imaging_characteristics": "Aspergilloma shows 'air crescent sign', invasive shows nodules", + "diagnostic_tests": "Specific testing depends on clinical syndrome", + "treatment": "Syndrome-specific approach", + "reasoning": reasoning + } + + elif "mucormycosis" in condition.lower(): + comparison_table[key] = { + "condition": condition, + "clinical_presentation": "Rapid onset fever, facial pain, black eschar, altered mental status", + "epidemiology_risk_factors": "Diabetic ketoacidosis, neutropenia, iron overload, corticosteroids", + "laboratory_findings": "Elevated glucose, acidosis, negative galactomannan", + "imaging_characteristics": "Rapid progression, tissue necrosis, 'black turbinate sign'", + "diagnostic_tests": "Tissue biopsy shows broad, non-septate hyphae with right-angle branching", + "treatment": "High-dose amphotericin B 10mg/kg/day, urgent surgical debridement", + "reasoning": reasoning + } + + elif "candida auris" in condition.lower(): + comparison_table[key] = { + "condition": condition, + "clinical_presentation": "Healthcare-associated fever, hypotension, multi-organ dysfunction", + "epidemiology_risk_factors": "ICU stay >14 days, central venous catheter, broad-spectrum antibiotics, mechanical ventilation", + "laboratory_findings": "Positive blood cultures, elevated lactate, multi-drug resistance pattern", + "imaging_characteristics": "May show endophthalmitis, endocarditis vegetations, hepatosplenic candidiasis", + "diagnostic_tests": "Molecular identification required (MALDI-TOF misidentifies), MIC testing shows resistance", + "treatment": "Empirical echinocandin (micafungin 100mg/day), contact isolation protocols", + "reasoning": reasoning + } + + elif "candida albicans" in condition.lower(): + comparison_table[key] = { + "condition": condition, + "clinical_presentation": "Fever, hypotension, but typically less severe organ dysfunction", + "epidemiology_risk_factors": "Healthcare or community exposure, shorter duration of risk factors", + "laboratory_findings": "Positive blood cultures, typically fluconazole-sensitive (MIC <2 ΞΌg/mL)", + "imaging_characteristics": "Similar complications but lower frequency of metastatic seeding", + "diagnostic_tests": "MALDI-TOF correctly identifies, standard antifungal susceptibility pattern", + "treatment": "Fluconazole 800mg loading then 400mg daily, or echinocandin for severe disease", + "reasoning": reasoning + } + + elif "histoplasmosis" in condition.lower(): + comparison_table[key] = { + "condition": condition, + "clinical_presentation": "Gradual onset fever, nonproductive cough, weight loss, fatigue", + "epidemiology_risk_factors": "Ohio/Mississippi River Valley exposure, cave exploration, soil disturbance activities", + "laboratory_findings": "Lymphopenia, elevated LDH, urine histoplasma antigen >10 ng/mL", + "imaging_characteristics": "Bilateral hilar lymphadenopathy with multiple small pulmonary nodules", + "diagnostic_tests": "Urine histoplasma antigen, complement fixation titers >1:32, tissue shows oval yeasts", + "treatment": "Itraconazole 200mg BID for 6-12 weeks for moderate disease", + "reasoning": reasoning + } + + elif "coccidioidomycosis" in condition.lower(): + comparison_table[key] = { + "condition": condition, + "clinical_presentation": "Fever, cough, pleuritic pain, arthralgias, erythema nodosum", + "epidemiology_risk_factors": "Southwestern US exposure (Arizona, California), dust storm exposure, construction work", + "laboratory_findings": "Eosinophilia >4%, elevated ESR, negative urine histoplasma antigen", + "imaging_characteristics": "Often unilateral infiltrate, thin-walled cavities, hilar adenopathy", + "diagnostic_tests": "Coccidioides IgM/IgG serology, spherules on tissue examination", + "treatment": "Fluconazole 400mg daily for mild-moderate disease, amphotericin B for severe", + "reasoning": reasoning + } + + elif "pneumonia" in condition.lower() and "community" in condition.lower(): + comparison_table[key] = { + "condition": condition, + "clinical_presentation": "Acute onset fever, productive cough with rust-colored sputum, pleuritic chest pain", + "epidemiology_risk_factors": "Age >65, COPD, diabetes, recent travel (cruise ship), smoking history", + "laboratory_findings": "Leukocytosis >12,000/ΞΌL with left shift, procalcitonin >2.0 ng/mL, positive urinary antigen", + "imaging_characteristics": "Lobar consolidation with air bronchograms, typically unilateral", + "diagnostic_tests": "Pneumococcal urinary antigen positive, blood cultures may grow S. pneumoniae", + "treatment": "Ceftriaxone 2g IV daily plus azithromycin 500mg IV daily for hospitalized patients", + "reasoning": reasoning + } + + else: + # Generic entry for unknown conditions + comparison_table[key] = { + "condition": condition, + "clinical_presentation": f"Clinical features characteristic of {condition}", + "epidemiology_risk_factors": f"Risk factors specific to {condition}", + "laboratory_findings": f"Laboratory pattern for {condition}", + "imaging_characteristics": f"Imaging findings in {condition}", + "diagnostic_tests": f"Diagnostic approach for {condition}", + "treatment": f"Treatment approach for {condition}", + "reasoning": reasoning + } + + return { + "table": comparison_table, + "reasoning_framework": differential_analysis["reasoning_framework"], + "clinical_context": clinical_context["condition_category"], + "correct_condition": comparison_table["correct_answer"]["condition"] + } + + def _develop_vignette_strategy(self, comparison_table: Dict[str, Any], clinical_context: Dict[str, Any]) -> Dict[str, Any]: + """ + Step 4: Vignette Construction Reasoning + Develop a strategic approach for creating a clinically complex vignette that has one clear correct answer + while including enough complexity to challenge clinical reasoning. + """ + + correct_answer = comparison_table["table"]["correct_answer"] + distractors = [comparison_table["table"][f"distractor_{i}"] for i in range(1, 5)] + + # Strategy: Include 3-4 strong features from correct answer + 1-2 confounding features + vignette_strategy = { + "primary_discriminating_features": [], + "confounding_features": [], + "clinical_complexity_elements": [], + "diagnostic_breadcrumbs": [], + "reasoning_challenges": [] + } + + # Select the 3-4 strongest discriminating features from correct answer + if "epidemiology_risk_factors" in correct_answer: + vignette_strategy["primary_discriminating_features"].append({ + "category": "epidemiology", + "feature": correct_answer["epidemiology_risk_factors"], + "strength": "strong", + "reasoning": "Key epidemiologic clue that points to correct diagnosis" + }) + + if "laboratory_findings" in correct_answer: + vignette_strategy["primary_discriminating_features"].append({ + "category": "laboratory", + "feature": correct_answer["laboratory_findings"], + "strength": "strong", + "reasoning": "Laboratory pattern specific to correct condition" + }) + + if "imaging_characteristics" in correct_answer: + vignette_strategy["primary_discriminating_features"].append({ + "category": "imaging", + "feature": correct_answer["imaging_characteristics"], + "strength": "strong", + "reasoning": "Imaging findings that distinguish correct diagnosis" + }) + + if "diagnostic_tests" in correct_answer: + vignette_strategy["primary_discriminating_features"].append({ + "category": "diagnostic", + "feature": correct_answer["diagnostic_tests"], + "strength": "strong", + "reasoning": "Specific diagnostic test results confirming diagnosis" + }) + + # Add 1-2 confounding features from distractors to maintain difficulty + for i, distractor in enumerate(distractors[:2]): + if "clinical_presentation" in distractor: + # Extract overlapping but non-specific symptoms + presentation = distractor["clinical_presentation"] + if "fever" in presentation.lower(): + vignette_strategy["confounding_features"].append({ + "feature": "fever", + "source": distractor["condition"], + "reasoning": "Non-specific symptom present in multiple conditions" + }) + if "cough" in presentation.lower() and len(vignette_strategy["confounding_features"]) < 2: + vignette_strategy["confounding_features"].append({ + "feature": "cough", + "source": distractor["condition"], + "reasoning": "Common respiratory symptom in multiple conditions" + }) + + # Add clinical complexity elements based on condition type + condition_category = clinical_context["condition_category"] + + if "fungal" in condition_category.lower() or "aspergillosis" in condition_category.lower(): + vignette_strategy["clinical_complexity_elements"] = [ + "Immunocompromised host status", + "Timeline of symptom progression", + "Response to initial antibiotics", + "Specific risk factor exposure" + ] + elif "candida" in condition_category.lower(): + vignette_strategy["clinical_complexity_elements"] = [ + "Healthcare exposure duration", + "Device-related factors", + "Resistance pattern complexity", + "Infection control implications" + ] + elif "pneumonia" in condition_category.lower(): + vignette_strategy["clinical_complexity_elements"] = [ + "Severity assessment criteria", + "Pathogen probability factors", + "Comorbidity influences", + "Treatment response indicators" + ] + + # Diagnostic breadcrumbs - subtle clues that guide toward correct answer + vignette_strategy["diagnostic_breadcrumbs"] = [ + "Specific temporal relationships", + "Characteristic physical exam findings", + "Laboratory value patterns", + "Imaging evolution over time" + ] + + # Reasoning challenges - elements that test clinical thinking + vignette_strategy["reasoning_challenges"] = [ + "Must integrate multiple data points", + "Requires prioritization of findings", + "Tests knowledge of disease mechanisms", + "Challenges recognition of patterns" + ] + + return vignette_strategy + + def _generate_reasoned_vignette(self, vignette_strategy: Dict[str, Any]) -> str: + """ + Step 5: Generate a clinically sophisticated vignette based on the strategic reasoning. + Creates a complex but focused clinical scenario with one clear correct answer. + """ + + # Extract key elements from strategy + primary_features = vignette_strategy["primary_discriminating_features"] + confounding_features = vignette_strategy["confounding_features"] + complexity_elements = vignette_strategy["clinical_complexity_elements"] + + # Identify the condition type from primary features + condition_indicators = [] + for feature in primary_features: + condition_indicators.append(feature["feature"].lower()) + + combined_indicators = " ".join(condition_indicators) + + # Generate condition-specific vignette + if "neutropenia" in combined_indicators or "galactomannan" in combined_indicators: + # Aspergillosis vignette + vignette = ( + "A 34-year-old man with acute myeloid leukemia is admitted for induction chemotherapy. " + "On day 12 of hospitalization, he develops fever to 102.8Β°F (39.3Β°C) despite broad-spectrum " + "antibiotics (piperacillin-tazobactam and vancomycin) for 48 hours. He reports new onset " + "of right-sided pleuritic chest pain and has had two episodes of small-volume hemoptysis. " + "Physical examination reveals an ill-appearing man with temperature 102.8Β°F, heart rate 115 bpm, " + "blood pressure 110/65 mmHg, respiratory rate 24/min, and oxygen saturation 94% on 2L nasal cannula. " + "Lung examination shows decreased breath sounds at the right base with dullness to percussion. " + "Laboratory studies show: WBC 400/ΞΌL (normal 4,500-11,000) with 85% neutrophils, absolute neutrophil " + "count 340/ΞΌL, platelet count 45,000/ΞΌL, and creatinine 1.2 mg/dL. Chest CT demonstrates a 2.5-cm " + "right lower lobe nodule with surrounding ground-glass opacity ('halo sign') and a smaller left " + "upper lobe nodule. Serum galactomannan index is 2.8 (normal <0.5). Blood cultures remain negative " + "after 72 hours. The patient has no known drug allergies and has been receiving prophylactic " + "fluconazole 400mg daily since admission." + ) + + elif "icu" in combined_indicators and "resistance" in combined_indicators: + # Candida auris vignette + vignette = ( + "A 67-year-old woman with end-stage renal disease on hemodialysis is admitted to the ICU " + "following complications from abdominal surgery 18 days ago. She has required multiple " + "invasive procedures including central venous catheter placement, mechanical ventilation, " + "and broad-spectrum antibiotic therapy with vancomycin, meropenem, and fluconazole. " + "On hospital day 20, she develops new fever to 101.6Β°F (38.7Β°C) and hypotension requiring " + "vasopressor support. Physical examination reveals temperature 101.6Β°F, heart rate 125 bpm, " + "blood pressure 85/45 mmHg on norepinephrine, and clear lungs. The central line insertion " + "site appears clean without erythema. Laboratory studies show: WBC 14,200/ΞΌL with 78% neutrophils " + "and 15% bands, lactate 3.8 mmol/L (normal <2.0), and procalcitonin 1.2 ng/mL. Blood cultures " + "drawn from both central and peripheral sites grow yeast after 16 hours. The isolate demonstrates " + "resistance to fluconazole (MIC >64 ΞΌg/mL) and intermediate resistance to amphotericin B (MIC 2 ΞΌg/mL). " + "MALDI-TOF mass spectrometry reports 'Candida haemulonii' with low confidence score (1.6). " + "The microbiology laboratory requests molecular identification due to the unusual resistance pattern." + ) + + elif "ohio" in combined_indicators or "cave" in combined_indicators: + # Histoplasmosis vignette + vignette = ( + "A 42-year-old construction worker from Cincinnati, Ohio, presents to the emergency department " + "with a 4-week history of persistent nonproductive cough, low-grade fever, and 15-pound " + "unintentional weight loss. He reports recent recreational spelunking activities at Mammoth Cave " + "in Kentucky approximately 7 weeks ago with several friends. Initial symptoms began gradually " + "2 weeks after the cave trip with fatigue and intermittent fever, progressing to persistent cough " + "and night sweats. He denies chest pain initially but now reports mild bilateral chest discomfort. " + "Physical examination reveals an afebrile man (temperature 99.8Β°F) with scattered tender erythematous " + "nodules on both anterior shins and mild bilateral ankle swelling. Vital signs show heart rate 88 bpm, " + "blood pressure 135/82 mmHg, respiratory rate 18/min, and oxygen saturation 96% on room air. " + "Laboratory studies reveal: WBC 3,400/ΞΌL (normal 4,500-11,000) with 68% lymphocytes, ESR 82 mm/hr, " + "and LDH 445 U/L (normal <250). Chest CT demonstrates bilateral hilar lymphadenopathy with multiple " + "small (<1 cm) pulmonary nodules scattered throughout both lung fields. Urine Histoplasma antigen " + "is 18.5 ng/mL (normal <0.6 ng/mL)." + ) + + elif "cruise" in combined_indicators and "consolidation" in combined_indicators: + # Pneumonia vignette + vignette = ( + "A 71-year-old man with COPD (FEV1 42% predicted) and well-controlled type 2 diabetes mellitus " + "presents to the emergency department with a 36-hour history of acute onset productive cough " + "with rust-colored sputum, right-sided pleuritic chest pain, and fever. He recently returned " + "from a 10-day cruise to the Caribbean 4 days ago and felt well during the entire trip. " + "Symptoms began abruptly yesterday evening with rigors and high fever, followed by productive " + "cough and sharp chest pain that worsens with deep inspiration. He denies recent antibiotic use " + "or hospitalization. Physical examination reveals an ill-appearing man with temperature 103.1Β°F " + "(39.5Β°C), heart rate 118 bmp, blood pressure 125/78 mmHg, respiratory rate 28/min, and oxygen " + "saturation 88% on room air improving to 95% on 3L nasal cannula. Lung examination shows dullness " + "to percussion and bronchial breath sounds over the right lower lobe posteriorly. Laboratory studies " + "reveal: WBC 17,200/ΞΌL with 86% neutrophils and 10% bands, procalcitonin 4.2 ng/mL (normal <0.1), " + "lactate 1.9 mmol/L, and creatinine 1.1 mg/dL. Chest X-ray demonstrates right lower lobe consolidation " + "with air bronchograms. Pneumococcal urinary antigen is positive. His calculated CURB-65 score is 2 " + "(age >65, respiratory rate >30)." + ) + + else: + # Generic sophisticated vignette + vignette = ( + f"A patient with relevant clinical risk factors presents with characteristic symptoms and signs. " + f"The presentation includes key discriminating findings from the primary features: " + f"{', '.join([f['feature'] for f in primary_features[:2]])}. " + f"Physical examination and diagnostic studies reveal significant findings that help establish " + f"the diagnosis through systematic clinical reasoning. The case includes some overlapping features " + f"that could suggest alternative diagnoses, requiring careful analysis of all discriminating elements." + ) + + return vignette + + def _create_reasoned_question_stem(self, vignette: str, clinical_context: Dict[str, Any]) -> str: + """ + Step 6: Create a focused question stem that tests the specific clinical reasoning objective. + """ + + condition_category = clinical_context["condition_category"] + + # Generate condition-appropriate question stems + if "antifungal" in condition_category.lower() or "aspergillosis" in condition_category.lower(): + question_stem = ( + f"{vignette}\n\n" + "Which of the following is the most appropriate next step in management?" + ) + elif "identification" in condition_category.lower() or "candida" in condition_category.lower(): + question_stem = ( + f"{vignette}\n\n" + "Which of the following is the most likely causative organism?" + ) + elif "treatment" in condition_category.lower(): + question_stem = ( + f"{vignette}\n\n" + "Which of the following is the most appropriate initial treatment?" + ) + elif "diagnosis" in condition_category.lower(): + question_stem = ( + f"{vignette}\n\n" + "Which of the following is the most likely diagnosis?" + ) + else: + question_stem = ( + f"{vignette}\n\n" + "Based on the clinical presentation and findings, which of the following is most appropriate?" + ) + + return question_stem + + def _generate_reasoned_answer_choices(self, comparison_table: Dict[str, Any], clinical_context: Dict[str, Any]) -> List[str]: + """ + Step 7: Generate answer choices based on the comparison table with clinical reasoning. + Each choice should represent a clinically plausible option with varying degrees of appropriateness. + """ + + table = comparison_table["table"] + condition_category = clinical_context["condition_category"] + + answer_choices = [] + + # Correct answer - format based on condition category + correct_answer = table["correct_answer"] + if "antifungal" in condition_category.lower(): + if "aspergillosis" in correct_answer["condition"].lower(): + answer_choices.append("Initiate voriconazole therapy") + elif "candida" in correct_answer["condition"].lower(): + answer_choices.append("Start micafungin therapy") + else: + answer_choices.append("Begin targeted antifungal therapy") + elif "identification" in condition_category.lower(): + answer_choices.append(correct_answer["condition"]) + elif "treatment" in condition_category.lower(): + treatment = correct_answer.get("treatment", "Appropriate targeted therapy") + answer_choices.append(treatment) + else: + answer_choices.append(correct_answer["condition"]) + + # Generate distractor choices from comparison table + for i in range(1, 5): + distractor_key = f"distractor_{i}" + if distractor_key in table: + distractor = table[distractor_key] + + if "antifungal" in condition_category.lower(): + if "bacterial" in distractor["condition"].lower(): + answer_choices.append("Continue current antibiotic therapy") + elif "viral" in distractor["condition"].lower(): + answer_choices.append("Start antiviral therapy") + elif "tuberculosis" in distractor["condition"].lower(): + answer_choices.append("Initiate anti-tuberculosis therapy") + else: + answer_choices.append(f"Begin therapy for {distractor['condition']}") + elif "identification" in condition_category.lower(): + answer_choices.append(distractor["condition"]) + elif "treatment" in condition_category.lower(): + treatment = distractor.get("treatment", f"Treatment for {distractor['condition']}") + answer_choices.append(treatment) + else: + answer_choices.append(distractor["condition"]) + + # Ensure we have exactly 5 choices + while len(answer_choices) < 5: + answer_choices.append("Alternative management approach") + + return answer_choices[:5] + + def _generate_reasoned_explanations(self, comparison_table: Dict[str, Any], clinical_context: Dict[str, Any]) -> Dict[str, str]: + """ + Step 8: Generate detailed explanations that demonstrate clinical reasoning for each answer choice. + """ + + table = comparison_table["table"] + correct_answer = table["correct_answer"] + + explanations = { + "correct_explanation": "", + "distractor_explanations": [] + } + + # Correct answer explanation + condition = correct_answer["condition"] + key_features = [] + + if "epidemiology_risk_factors" in correct_answer: + key_features.append(f"epidemiologic risk factors ({correct_answer['epidemiology_risk_factors']})") + if "laboratory_findings" in correct_answer: + key_features.append(f"laboratory findings ({correct_answer['laboratory_findings']})") + if "diagnostic_tests" in correct_answer: + key_features.append(f"diagnostic test results ({correct_answer['diagnostic_tests']})") + if "imaging_characteristics" in correct_answer: + key_features.append(f"imaging characteristics ({correct_answer['imaging_characteristics']})") + + explanations["correct_explanation"] = ( + f"Correct. The clinical presentation is most consistent with {condition}. " + f"Key supporting features include: {', '.join(key_features)}. " + f"These findings in combination provide strong evidence for this diagnosis and guide appropriate management." + ) + + # Distractor explanations + for i in range(1, 5): + distractor_key = f"distractor_{i}" + if distractor_key in table: + distractor = table[distractor_key] + + # Identify why this distractor is incorrect + differences = [] + if "epidemiology_risk_factors" in distractor and "epidemiology_risk_factors" in correct_answer: + if distractor["epidemiology_risk_factors"] != correct_answer["epidemiology_risk_factors"]: + differences.append(f"epidemiologic factors differ ({distractor['epidemiology_risk_factors']} vs expected {correct_answer['epidemiology_risk_factors']})") + + if "laboratory_findings" in distractor and "laboratory_findings" in correct_answer: + if distractor["laboratory_findings"] != correct_answer["laboratory_findings"]: + differences.append(f"laboratory pattern inconsistent ({distractor['laboratory_findings']} not consistent with findings)") + + if "diagnostic_tests" in distractor and "diagnostic_tests" in correct_answer: + if distractor["diagnostic_tests"] != correct_answer["diagnostic_tests"]: + differences.append(f"diagnostic test results do not support this diagnosis") + + explanation = ( + f"Incorrect. While {distractor['condition']} could present with some similar features, " + f"the clinical presentation is not consistent with this diagnosis. " + f"Key differences include: {', '.join(differences[:2]) if differences else 'clinical pattern and diagnostic findings do not align with this condition'}." + ) + + explanations["distractor_explanations"].append(explanation) + + return explanations + + def _clinical_quality_review(self, question_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Step 9: Final clinical quality review and refinement. + Reviews the complete question for clinical accuracy, educational value, and appropriate difficulty. + """ + + review_results = { + "clinical_accuracy_score": 0, + "educational_value_score": 0, + "difficulty_appropriateness": 0, + "improvements_needed": [], + "quality_passed": False + } + + # Clinical accuracy assessment + vignette = question_data.get("question_stem", "") + answer_choices = question_data.get("answer_choices", []) + + clinical_accuracy_points = 0 + + # Check for specific clinical details (each worth 1 point, max 5) + clinical_details = [ + "specific lab values", + "imaging findings", + "epidemiologic factors", + "temporal relationships", + "physical exam findings" + ] + + for detail in clinical_details: + if any(keyword in vignette.lower() for keyword in detail.split()): + clinical_accuracy_points += 1 + + review_results["clinical_accuracy_score"] = min(clinical_accuracy_points, 5) + + # Educational value assessment + educational_points = 0 + + if len(answer_choices) == 5: + educational_points += 1 + if any("most appropriate" in choice.lower() for choice in [question_data.get("question_stem", "")]): + educational_points += 1 + if len(vignette) > 300: # Substantial clinical detail + educational_points += 1 + if "reasoning" in str(question_data.get("explanations", {})).lower(): + educational_points += 1 + + review_results["educational_value_score"] = educational_points + + # Difficulty appropriateness (board exam level) + difficulty_points = 0 + + if len(vignette) > 200: # Complex vignette + difficulty_points += 1 + if any(term in vignette.lower() for term in ["icu", "immunocompromised", "resistance", "multiple"]): + difficulty_points += 1 + if review_results["clinical_accuracy_score"] >= 3: + difficulty_points += 1 + + review_results["difficulty_appropriateness"] = difficulty_points + + # Quality assessment + total_score = ( + review_results["clinical_accuracy_score"] + + review_results["educational_value_score"] + + review_results["difficulty_appropriateness"] + ) + + if total_score >= 8: + review_results["quality_passed"] = True + else: + if review_results["clinical_accuracy_score"] < 3: + review_results["improvements_needed"].append("Increase clinical specificity and accuracy") + if review_results["educational_value_score"] < 3: + review_results["improvements_needed"].append("Enhance educational value and learning objectives") + if review_results["difficulty_appropriateness"] < 2: + review_results["improvements_needed"].append("Adjust difficulty to board exam level") + + return review_results + + def _generate_comparison_table(self, topic: str, question_type: str, difficulty_level: str) -> Dict[str, Any]: + """ + Generate a comprehensive comparison table with the correct answer and 4 plausible distractors. + + This systematic approach creates discriminating features for each option to enable + sophisticated question generation that tests true clinical reasoning. + + Returns a table with 5 conditions and their distinguishing characteristics. + """ + + # Define categories for comparison based on question type + if question_type == "diagnosis": + categories = [ + "clinical_presentation", + "epidemiology_risk_factors", + "laboratory_findings", + "imaging_characteristics", + "diagnostic_tests" + ] + elif question_type == "management": + categories = [ + "first_line_treatment", + "dosing_regimen", + "duration_therapy", + "monitoring_requirements", + "contraindications" + ] + elif question_type == "pharmacology": + categories = [ + "mechanism_action", + "absorption_distribution", + "drug_interactions", + "adverse_effects", + "dosing_adjustments" + ] + else: + categories = [ + "pathophysiology", + "clinical_features", + "diagnostic_approach", + "treatment_principles", + "prognosis_complications" + ] + + # Generate condition-specific comparison table + if "candida auris" in topic.lower(): + comparison_table = { + "correct_answer": { + "condition": "Candida auris invasive candidiasis", + "clinical_presentation": "ICU patient with central line, fever, hypotension, multi-organ dysfunction", + "epidemiology_risk_factors": "Healthcare exposure, invasive devices, broad-spectrum antibiotics, ICU stay >14 days", + "laboratory_findings": "Blood cultures positive for yeast, elevated lactate, multi-drug resistance pattern", + "imaging_characteristics": "May show endophthalmitis, endocarditis vegetations, or hepatosplenic lesions", + "diagnostic_tests": "MALDI-TOF often misidentifies; requires molecular identification (PCR/sequencing)" + }, + "distractor_1": { + "condition": "Candida albicans candidemia", + "clinical_presentation": "Similar fever and hypotension but usually less severe organ dysfunction", + "epidemiology_risk_factors": "Healthcare exposure but can occur in community settings, shorter ICU stays", + "laboratory_findings": "Blood cultures positive, typically fluconazole-sensitive (MIC <2 ΞΌg/mL)", + "imaging_characteristics": "Similar complications but lower frequency of metastatic seeding", + "diagnostic_tests": "MALDI-TOF correctly identifies as C. albicans, standard susceptibility testing" + }, + "distractor_2": { + "condition": "Candida glabrata candidemia", + "clinical_presentation": "Often more indolent course, less acute organ dysfunction", + "epidemiology_risk_factors": "Diabetes, advanced age, previous azole exposure", + "laboratory_findings": "Fluconazole-resistant but echinocandin-sensitive, normal lactate often", + "imaging_characteristics": "Lower rate of metastatic complications compared to C. auris", + "diagnostic_tests": "MALDI-TOF correctly identifies, predictable resistance pattern" + }, + "distractor_3": { + "condition": "Bacterial sepsis (MRSA)", + "clinical_presentation": "Similar fever, hypotension, but may have skin/soft tissue source", + "epidemiology_risk_factors": "Healthcare exposure, invasive devices, prior MRSA colonization", + "laboratory_findings": "Blood cultures positive for gram-positive cocci, procalcitonin markedly elevated", + "imaging_characteristics": "May show pneumonia, skin infections, or endocarditis", + "diagnostic_tests": "Gram stain shows gram-positive cocci in clusters, rapid PCR available" + }, + "distractor_4": { + "condition": "Candidemia due to central line infection", + "clinical_presentation": "Fever temporally related to line access, may lack organ dysfunction", + "epidemiology_risk_factors": "Recent line placement, total parenteral nutrition, immunosuppression", + "laboratory_findings": "Blood cultures positive from line and peripheral sites", + "imaging_characteristics": "May show line-associated thrombus or vegetation", + "diagnostic_tests": "Standard Candida species identification, usually antifungal-sensitive" + } + } + + elif "histoplasmosis" in topic.lower() or ("dimorphic" in topic.lower() and "histoplasma" in topic.lower()): + comparison_table = { + "correct_answer": { + "condition": "Acute pulmonary histoplasmosis", + "clinical_presentation": "Fever, cough, weight loss after cave/soil exposure", + "epidemiology_risk_factors": "Ohio/Mississippi River Valley, cave exploration, soil disturbance", + "laboratory_findings": "Lymphopenia, elevated LDH, positive urine antigen", + "imaging_characteristics": "Bilateral hilar lymphadenopathy with multiple small pulmonary nodules", + "diagnostic_tests": "Urine Histoplasma antigen >10 ng/mL, complement fixation titers >1:32" + }, + "distractor_1": { + "condition": "Coccidioidomycosis (Valley Fever)", + "clinical_presentation": "Similar respiratory symptoms but with arthritis, erythema nodosum", + "epidemiology_risk_factors": "Southwestern US (Arizona, California), dust exposure", + "laboratory_findings": "Eosinophilia (>4%), elevated ESR, negative urine histoplasma antigen", + "imaging_characteristics": "Often unilateral infiltrate, thin-walled cavities", + "diagnostic_tests": "Coccidioides IgM/IgG, spherules on histology" + }, + "distractor_2": { + "condition": "Blastomycosis", + "clinical_presentation": "Pulmonary symptoms with characteristic skin lesions", + "epidemiology_risk_factors": "Great Lakes region, outdoor activities near water", + "laboratory_findings": "Normal lymphocyte count, negative urine histoplasma antigen", + "imaging_characteristics": "Mass-like consolidation, often unilateral", + "diagnostic_tests": "Broad-based budding yeasts on histology, Blastomyces antigen" + }, + "distractor_3": { + "condition": "Sarcoidosis with LΓΆfgren syndrome", + "clinical_presentation": "Similar bilateral hilar lymphadenopathy with erythema nodosum", + "epidemiology_risk_factors": "No specific geographic exposure, autoimmune predisposition", + "laboratory_findings": "Normal lymphocytes, elevated ACE level, negative fungal antigens", + "imaging_characteristics": "Bilateral hilar lymphadenopathy but without pulmonary nodules", + "diagnostic_tests": "Elevated serum ACE, non-caseating granulomas on biopsy" + }, + "distractor_4": { + "condition": "Hypersensitivity pneumonitis", + "clinical_presentation": "Cough and dyspnea after organic dust exposure", + "epidemiology_risk_factors": "Occupational/environmental organic dust exposure", + "laboratory_findings": "Normal lymphocytes, negative fungal antigens", + "imaging_characteristics": "Ground-glass opacities, upper lobe predominance", + "diagnostic_tests": "Specific precipitating antibodies, lymphocytosis on BAL" + } + } + + elif "pneumonia" in topic.lower(): + comparison_table = { + "correct_answer": { + "condition": "Community-acquired pneumonia (S. pneumoniae)", + "clinical_presentation": "Acute onset fever, productive cough with rust-colored sputum, pleuritic pain", + "epidemiology_risk_factors": "Age >65, COPD, diabetes, recent travel (cruise)", + "laboratory_findings": "Leukocytosis with left shift, procalcitonin >2.0 ng/mL, positive urinary antigen", + "imaging_characteristics": "Lobar consolidation with air bronchograms", + "diagnostic_tests": "Positive pneumococcal urinary antigen, blood cultures may be positive" + }, + "distractor_1": { + "condition": "Atypical pneumonia (Legionella)", + "clinical_presentation": "Gradual onset, dry cough, GI symptoms, confusion", + "epidemiology_risk_factors": "Recent travel, hotel/cruise exposure, older age", + "laboratory_findings": "Hyponatremia, elevated LDH, lower procalcitonin (<1.0 ng/mL)", + "imaging_characteristics": "Patchy infiltrates, often multilobar", + "diagnostic_tests": "Legionella urinary antigen (type 1 only), respiratory PCR" + }, + "distractor_2": { + "condition": "Viral pneumonia (Influenza)", + "clinical_presentation": "Fever, myalgias, dry cough, gradual onset", + "epidemiology_risk_factors": "Winter season, crowded conditions, unvaccinated", + "laboratory_findings": "Normal or low WBC, low procalcitonin (<0.5 ng/mL)", + "imaging_characteristics": "Bilateral interstitial infiltrates", + "diagnostic_tests": "Respiratory viral PCR positive for influenza" + }, + "distractor_3": { + "condition": "Healthcare-associated pneumonia (P. aeruginosa)", + "clinical_presentation": "Similar symptoms but in hospitalized/recent healthcare exposure", + "epidemiology_risk_factors": "Recent hospitalization, ventilator, broad-spectrum antibiotics", + "laboratory_findings": "Leukocytosis, elevated procalcitonin, resistant organism pattern", + "imaging_characteristics": "Often multilobar, necrotizing changes", + "diagnostic_tests": "Sputum culture shows resistant gram-negative rods" + }, + "distractor_4": { + "condition": "Pulmonary embolism", + "clinical_presentation": "Acute dyspnea, pleuritic pain, but no productive cough", + "epidemiology_risk_factors": "Recent travel, immobilization, hypercoagulable state", + "laboratory_findings": "Elevated D-dimer, normal procalcitonin, no leukocytosis", + "imaging_characteristics": "No consolidation, may show pleural effusion", + "diagnostic_tests": "CT pulmonary angiogram shows filling defects" + } + } + + else: + # Generic comparison table for any condition + comparison_table = { + "correct_answer": { + "condition": f"Primary diagnosis: {topic}", + "clinical_presentation": f"Classic presentation of {topic} with characteristic symptoms", + "epidemiology_risk_factors": f"Typical risk factors and demographics for {topic}", + "laboratory_findings": f"Laboratory pattern diagnostic for {topic}", + "imaging_characteristics": f"Imaging findings pathognomonic for {topic}", + "diagnostic_tests": f"Gold standard diagnostic test for {topic}" + }, + "distractor_1": { + "condition": f"Common differential diagnosis #1", + "clinical_presentation": f"Similar symptoms but key distinguishing features", + "epidemiology_risk_factors": f"Different risk factor profile", + "laboratory_findings": f"Laboratory pattern that rules out {topic}", + "imaging_characteristics": f"Different imaging pattern", + "diagnostic_tests": f"Different diagnostic test results" + }, + "distractor_2": { + "condition": f"Common differential diagnosis #2", + "clinical_presentation": f"Overlapping symptoms with subtle differences", + "epidemiology_risk_factors": f"Alternative epidemiologic pattern", + "laboratory_findings": f"Distinct laboratory abnormalities", + "imaging_characteristics": f"Alternative imaging findings", + "diagnostic_tests": f"Specific tests that distinguish from {topic}" + }, + "distractor_3": { + "condition": f"Common differential diagnosis #3", + "clinical_presentation": f"Similar initial presentation", + "epidemiology_risk_factors": f"Different patient population", + "laboratory_findings": f"Laboratory findings inconsistent with {topic}", + "imaging_characteristics": f"Imaging that excludes {topic}", + "diagnostic_tests": f"Testing that rules out {topic}" + }, + "distractor_4": { + "condition": f"Common differential diagnosis #4", + "clinical_presentation": f"Mimics {topic} but with key differences", + "epidemiology_risk_factors": f"Distinct epidemiologic factors", + "laboratory_findings": f"Different laboratory profile", + "imaging_characteristics": f"Characteristic imaging for alternative diagnosis", + "diagnostic_tests": f"Confirmatory tests for alternative condition" + } + } + + return { + "table": comparison_table, + "categories": categories, + "correct_condition": comparison_table["correct_answer"]["condition"], + "difficulty_level": difficulty_level + } + + def _generate_sophisticated_vignette(self, topic: str, question_type: str, comparison_table: Dict[str, Any]) -> str: + """ + Generate a sophisticated clinical vignette using the comparison table. + + Strategy: + 1. Use 3-4 key features from the correct answer + 2. Include 1-2 features that could suggest distractors to maintain difficulty + 3. Avoid giving away the answer while providing sufficient discriminating information + """ + + correct_features = comparison_table["table"]["correct_answer"] + distractors = [comparison_table["table"][f"distractor_{i}"] for i in range(1, 5)] + + # Select 3 strong discriminating features from correct answer + key_correct_features = [] + if "clinical_presentation" in correct_features: + key_correct_features.append(correct_features["clinical_presentation"]) + if "epidemiology_risk_factors" in correct_features: + key_correct_features.append(correct_features["epidemiology_risk_factors"]) + if "laboratory_findings" in correct_features: + key_correct_features.append(correct_features["laboratory_findings"]) + + # Select 1-2 features that might suggest distractors (to maintain difficulty) + confounding_features = [] + for distractor in distractors[:2]: # Use first 2 distractors + if "clinical_presentation" in distractor: + # Extract a non-specific symptom that could apply to multiple conditions + presentation = distractor["clinical_presentation"] + if "fever" in presentation.lower() and "fever" not in key_correct_features[0].lower(): + confounding_features.append("fever") + elif "cough" in presentation.lower() and len(confounding_features) < 2: + confounding_features.append("cough") + + # Construct vignette based on specific topic + if "candida auris" in topic.lower(): + vignette = ( + f"A 72-year-old man with end-stage renal disease on hemodialysis develops fever and hypotension " + f"48 hours after central venous catheter placement in the ICU. He has been hospitalized for " + f"3 weeks following complicated abdominal surgery with multiple invasive procedures and " + f"broad-spectrum antibiotic exposure. Physical examination reveals temperature 101.8Β°F (38.8Β°C), " + f"heart rate 125 bpm, blood pressure 85/45 mmHg despite fluid resuscitation. The central line " + f"insertion site appears clean without erythema. Laboratory studies show WBC 12,500/ΞΌL with " + f"left shift, lactate 3.2 mmol/L, and creatinine 4.8 mg/dL (baseline 4.2 mg/dL). Blood cultures " + f"drawn from both the central line and peripheral sites grow yeast after 18 hours. The organism " + f"demonstrates resistance to fluconazole (MIC >64 ΞΌg/mL) and intermediate resistance to " + f"amphotericin B (MIC 2 ΞΌg/mL). MALDI-TOF mass spectrometry initially reports the organism as " + f"'C. haemulonii' with low confidence score. The microbiology laboratory requests molecular " + f"identification due to the unusual resistance pattern and concern for an emerging pathogen." + ) + + elif "histoplasmosis" in topic.lower(): + vignette = ( + f"A 45-year-old construction worker from Louisville, Kentucky, presents with a 3-week history of " + f"nonproductive cough, low-grade fever, and 12-pound unintentional weight loss. He reports " + f"recent recreational cave exploration activities in Mammoth Cave 6 weeks prior to symptom onset. " + f"Initial symptoms began gradually with fatigue and low-grade fever, progressing to persistent " + f"cough and night sweats. Physical examination reveals temperature 100.8Β°F (38.2Β°C), scattered " + f"tender erythematous nodules on both shins, and mild bilateral ankle swelling. Vital signs " + f"are otherwise stable except for mild tachypnea. Laboratory studies show WBC 3,100/ΞΌL " + f"(normal 4,500-11,000) with 65% lymphocytes, ESR 75 mm/hr, and LDH 420 U/L (normal <250 U/L). " + f"Chest CT demonstrates bilateral hilar lymphadenopathy with multiple small (<1 cm) pulmonary " + f"nodules scattered throughout both lung fields. Urine Histoplasma antigen is 15.2 ng/mL " + f"(normal <0.6 ng/mL). The patient denies immunocompromising conditions and has no significant " + f"past medical history aside from seasonal allergies." + ) + + elif "pneumonia" in topic.lower(): + vignette = ( + f"A 68-year-old man with COPD (FEV1 45% predicted) and well-controlled diabetes mellitus " + f"presents to the emergency department with 48 hours of productive cough with rust-colored " + f"sputum, right-sided pleuritic chest pain, and fever. He recently returned from a 7-day " + f"cruise 5 days ago and reports feeling well during travel. Symptoms began abruptly with " + f"rigors and high fever, followed by productive cough and sharp chest pain that worsens " + f"with inspiration. Physical examination reveals an ill-appearing man with dullness to " + f"percussion and bronchial breath sounds over the right lower lobe. Temperature 102.8Β°F " + f"(39.3Β°C), heart rate 110 bpm, blood pressure 118/72 mmHg, respiratory rate 26/min, " + f"oxygen saturation 89% on room air improving to 94% on 2L nasal cannula. Laboratory " + f"studies show WBC 15,800/ΞΌL with 82% neutrophils and 8% bands, procalcitonin 3.8 ng/mL " + f"(normal <0.1 ng/mL), and lactate 1.8 mmol/L. Chest X-ray shows right lower lobe " + f"consolidation with air bronchograms. Pneumococcal urinary antigen is positive. " + f"His CURB-65 score is calculated as 2 points (age >65, urea normal, RR >30, BP normal, confusion absent)." + ) + + else: + # Generic sophisticated vignette using comparison table features + vignette = ( + f"A patient with {correct_features.get('epidemiology_risk_factors', 'relevant risk factors')} " + f"presents with {correct_features.get('clinical_presentation', 'characteristic symptoms')}. " + f"Physical examination and initial studies reveal {correct_features.get('laboratory_findings', 'significant findings')}. " + f"Imaging demonstrates {correct_features.get('imaging_characteristics', 'relevant abnormalities')}. " + f"The clinical presentation includes some features that could suggest alternative diagnoses, " + f"requiring careful analysis of the discriminating features to establish the correct diagnosis." + ) + + return vignette + + def _create_advanced_question_stem_from_table(self, topic: str, question_type: str, comparison_table: Dict[str, Any]) -> str: + """Create an advanced question stem based on the comparison table analysis.""" + + if question_type == "diagnosis": + # Transform to higher-order management question + if "candida auris" in topic.lower(): + return "Given the antifungal resistance pattern and institutional infection control concerns, what is the most appropriate initial management approach?" + elif "histoplasmosis" in topic.lower(): + return "Based on the clinical presentation, epidemiologic factors, and diagnostic findings, what is the most appropriate treatment approach?" + elif "pneumonia" in topic.lower(): + return "Given the clinical severity assessment and microbiological findings, what is the most appropriate antibiotic management?" + else: + return "Based on the discriminating clinical features, what is the most appropriate next step in management?" + + elif question_type == "management": + # Transform to monitoring/complications question + if "candida auris" in topic.lower(): + return "What is the most critical monitoring parameter during treatment of this multi-drug resistant organism?" + elif "histoplasmosis" in topic.lower(): + return "Which parameter should be monitored most closely during antifungal therapy for this condition?" + elif "pneumonia" in topic.lower(): + return "What is the most important clinical parameter to assess treatment response in this patient?" + else: + return "Which monitoring approach is most critical for optimizing treatment outcomes?" + + elif question_type == "pharmacology": + # Transform to drug selection/interaction question + return "Which pharmacologic consideration is most important when selecting therapy for this patient?" + + elif question_type == "pathophysiology": + return "Based on the underlying pathophysiologic mechanism, why is the recommended approach most appropriate?" + + else: + return "What is the most appropriate clinical decision based on the comparative analysis of this presentation?" + + def _generate_answer_choices_from_table(self, comparison_table: Dict[str, Any]) -> List[Dict[str, str]]: + """Generate answer choices based on the comparison table.""" + + table = comparison_table["table"] + choices = [] + + # Correct answer from the comparison table + correct_condition = table["correct_answer"]["condition"] + + # Generate management-focused choices if this was originally a diagnosis question + if "candida auris" in correct_condition.lower(): + choice_texts = [ + "Immediate empirical echinocandin therapy with infection control isolation", + "Fluconazole therapy pending final susceptibility results", + "Amphotericin B monotherapy for broad-spectrum coverage", + "Combination antifungal therapy with fluconazole plus echinocandin", + "Observation with repeat cultures in 48 hours" + ] + elif "histoplasmosis" in correct_condition.lower(): + choice_texts = [ + "Itraconazole 200 mg twice daily for 6-12 weeks", + "Amphotericin B 0.7 mg/kg/day for 4-6 weeks", + "Fluconazole 400 mg daily for 12 weeks", + "Observation with repeat imaging in 4 weeks", + "Empirical antibacterial therapy pending culture results" + ] + elif "pneumonia" in correct_condition.lower(): + choice_texts = [ + "Ceftriaxone 2g IV daily plus azithromycin 500mg IV daily", + "Levofloxacin 750mg IV daily monotherapy", + "Piperacillin-tazobactam 4.5g IV every 6 hours plus vancomycin", + "Doxycycline 100mg twice daily", + "Oseltamivir 75mg twice daily" + ] + else: + # Generic choices based on the conditions in the table + choice_texts = [ + f"Treatment appropriate for {table['correct_answer']['condition']}", + f"Treatment more suitable for {table['distractor_1']['condition']}", + f"Treatment indicated for {table['distractor_2']['condition']}", + f"Treatment appropriate for {table['distractor_3']['condition']}", + f"Treatment suitable for {table['distractor_4']['condition']}" + ] + + # Format as lettered choices + letters = ["A", "B", "C", "D", "E"] + for i, text in enumerate(choice_texts): + choices.append({ + "letter": letters[i], + "text": text, + "is_correct": i == 0 # First choice is always correct + }) + + return choices + + def _generate_explanations_from_table(self, topic: str, question_type: str, comparison_table: Dict[str, Any], answer_choices: List[Dict]) -> Dict[str, str]: + """Generate detailed explanations using the comparison table analysis.""" + + explanations = {} + table = comparison_table["table"] + + for choice in answer_choices: + letter = choice["letter"] + text = choice["text"] + is_correct = choice["is_correct"] + + if is_correct: + correct_features = table["correct_answer"] + if "candida auris" in topic.lower(): + explanations[letter] = ( + f"**CORRECT**: {text} is the appropriate approach. The clinical presentation suggests " + f"C. auris invasive candidiasis based on: (1) {correct_features['epidemiology_risk_factors']}, " + f"(2) {correct_features['laboratory_findings']}, and (3) {correct_features['diagnostic_tests']}. " + f"C. auris requires immediate echinocandin therapy due to intrinsic fluconazole resistance and " + f"frequent amphotericin B resistance. Infection control isolation is critical due to environmental " + f"persistence and healthcare transmission risk. The resistance pattern and MALDI-TOF misidentification " + f"are characteristic of this emerging pathogen." + ) + elif "histoplasmosis" in topic.lower(): + explanations[letter] = ( + f"**CORRECT**: {text} is the appropriate treatment. The clinical presentation confirms " + f"acute pulmonary histoplasmosis based on: (1) {correct_features['epidemiology_risk_factors']}, " + f"(2) {correct_features['imaging_characteristics']}, and (3) {correct_features['diagnostic_tests']}. " + f"For moderate pulmonary disease with symptoms >4 weeks and weight loss, itraconazole is the " + f"first-line oral therapy per IDSA guidelines. The combination of geographic exposure, bilateral " + f"hilar lymphadenopathy, and positive urine antigen distinguishes this from other endemic mycoses." + ) + elif "pneumonia" in topic.lower(): + explanations[letter] = ( + f"**CORRECT**: {text} is the appropriate antibiotic regimen. The clinical presentation confirms " + f"community-acquired pneumonia with: (1) {correct_features['clinical_presentation']}, " + f"(2) {correct_features['laboratory_findings']}, and (3) {correct_features['diagnostic_tests']}. " + f"The CURB-65 score of 2 indicates moderate severity requiring hospitalization. The combination " + f"provides coverage for typical pathogens (ceftriaxone) and atypical pathogens (azithromycin) " + f"as recommended by IDSA/ATS guidelines." + ) + else: + explanations[letter] = ( + f"**CORRECT**: {text} is appropriate based on the discriminating features: " + f"{correct_features.get('clinical_presentation', '')} with " + f"{correct_features.get('laboratory_findings', '')} and " + f"{correct_features.get('diagnostic_tests', '')}." + ) + else: + # Generate specific explanations for incorrect choices using comparison table + choice_index = ord(letter) - ord('A') + if choice_index <= 4: + distractor_key = f"distractor_{choice_index}" if choice_index > 0 else "correct_answer" + if distractor_key in table and choice_index > 0: + distractor_features = table[distractor_key] + explanations[letter] = ( + f"**INCORRECT**: {text} would be more appropriate for " + f"{distractor_features['condition']}. This condition differs because it typically presents with " + f"{distractor_features.get('clinical_presentation', 'different clinical features')} and " + f"{distractor_features.get('laboratory_findings', 'different laboratory findings')}. " + f"The epidemiology ({distractor_features.get('epidemiology_risk_factors', 'different risk factors')}) " + f"and diagnostic testing ({distractor_features.get('diagnostic_tests', 'different test results')}) " + f"help distinguish this from the correct diagnosis." + ) + else: + explanations[letter] = ( + f"**INCORRECT**: {text} is not appropriate for this clinical scenario based on the " + f"discriminating features presented in the vignette." + ) + else: + explanations[letter] = f"**INCORRECT**: {text} is not indicated for this clinical presentation." + + return explanations + + def _generate_clinical_vignette_advanced(self, topic: str, difficulty_level: str, question_type: str, discriminating_features: Dict[str, List[str]]) -> str: + """Generate a clinical vignette that includes discriminating features but doesn't give away the answer.""" + + if "histoplasmosis" in topic.lower() or ("dimorphic" in topic.lower() and "histoplasma" in topic.lower()): + vignette = ( + "A 45-year-old construction worker from Louisville, Kentucky, presents with a 3-week history of " + "nonproductive cough, low-grade fever (100.8Β°F), and 12-pound unintentional weight loss. He reports " + "recent recreational cave exploration activities 6 weeks prior to symptom onset. Physical examination " + "reveals scattered tender erythematous nodules on both shins. Vital signs are stable except for mild " + "tachypnea. Laboratory studies show WBC 3,100/ΞΌL (normal 4,500-11,000) with 65% lymphocytes. " + "Chest CT demonstrates bilateral hilar lymphadenopathy with multiple small (<1 cm) pulmonary nodules " + "scattered throughout both lung fields. The patient denies any known immunocompromising conditions " + "and has no significant past medical history." + ) + + elif "coccidioidomycosis" in topic.lower(): + vignette = ( + "A 28-year-old graduate student from Phoenix, Arizona, presents with a 2-week history of dry cough, " + "pleuritic chest pain, and arthralgias affecting knees and ankles. She reports recent field research " + "in the Sonoran Desert involving soil sampling. Physical examination reveals tender erythematous " + "nodules on the anterior shins and mild ankle swelling. Temperature is 101.4Β°F with otherwise normal " + "vital signs. Laboratory studies show WBC 8,200/ΞΌL with 12% eosinophils (normal <4%) and ESR 78 mm/hr. " + "Chest X-ray shows a right lower lobe infiltrate with hilar prominence. The patient is otherwise " + "healthy with no known drug allergies or significant medical history." + ) + + elif "blastomycosis" in topic.lower(): + vignette = ( + "A 52-year-old avid fisherman from northern Wisconsin presents with a 6-week history of productive " + "cough, low-grade fever, and a progressively enlarging skin lesion on his right forearm. He frequently " + "camps near lake shores and clears fallen timber. Physical examination reveals a 3-cm verrucous plaque " + "with central ulceration and rolled borders on the forearm. Pulmonary examination shows decreased " + "breath sounds at the right base. Chest CT demonstrates a right lower lobe mass-like consolidation. " + "Tissue biopsy of the skin lesion shows numerous thick-walled yeast forms with broad-based budding. " + "The patient has diabetes mellitus type 2 but is otherwise healthy." + ) + + elif "candida auris" in topic.lower(): + vignette = ( + "A 72-year-old man with end-stage renal disease on hemodialysis develops fever and hypotension 48 hours " + "after central venous catheter placement in the ICU. He has been hospitalized for 3 weeks following " + "complicated abdominal surgery with multiple invasive procedures. Blood cultures drawn from both the " + "central line and peripheral sites grow yeast identified as Candida species. The organism demonstrates " + "resistance to fluconazole (MIC >64 ΞΌg/mL) and intermediate resistance to amphotericin B (MIC 2 ΞΌg/mL). " + "MALDI-TOF mass spectrometry initially misidentifies the organism as C. haemulonii. The microbiology " + "laboratory requests molecular identification and antifungal susceptibility testing. The infection " + "control team is notified due to concerns about a multi-drug resistant Candida species." + ) + + elif "pneumonia" in topic.lower(): + vignette = ( + "A 68-year-old man with COPD (FEV1 45% predicted) and well-controlled diabetes presents with 48 hours " + "of productive cough with rust-colored sputum, right-sided pleuritic chest pain, and fever. He recently " + "returned from a 7-day cruise 5 days ago. Physical examination reveals dullness to percussion and " + "bronchial breath sounds over the right lower lobe. Temperature 102.8Β°F, heart rate 110 bpm, blood " + "pressure 118/72 mmHg, respiratory rate 26/min, oxygen saturation 89% on room air. Laboratory studies " + "show WBC 15,800/ΞΌL with 82% neutrophils, procalcitonin 3.8 ng/mL. Chest X-ray shows right lower lobe " + "consolidation with air bronchograms. His CURB-65 score is calculated as 2 points." + ) + + else: + # Generic advanced vignette + vignette = ( + f"A patient with appropriate risk factors for {topic} presents with characteristic clinical features. " + f"The presentation includes key discriminating findings that help establish the diagnosis through " + f"systematic clinical reasoning. Physical examination and initial diagnostic studies reveal findings " + f"consistent with the suspected condition. Additional specialized testing is being considered to " + f"confirm the diagnosis and guide management decisions." + ) + + return vignette + + def _create_advanced_question_stem(self, topic: str, question_type: str, discriminating_features: Dict[str, List[str]]) -> str: + """Create a higher-order question stem that tests clinical reasoning.""" + + if question_type == "diagnosis": + # Upgrade to management question + if "histoplasmosis" in topic.lower() or "dimorphic" in topic.lower(): + return "Based on the clinical presentation and epidemiologic factors, what is the most appropriate initial treatment approach?" + elif "pneumonia" in topic.lower(): + return "Given the severity assessment and likely pathogen, what is the most appropriate antibiotic regimen?" + else: + return f"What is the most appropriate next step in management for this patient?" + + elif question_type == "management": + # Upgrade to monitoring/complications question + if "histoplasmosis" in topic.lower() or "dimorphic" in topic.lower(): + return "After initiating the appropriate antifungal therapy, which laboratory parameter should be monitored most closely?" + elif "pneumonia" in topic.lower(): + return "What is the most important parameter to monitor for treatment response in this patient?" + else: + return f"Which monitoring parameter is most critical during treatment of this condition?" + + elif question_type == "pharmacology": + # Upgrade to drug interactions/side effects question + if "histoplasmosis" in topic.lower() or "dimorphic" in topic.lower(): + return "What is the most important drug interaction to consider with the first-line oral antifungal therapy?" + elif "pneumonia" in topic.lower(): + return "Which adverse effect requires monitoring with the recommended Ξ²-lactam antibiotic?" + else: + return f"What is the most significant drug interaction concern with first-line therapy?" + + elif question_type == "pathophysiology": + # Upgrade to mechanism-based treatment question + return f"Based on the underlying pathophysiology, why is the recommended treatment approach most appropriate?" + + elif question_type == "complications": + # Upgrade to prognostic factors question + return f"Which factor most significantly influences the prognosis in this condition?" + + else: + return f"What is the most appropriate clinical decision-making approach for this patient?" + + def _generate_advanced_answer_choices(self, topic: str, question_type: str, difficulty_level: str, discriminating_features: Dict[str, List[str]]) -> List[Dict[str, str]]: + """Generate answer choices for higher-order questions.""" + + if "histoplasmosis" in topic.lower() or "dimorphic" in topic.lower(): + if question_type == "diagnosis": # Now management question + choices = [ + "Itraconazole 200 mg twice daily for 6-12 weeks", + "Amphotericin B 0.7 mg/kg/day for 4-6 weeks", + "Fluconazole 400 mg daily for 12 weeks", + "Observation with repeat imaging in 4 weeks", + "Empirical antibacterial therapy with ceftriaxone and azithromycin" + ] + elif question_type == "management": # Now monitoring question + choices = [ + "Itraconazole serum levels after 2 weeks of therapy", + "Complete blood count every 2 weeks", + "Serum creatinine weekly", + "Liver function tests monthly", + "Chest imaging every 4 weeks" + ] + elif question_type == "pharmacology": # Drug interactions + choices = [ + "Proton pump inhibitors reducing itraconazole absorption", + "Warfarin enhancing anticoagulation effects", + "Statins increasing rhabdomyolysis risk", + "Calcium channel blockers causing hypotension", + "ACE inhibitors potentiating hyperkalemia" + ] + + elif "pneumonia" in topic.lower(): + if question_type == "diagnosis": # Now management question + choices = [ + "Ceftriaxone 2g IV daily plus azithromycin 500mg IV daily", + "Levofloxacin 750mg IV daily monotherapy", + "Piperacillin-tazobactam 4.5g IV every 6 hours", + "Vancomycin plus cefepime", + "Oseltamivir 75mg twice daily" + ] + elif question_type == "management": # Now monitoring question + choices = [ + "Clinical improvement within 48-72 hours", + "White blood cell count normalization", + "Chest X-ray clearing within 24 hours", + "Procalcitonin reduction by 50% daily", + "Oxygen saturation improvement within 6 hours" + ] + elif question_type == "pharmacology": # Drug interactions + choices = [ + "QTc prolongation with azithromycin", + "Nephrotoxicity with ceftriaxone", + "Hepatotoxicity with beta-lactams", + "Ototoxicity with macrolides", + "Photosensitivity with cephalosporins" + ] + + else: + # Generic advanced choices + choices = [ + f"Evidence-based first-line approach for {topic}", + f"Alternative therapy with higher risk profile", + f"Inappropriate therapy for this clinical scenario", + f"Treatment with contraindication in this patient", + f"Suboptimal therapy with inadequate coverage" + ] + + # Format as lettered choices + letters = ["A", "B", "C", "D", "E"] + formatted_choices = [] + + for i, choice in enumerate(choices): + formatted_choices.append({ + "letter": letters[i], + "text": choice, + "is_correct": i == 0 # First choice is always correct + }) + + return formatted_choices + + def _generate_advanced_explanations(self, topic: str, answer_choices: List[Dict], question_type: str, discriminating_features: Dict[str, List[str]]) -> Dict[str, str]: + """Generate explanations that emphasize clinical reasoning.""" + + explanations = {} + + for choice in answer_choices: + letter = choice["letter"] + text = choice["text"] + is_correct = choice["is_correct"] + + if is_correct: + if "itraconazole" in text.lower() and "histoplasmosis" in topic.lower(): + explanations[letter] = ( + f"**CORRECT**: {text} is the appropriate treatment. The clinical presentation suggests acute " + f"pulmonary histoplasmosis based on: (1) Geographic exposure to Ohio River Valley with cave activities, " + f"(2) Bilateral hilar lymphadenopathy with multiple small nodules characteristic of histoplasmosis, and " + f"(3) Lymphopenia rather than neutrophilia, which helps distinguish from bacterial infections. " + f"For moderate pulmonary disease with symptoms lasting >4 weeks, itraconazole is the first-line " + f"oral therapy per IDSA guidelines. Amphotericin B is reserved for severe or disseminated disease." + ) + elif "ceftriaxone" in text.lower() and "azithromycin" in text.lower(): + explanations[letter] = ( + f"**CORRECT**: {text} is the appropriate antibiotic regimen. The clinical presentation suggests " + f"community-acquired pneumonia with: (1) Classic symptoms and consolidation pattern, " + f"(2) CURB-65 score of 2 indicating moderate severity requiring hospitalization, and " + f"(3) High procalcitonin (3.8 ng/mL) suggesting bacterial etiology. The combination provides " + f"coverage for typical pathogens (ceftriaxone) and atypical pathogens (azithromycin) as " + f"recommended by IDSA/ATS guidelines for hospitalized CAP patients." + ) + elif "itraconazole serum levels" in text.lower(): + explanations[letter] = ( + f"**CORRECT**: {text} is the most important monitoring parameter. Itraconazole has significant " + f"pharmacokinetic variability and drug interactions that can affect absorption and metabolism. " + f"Therapeutic levels (>1.0 mcg/mL) should be checked after 2 weeks of therapy to ensure adequate " + f"exposure. Subtherapeutic levels are associated with treatment failure, while supratherapeutic " + f"levels increase toxicity risk. Other monitoring is important but less critical for treatment success." + ) + elif "clinical improvement" in text.lower(): + explanations[letter] = ( + f"**CORRECT**: {text} is the most important parameter to monitor. Clinical response should occur " + f"within 48-72 hours of appropriate antibiotic therapy, including improved vital signs, decreased " + f"oxygen requirements, and reduced cough/sputum production. Lack of improvement suggests treatment " + f"failure, resistant organism, or complications requiring reassessment. Laboratory parameters and " + f"imaging may lag behind clinical improvement." + ) + else: + explanations[letter] = ( + f"**CORRECT**: {text} is the appropriate clinical decision based on the discriminating features " + f"presented in this case and current evidence-based guidelines." + ) + else: + # Generate specific incorrect explanations + if "amphotericin" in text.lower() and "histoplasmosis" in topic.lower(): + explanations[letter] = ( + f"**INCORRECT**: {text} is inappropriate for this patient. Amphotericin B is reserved for " + f"severe pulmonary histoplasmosis or disseminated disease. This patient has moderate disease " + f"without signs of respiratory failure or dissemination. Starting with amphotericin exposes " + f"the patient to unnecessary nephrotoxicity and infusion-related reactions when oral itraconazole " + f"is equally effective for this severity." + ) + elif "observation" in text.lower(): + explanations[letter] = ( + f"**INCORRECT**: {text} is inappropriate. The patient has symptomatic disease lasting >4 weeks " + f"with significant weight loss, indicating moderate severity that requires antifungal treatment. " + f"Observation is only appropriate for asymptomatic patients or those with very mild, self-limited " + f"disease. Untreated moderate disease can progress to chronic or disseminated forms." + ) + elif "levofloxacin" in text.lower() and "pneumonia" in topic.lower(): + explanations[letter] = ( + f"**INCORRECT**: {text} monotherapy is suboptimal. While levofloxacin covers both typical and " + f"atypical pathogens, current IDSA/ATS guidelines recommend combination therapy (Ξ²-lactam plus " + f"macrolide) for hospitalized CAP patients to improve outcomes. Monotherapy is reserved for " + f"outpatients or specific clinical scenarios with contraindications to combination therapy." + ) + else: + explanations[letter] = ( + f"**INCORRECT**: {text} is not appropriate based on the clinical presentation and discriminating " + f"features that establish the diagnosis and guide optimal management decisions." + ) + + return explanations + + def _generate_clinical_vignette(self, topic: str, difficulty_level: str, question_type: str) -> str: + """Generate a realistic clinical vignette with at least 3 diagnostic hints.""" + + # Enhanced vignette templates with specific diagnostic hints + vignette_templates = { + "pneumonia": { + "patient": "A 65-year-old man with a history of COPD and diabetes mellitus", + "presentation": "presents to the emergency department with a 3-day history of productive cough with purulent sputum, fever to 102.5Β°F (39.2Β°C), and shortness of breath", + "exam": "On examination, he appears ill and has decreased breath sounds and crackles in the right lower lobe", + "vitals": "Temperature 102.5Β°F (39.2Β°C), heart rate 110 bpm, blood pressure 140/85 mmHg, respiratory rate 24/min, oxygen saturation 88% on room air", + "diagnostic_hints": [ + "Chest X-ray shows a right lower lobe consolidation", + "White blood cell count is 18,000/ΞΌL with 85% neutrophils and left shift", + "Procalcitonin level is elevated at 2.5 ng/mL (normal <0.1 ng/mL)" + ] + }, + "heart failure": { + "patient": "A 72-year-old woman with a history of hypertension and coronary artery disease", + "presentation": "presents with a 2-week history of progressive dyspnea on exertion, orthopnea, and bilateral lower extremity edema", + "exam": "Physical examination reveals jugular venous distension, bilateral pulmonary rales, and 2+ pitting edema to the knees", + "vitals": "Temperature 98.6Β°F (37Β°C), heart rate 95 bpm, blood pressure 160/95 mmHg, respiratory rate 20/min, oxygen saturation 92% on room air", + "diagnostic_hints": [ + "Echocardiogram shows ejection fraction of 35% with global hypokinesis", + "B-type natriuretic peptide (BNP) is elevated at 1,200 pg/mL (normal <100 pg/mL)", + "Chest X-ray demonstrates cardiomegaly with bilateral pulmonary vascular congestion" + ] + }, + "diabetes": { + "patient": "A 58-year-old obese man with a 10-year history of type 2 diabetes mellitus", + "presentation": "presents for routine follow-up. He reports good adherence to metformin but continues to have elevated blood glucose readings", + "exam": "Physical examination is notable for mild diabetic retinopathy on fundoscopy and diminished vibration sensation in both feet", + "vitals": "Temperature 98.4Β°F (36.9Β°C), heart rate 78 bpm, blood pressure 145/90 mmHg, BMI 32 kg/mΒ²", + "diagnostic_hints": [ + "Hemoglobin A1c is 9.2% (target <7%)", + "Fasting glucose levels average 180-220 mg/dL over the past month", + "Microalbumin-to-creatinine ratio is 45 mg/g (normal <30 mg/g), indicating early diabetic nephropathy" + ] + }, + "sepsis": { + "patient": "A 45-year-old woman with no significant medical history", + "presentation": "presents to the emergency department with a 12-hour history of fever, chills, and altered mental status", + "exam": "She appears acutely ill with warm, flushed skin and is confused to time and place", + "vitals": "Temperature 101.8Β°F (38.8Β°C), heart rate 120 bpm, blood pressure 85/50 mmHg, respiratory rate 28/min, oxygen saturation 94% on room air", + "diagnostic_hints": [ + "Lactate level is 4.2 mmol/L (normal <2.0 mmol/L)", + "White blood cell count is 18,500/ΞΌL with 20% immature forms (bands)", + "Procalcitonin is markedly elevated at 8.5 ng/mL, and blood cultures are pending" + ] + }, + "myocardial infarction": { + "patient": "A 62-year-old man with a history of hypertension and smoking", + "presentation": "presents with 2 hours of severe substernal chest pain radiating to his left arm, associated with diaphoresis and nausea", + "exam": "He appears diaphoretic and anxious, with normal heart sounds and clear lung fields", + "vitals": "Temperature 98.2Β°F (36.8Β°C), heart rate 95 bpm, blood pressure 150/90 mmHg, respiratory rate 18/min, oxygen saturation 98% on room air", + "diagnostic_hints": [ + "ECG shows ST-segment elevation in leads II, III, and aVF", + "Troponin I is elevated at 8.5 ng/mL (normal <0.04 ng/mL)", + "Echocardiogram reveals new wall motion abnormality in the inferior wall" + ] + } + } + + # Get template or create enhanced generic one + if topic.lower() in vignette_templates: + template = vignette_templates[topic.lower()] + else: + template = self._create_enhanced_generic_template(topic, question_type) + + # Add question-type specific hints + additional_hints = self._get_question_type_hints(topic, question_type) + all_hints = template["diagnostic_hints"] + additional_hints + + # Ensure we have at least 3 hints + if len(all_hints) < 3: + all_hints.extend(self._generate_additional_hints(topic, question_type, 3 - len(all_hints))) + + # Add laboratory/imaging based on question type + additional_info = "" + if question_type == "diagnosis": + additional_info = " Additional diagnostic studies are obtained to confirm the diagnosis." + elif question_type == "management": + additional_info = " The diagnosis has been established and treatment options are being considered." + elif question_type == "pharmacology": + additional_info = " Medication management is being optimized based on the clinical findings." + + # Combine into complete vignette with diagnostic hints + vignette = f"{template['patient']} {template['presentation']}. {template['exam']}. Vital signs: {template['vitals']}. " + + # Add diagnostic hints + for i, hint in enumerate(all_hints[:3], 1): # Use top 3 hints + vignette += f"Laboratory/imaging finding {i}: {hint}. " + + vignette += additional_info + + return vignette + + def _create_enhanced_generic_template(self, topic: str, question_type: str) -> Dict[str, Any]: + """Create an enhanced generic template with diagnostic hints.""" + + return { + "patient": f"A 55-year-old patient with relevant medical history for {topic}", + "presentation": f"presents with classic symptoms and signs consistent with {topic}", + "exam": f"Physical examination reveals key findings suggestive of {topic}", + "vitals": "Vital signs show relevant abnormalities for the condition", + "diagnostic_hints": [ + f"Key diagnostic test result #1 specific to {topic}", + f"Key diagnostic test result #2 that confirms {topic}", + f"Key diagnostic test result #3 that rules out alternatives to {topic}" + ] + } + + def _get_question_type_hints(self, topic: str, question_type: str) -> List[str]: + """Get additional hints specific to the question type.""" + + hints = [] + + if question_type == "diagnosis": + hints.extend([ + "Imaging findings are consistent with the suspected diagnosis", + "Biomarker levels support the clinical impression" + ]) + elif question_type == "management": + hints.extend([ + "Current vital signs indicate urgency of intervention", + "Comorbidities influence treatment selection" + ]) + elif question_type == "pharmacology": + hints.extend([ + "Renal function affects medication dosing", + "Drug interactions must be considered" + ]) + elif question_type == "pathophysiology": + hints.extend([ + "Underlying mechanism explains the clinical presentation", + "Pathophysiologic process accounts for laboratory abnormalities" + ]) + + return hints + + def _generate_additional_hints(self, topic: str, question_type: str, num_needed: int) -> List[str]: + """Generate additional diagnostic hints when needed.""" + + generic_hints = [ + f"Clinical course is typical for {topic}", + f"Response to initial interventions supports {topic} diagnosis", + f"Differential diagnosis considerations favor {topic}", + f"Risk factors align with {topic} presentation", + f"Timeline of symptoms is characteristic of {topic}" + ] + + return generic_hints[:num_needed] + + def _create_question_stem(self, topic: str, question_type: str) -> str: + """Create the question stem based on type.""" + + question_stems = { + "diagnosis": f"What is the most likely diagnosis?", + "management": f"What is the most appropriate next step in management?", + "pathophysiology": f"What is the most likely underlying pathophysiology?", + "pharmacology": f"What is the most appropriate medication for this patient?", + "complications": f"What is the most likely complication to monitor for?" + } + + return question_stems.get(question_type, f"What is the most appropriate approach for this patient with {topic}?") + + def _generate_answer_choices(self, topic: str, question_type: str, difficulty_level: str) -> List[Dict[str, str]]: + """Generate 5 answer choices with the correct answer first, designed to have only one correct option based on vignette hints.""" + + # Enhanced topic-specific answer choices with clear distinctions + choices_map = { + "pneumonia": { + "diagnosis": [ + "Community-acquired pneumonia", + "Pulmonary embolism", # Would not have consolidation, elevated WBC, or procalcitonin + "Congestive heart failure", # Would not have fever, purulent sputum, or elevated procalcitonin + "Chronic obstructive pulmonary disease exacerbation", # Would not have consolidation or elevated procalcitonin + "Lung cancer" # Would not have acute fever, elevated WBC, or procalcitonin + ], + "management": [ + "Start empirical antibiotic therapy and obtain cultures", + "Prescribe bronchodilators only", # Inadequate for bacterial pneumonia with elevated procalcitonin + "Immediate intubation", # Not indicated with current O2 sat and mental status + "High-dose corticosteroids", # Not first-line for bacterial pneumonia + "Observe without treatment" # Inappropriate with clear signs of bacterial infection + ] + }, + "heart failure": { + "diagnosis": [ + "Acute decompensated heart failure", + "Pneumonia", # Would not have elevated BNP, cardiomegaly, or reduced EF + "Pulmonary embolism", # Would not have elevated BNP, cardiomegaly, or bilateral edema + "Chronic kidney disease", # Would not have reduced EF or pulmonary congestion + "Liver cirrhosis" # Would not have elevated BNP, reduced EF, or pulmonary congestion + ], + "management": [ + "Diuretics and ACE inhibitor optimization", + "Immediate cardiac catheterization", # Not indicated for chronic heart failure exacerbation + "High-dose beta-blockers", # Inappropriate in acute decompensated state + "Fluid resuscitation", # Contraindicated with volume overload + "Antibiotics" # Not indicated without signs of infection + ] + }, + "sepsis": { + "diagnosis": [ + "Sepsis with organ dysfunction", + "Pneumonia without systemic involvement", # Would not have hypotension, altered mental status, or elevated lactate + "Hypovolemic shock", # Would not have warm, flushed skin or elevated WBC with left shift + "Cardiogenic shock", # Would not have warm skin, elevated WBC, or elevated procalcitonin + "Anaphylactic shock" # Would not have fever, elevated WBC, or elevated procalcitonin + ], + "management": [ + "Immediate IV antibiotics and fluid resuscitation", + "Wait for culture results before antibiotics", # Inappropriate delay in sepsis + "Vasopressors as first-line therapy", # Should try fluid resuscitation first + "High-dose corticosteroids", # Not first-line for septic shock + "Observation only" # Inappropriate with clear signs of sepsis + ] + }, + "myocardial infarction": { + "diagnosis": [ + "ST-elevation myocardial infarction (STEMI)", + "Unstable angina", # Would not have ST elevation or markedly elevated troponin + "Pericarditis", # Would not have focal ST elevation or wall motion abnormality + "Aortic dissection", # Would not have ST elevation or elevated troponin + "Pulmonary embolism" # Would not have ST elevation in inferior leads or wall motion abnormality + ], + "management": [ + "Immediate cardiac catheterization for primary PCI", + "Medical management with anticoagulation only", # Inadequate for STEMI + "Thrombolytic therapy", # PCI preferred when available within time window + "Observation with serial troponins", # Inappropriate delay for STEMI + "High-dose aspirin only" # Inadequate monotherapy for STEMI + ] + }, + "diabetes": { + "diagnosis": [ + "Poorly controlled type 2 diabetes mellitus", + "Type 1 diabetes mellitus", # Would typically present at younger age, different history + "Gestational diabetes", # Not applicable to 58-year-old male + "Secondary diabetes due to pancreatitis", # Would need history of pancreatitis + "Prediabetes" # HbA1c 9.2% exceeds diagnostic threshold + ], + "management": [ + "Intensify diabetes management with additional oral agent or insulin", + "Continue metformin alone", # Inadequate given HbA1c 9.2% + "Dietary modification only", # Insufficient for HbA1c 9.2% + "Discontinue all medications", # Inappropriate and dangerous + "Refer to endocrinology without changes" # Should initiate intensification while arranging referral + ] + } + } + + # Get choices or create enhanced generic ones + if topic.lower() in choices_map and question_type in choices_map[topic.lower()]: + choice_list = choices_map[topic.lower()][question_type] + else: + choice_list = self._generate_enhanced_generic_choices(topic, question_type) + + # Format as lettered choices + letters = ["A", "B", "C", "D", "E"] + formatted_choices = [] + + for i, choice in enumerate(choice_list): + formatted_choices.append({ + "letter": letters[i], + "text": choice, + "is_correct": i == 0 # First choice is always correct + }) + + return formatted_choices + + def _generate_enhanced_generic_choices(self, topic: str, question_type: str) -> List[str]: + """Generate enhanced generic choices that are clearly distinguishable.""" + + if question_type == "diagnosis": + return [ + f"Correct diagnosis: {topic}", + f"Common differential that lacks key diagnostic features", + f"Alternative condition with different laboratory pattern", + f"Condition with different imaging findings", + f"Condition with different clinical course" + ] + elif question_type == "management": + return [ + f"Evidence-based first-line treatment for {topic}", + f"Treatment appropriate for different condition", + f"Treatment with contraindication in this case", + f"Treatment that is premature or excessive", + f"Treatment that is inadequate for severity" + ] + elif question_type == "pharmacology": + return [ + f"Appropriate medication choice for {topic}", + f"Medication with contraindication in this patient", + f"Medication inappropriate for this condition", + f"Medication with significant drug interaction", + f"Medication with inappropriate dosing" + ] + else: + return [ + f"Correct answer for {topic} {question_type}", + f"Common misconception about {topic}", + f"Less likely but possible option", + f"Incorrect but plausible choice", + f"Clearly incorrect option" + ] + + def _generate_explanations(self, topic: str, answer_choices: List[Dict], question_type: str) -> Dict[str, str]: + """Generate detailed explanations for each answer choice.""" + + explanations = {} + + for choice in answer_choices: + letter = choice["letter"] + text = choice["text"] + is_correct = choice["is_correct"] + + if is_correct: + explanations[letter] = f"**CORRECT**: {text} is the correct answer. This diagnosis fits the clinical presentation because [detailed explanation of why this is correct, including pathophysiology, clinical features, and supporting evidence]. Key teaching points include [relevant educational content about the condition, management, and important clinical pearls]." + else: + explanations[letter] = f"**INCORRECT**: {text} is incorrect because [detailed explanation of why this option is wrong, including how it differs from the correct diagnosis]. However, this is an important differential to consider because [educational content about this condition, when it should be considered, and key distinguishing features]." + + return explanations + + def _enhance_question_difficulty( + self, + topic: str, + difficulty_level: str, + question_type: str, + vignette: str, + question_stem: str, + answer_choices: List[Dict[str, str]], + explanations: Dict[str, str] + ) -> Dict[str, Any]: + """ + Quality control method to enhance question difficulty and clinical accuracy. + + This method reviews the initial question and makes it more challenging while + maintaining clinical accuracy, especially for infectious disease specialists. + """ + + enhancement_notes = [] + + # Enhanced vignette with more specific clinical details + enhanced_vignette = self._enhance_vignette_specificity(topic, vignette, difficulty_level) + if enhanced_vignette != vignette: + enhancement_notes.append("Enhanced vignette with more specific clinical details") + + # Enhanced answer choices with more nuanced distractors + enhanced_choices = self._enhance_answer_choices_specificity(topic, answer_choices, question_type) + if enhanced_choices != answer_choices: + enhancement_notes.append("Enhanced answer choices with more specific distractors") + + # Enhanced explanations with more detailed clinical reasoning + enhanced_explanations = self._enhance_explanations_depth(topic, enhanced_choices, question_type) + + return { + "vignette": enhanced_vignette, + "question_stem": question_stem, + "answer_choices": enhanced_choices, + "explanations": enhanced_explanations, + "enhancement_notes": "; ".join(enhancement_notes) if enhancement_notes else "No enhancements needed" + } + + def _enhance_vignette_specificity(self, topic: str, vignette: str, difficulty_level: str) -> str: + """Enhance vignette with more specific clinical details for higher difficulty.""" + + # Topic-specific enhancements for infectious disease focus + if "dimorphic fungi" in topic.lower() or "fungal" in topic.lower(): + # Make it more specific for ID specialists + enhanced_vignette = ( + "A 45-year-old construction worker from the Ohio River Valley presents with a 3-week history of " + "fever, nonproductive cough, and 15-pound weight loss. He reports recent spelunking activities " + "in Kentucky caves 6 weeks ago. Physical examination reveals scattered erythematous nodules on " + "the shins consistent with erythema nodosum, and bilateral hilar lymphadenopathy on chest imaging. " + "Temperature 101.2Β°F (38.4Β°C), heart rate 88 bpm, blood pressure 135/80 mmHg, respiratory rate 20/min, " + "oxygen saturation 94% on room air. Laboratory findings: WBC 3,200/ΞΌL with lymphopenia, " + "ESR 85 mm/hr, and LDH 420 U/L. Chest CT shows bilateral hilar lymphadenopathy with multiple " + "small pulmonary nodules. Histoplasma urine antigen is positive at 15.2 ng/mL (normal <0.6 ng/mL). " + "Serum complement fixation titers for Histoplasma are elevated at 1:32 for both mycelial and yeast forms." + ) + return enhanced_vignette + + elif "pneumonia" in topic.lower(): + # Make pneumonia more challenging for ID specialists + enhanced_vignette = ( + "A 68-year-old man with COPD (FEV1 45% predicted) and diabetes mellitus presents with acute onset " + "of productive cough with rust-colored sputum, pleuritic chest pain, and fever for 48 hours. " + "He recently returned from a cruise ship 5 days ago. Physical examination reveals dullness to " + "percussion and bronchial breath sounds over the right lower lobe. Vital signs: temperature 102.8Β°F " + "(39.3Β°C), heart rate 115 bpm, blood pressure 110/70 mmHg, respiratory rate 28/min, oxygen saturation " + "89% on room air. Laboratory findings: WBC 16,800/ΞΌL with 85% neutrophils and 12% bands, procalcitonin " + "4.2 ng/mL, lactate 2.8 mmol/L. Chest X-ray shows right lower lobe consolidation with air bronchograms. " + "Urinary pneumococcal antigen is positive, and blood cultures are pending. His CURB-65 score is 2." + ) + return enhanced_vignette + + # If no specific enhancement available, return original + return vignette + + def _enhance_answer_choices_specificity(self, topic: str, answer_choices: List[Dict[str, str]], question_type: str) -> List[Dict[str, str]]: + """Enhance answer choices with more specific, challenging distractors.""" + + if "dimorphic fungi" in topic.lower() or "fungal" in topic.lower(): + if question_type == "diagnosis": + enhanced_choices = [ + {"letter": "A", "text": "Acute pulmonary histoplasmosis", "is_correct": True}, + {"letter": "B", "text": "Chronic pulmonary coccidioidomycosis", "is_correct": False}, + {"letter": "C", "text": "Pulmonary blastomycosis", "is_correct": False}, + {"letter": "D", "text": "Sarcoidosis with LΓΆfgren syndrome", "is_correct": False}, + {"letter": "E", "text": "Hypersensitivity pneumonitis", "is_correct": False} + ] + return enhanced_choices + elif question_type == "management": + enhanced_choices = [ + {"letter": "A", "text": "Itraconazole 200 mg twice daily for 6-12 weeks", "is_correct": True}, + {"letter": "B", "text": "Amphotericin B 0.7 mg/kg/day for 2 weeks", "is_correct": False}, + {"letter": "C", "text": "Fluconazole 400 mg daily for 12 weeks", "is_correct": False}, + {"letter": "D", "text": "Voriconazole 200 mg twice daily for 8 weeks", "is_correct": False}, + {"letter": "E", "text": "Observation without antifungal therapy", "is_correct": False} + ] + return enhanced_choices + + elif "pneumonia" in topic.lower(): + if question_type == "diagnosis": + enhanced_choices = [ + {"letter": "A", "text": "Community-acquired pneumonia (CAP) with Streptococcus pneumoniae", "is_correct": True}, + {"letter": "B", "text": "Healthcare-associated pneumonia (HCAP) with Pseudomonas aeruginosa", "is_correct": False}, + {"letter": "C", "text": "Atypical pneumonia with Legionella pneumophila", "is_correct": False}, + {"letter": "D", "text": "Viral pneumonia with influenza A", "is_correct": False}, + {"letter": "E", "text": "Aspiration pneumonia with anaerobic bacteria", "is_correct": False} + ] + return enhanced_choices + elif question_type == "management": + enhanced_choices = [ + {"letter": "A", "text": "Ceftriaxone 2g IV daily plus azithromycin 500mg IV daily", "is_correct": True}, + {"letter": "B", "text": "Piperacillin-tazobactam 4.5g IV every 6 hours", "is_correct": False}, + {"letter": "C", "text": "Levofloxacin 750mg IV daily monotherapy", "is_correct": False}, + {"letter": "D", "text": "Vancomycin 15mg/kg IV every 12 hours plus cefepime 2g IV every 8 hours", "is_correct": False}, + {"letter": "E", "text": "Doxycycline 100mg PO twice daily", "is_correct": False} + ] + return enhanced_choices + + # Return original if no specific enhancement + return answer_choices + + def _enhance_explanations_depth(self, topic: str, answer_choices: List[Dict[str, str]], question_type: str) -> Dict[str, str]: + """Generate more detailed, clinically sophisticated explanations.""" + + explanations = {} + + for choice in answer_choices: + letter = choice["letter"] + text = choice["text"] + is_correct = choice["is_correct"] + + if is_correct: + if "histoplasmosis" in text.lower(): + explanations[letter] = ( + f"**CORRECT**: {text} is the correct diagnosis. This patient's presentation is classic for " + f"acute pulmonary histoplasmosis following cave exposure in an endemic area (Ohio River Valley). " + f"Key diagnostic features include: (1) Geographic risk factor with recent spelunking in Kentucky, " + f"(2) Constellation of fever, weight loss, and erythema nodosum, (3) Bilateral hilar lymphadenopathy " + f"with pulmonary nodules on CT, (4) Positive urine antigen (highly sensitive and specific), and " + f"(5) Elevated complement fixation titers for both mycelial and yeast forms. The lymphopenia is " + f"characteristic of histoplasmosis and helps distinguish it from bacterial infections. Treatment " + f"with itraconazole is indicated for moderate symptoms lasting >4 weeks." + ) + elif "pneumonia" in text.lower() and "streptococcus" in text.lower(): + explanations[letter] = ( + f"**CORRECT**: {text} is the correct diagnosis. This patient presents with classic features " + f"of pneumococcal pneumonia: (1) Acute onset with rust-colored sputum and pleuritic chest pain, " + f"(2) Lobar consolidation on chest imaging, (3) Positive urinary pneumococcal antigen, and " + f"(4) High procalcitonin suggesting bacterial etiology. The recent cruise ship exposure increases " + f"risk for pneumococcal disease. CURB-65 score of 2 indicates moderate severity requiring " + f"hospitalization. The combination of Ξ²-lactam plus macrolide is recommended for hospitalized " + f"CAP patients to cover both typical and atypical pathogens." + ) + else: + explanations[letter] = ( + f"**CORRECT**: {text} is the correct answer based on the clinical presentation, " + f"diagnostic findings, and epidemiologic factors presented in this case." + ) + else: + # Enhanced incorrect explanations + if "coccidioidomycosis" in text.lower(): + explanations[letter] = ( + f"**INCORRECT**: {text} is incorrect. While coccidioidomycosis can present with similar " + f"pulmonary symptoms and erythema nodosum, this patient's geographic exposure (Ohio River Valley " + f"caves) and positive Histoplasma urine antigen are not consistent with coccidioidomycosis. " + f"Coccidioidomycosis is endemic to the southwestern United States, particularly Arizona and " + f"California. The urine antigen test is specific for Histoplasma and would not be positive " + f"in coccidioidomycosis." + ) + elif "sarcoidosis" in text.lower(): + explanations[letter] = ( + f"**INCORRECT**: {text} is incorrect. While sarcoidosis with LΓΆfgren syndrome can present with " + f"bilateral hilar lymphadenopathy and erythema nodosum, several features argue against this " + f"diagnosis: (1) Recent cave exposure in endemic area, (2) Positive Histoplasma urine antigen, " + f"(3) Elevated complement fixation titers, and (4) Significant weight loss and fever. LΓΆfgren " + f"syndrome typically has a more acute onset and better prognosis than chronic sarcoidosis." + ) + elif "legionella" in text.lower(): + explanations[letter] = ( + f"**INCORRECT**: {text} is incorrect. While Legionella pneumonia can occur in cruise ship " + f"outbreaks, the positive urinary pneumococcal antigen specifically identifies S. pneumoniae. " + f"Legionella would typically present with hyponatremia, elevated LDH, and diarrhea, and would " + f"require urinary Legionella antigen testing for diagnosis. The clinical picture and positive " + f"pneumococcal antigen confirm pneumococcal etiology." + ) + else: + explanations[letter] = ( + f"**INCORRECT**: {text} is incorrect based on the clinical presentation and diagnostic findings. " + f"This option can be ruled out by the specific laboratory and imaging findings presented." + ) + + return explanations + + async def _convert_generic_to_clinical_vignette(self, generic_vignette: str, clinical_context: Dict[str, Any], comparison_table: Dict[str, Any]) -> str: + """ + Step 10: Convert generic placeholder vignette into realistic clinical scenario + This addresses the issue of generic vignettes with placeholder text that gives away answers. + """ + + condition_category = clinical_context["condition_category"] + correct_answer = comparison_table["table"]["correct_answer"] + + # Create realistic clinical vignettes based on condition type + if "aspergillosis" in condition_category.lower() or "aspergillus" in condition_category.lower(): + realistic_vignette = ( + "A 34-year-old man with acute myeloid leukemia is admitted for induction chemotherapy. " + "On day 12 of hospitalization, he develops fever to 102.8Β°F (39.3Β°C) despite broad-spectrum " + "antibiotics (piperacillin-tazobactam and vancomycin) for 48 hours. He reports new onset " + "of right-sided pleuritic chest pain and has had two episodes of small-volume hemoptysis. " + "Physical examination reveals an ill-appearing man with temperature 102.8Β°F, heart rate 115 bpm, " + "blood pressure 110/65 mmHg, respiratory rate 24/min, and oxygen saturation 94% on 2L nasal cannula. " + "Lung examination shows decreased breath sounds at the right base with dullness to percussion. " + "Laboratory studies show: WBC 400/ΞΌL (normal 4,500-11,000) with 85% neutrophils, absolute neutrophil " + "count 340/ΞΌL, platelet count 45,000/ΞΌL, and creatinine 1.2 mg/dL. Chest CT demonstrates a 2.5-cm " + "right lower lobe nodule with surrounding ground-glass opacity ('halo sign') and a smaller left " + "upper lobe nodule. Serum galactomannan index is 2.8 (normal <0.5). Blood cultures remain negative " + "after 72 hours. The patient has no known drug allergies and has been receiving prophylactic " + "fluconazole 400mg daily since admission." + ) + + elif "candida" in condition_category.lower() and "auris" in condition_category.lower(): + realistic_vignette = ( + "A 67-year-old woman with end-stage renal disease on hemodialysis is admitted to the ICU " + "following complications from abdominal surgery 18 days ago. She has required multiple " + "invasive procedures including central venous catheter placement, mechanical ventilation, " + "and broad-spectrum antibiotic therapy with vancomycin, meropenem, and fluconazole. " + "On hospital day 20, she develops new fever to 101.6Β°F (38.7Β°C) and hypotension requiring " + "vasopressor support. Physical examination reveals temperature 101.6Β°F, heart rate 125 bpm, " + "blood pressure 85/45 mmHg on norepinephrine, and clear lungs. The central line insertion " + "site appears clean without erythema. Laboratory studies show: WBC 14,200/ΞΌL with 78% neutrophils " + "and 15% bands, lactate 3.8 mmol/L (normal <2.0), and procalcitonin 1.2 ng/mL. Blood cultures " + "drawn from both central and peripheral sites grow yeast after 16 hours. The isolate demonstrates " + "resistance to fluconazole (MIC >64 ΞΌg/mL) and intermediate resistance to amphotericin B (MIC 2 ΞΌg/mL). " + "MALDI-TOF mass spectrometry reports 'Candida haemulonii' with low confidence score (1.6). " + "The microbiology laboratory requests molecular identification due to the unusual resistance pattern." + ) + + elif "histoplasmosis" in condition_category.lower() or "cave" in condition_category.lower(): + realistic_vignette = ( + "A 42-year-old construction worker from Cincinnati, Ohio, presents to the emergency department " + "with a 4-week history of persistent nonproductive cough, low-grade fever, and 15-pound " + "unintentional weight loss. He reports recent recreational spelunking activities at Mammoth Cave " + "in Kentucky approximately 7 weeks ago with several friends. Initial symptoms began gradually " + "2 weeks after the cave trip with fatigue and intermittent fever, progressing to persistent cough " + "and night sweats. He denies chest pain initially but now reports mild bilateral chest discomfort. " + "Physical examination reveals an afebrile man (temperature 99.8Β°F) with scattered tender erythematous " + "nodules on both anterior shins and mild bilateral ankle swelling. Vital signs show heart rate 88 bpm, " + "blood pressure 135/82 mmHg, respiratory rate 18/min, and oxygen saturation 96% on room air. " + "Laboratory studies reveal: WBC 3,400/ΞΌL (normal 4,500-11,000) with 68% lymphocytes, ESR 82 mm/hr, " + "and LDH 445 U/L (normal <250). Chest CT demonstrates bilateral hilar lymphadenopathy with multiple " + "small (<1 cm) pulmonary nodules scattered throughout both lung fields. Urine Histoplasma antigen " + "is 18.5 ng/mL (normal <0.6 ng/mL)." + ) + + elif "pneumonia" in condition_category.lower() and "pneumococcal" in condition_category.lower(): + realistic_vignette = ( + "A 71-year-old man with COPD (FEV1 42% predicted) and well-controlled type 2 diabetes mellitus " + "presents to the emergency department with a 36-hour history of acute onset productive cough " + "with rust-colored sputum, right-sided pleuritic chest pain, and fever. He recently returned " + "from a 10-day cruise to the Caribbean 4 days ago and felt well during the entire trip. " + "Symptoms began abruptly yesterday evening with rigors and high fever, followed by productive " + "cough and sharp chest pain that worsens with deep inspiration. He denies recent antibiotic use " + "or hospitalization. Physical examination reveals an ill-appearing man with temperature 103.1Β°F " + "(39.5Β°C), heart rate 118 bmp, blood pressure 125/78 mmHg, respiratory rate 28/min, and oxygen " + "saturation 88% on room air improving to 95% on 3L nasal cannula. Lung examination shows dullness " + "to percussion and bronchial breath sounds over the right lower lobe posteriorly. Laboratory studies " + "reveal: WBC 17,200/ΞΌL with 86% neutrophils and 10% bands, procalcitonin 4.2 ng/mL (normal <0.1), " + "lactate 1.9 mmol/L, and creatinine 1.1 mg/dL. Chest X-ray demonstrates right lower lobe consolidation " + "with air bronchograms. Pneumococcal urinary antigen is positive. His calculated CURB-65 score is 2 " + "(age >65, respiratory rate >30)." + ) + + elif "bacterial" in condition_category.lower() and "pneumonia" in condition_category.lower(): + realistic_vignette = ( + "A 45-year-old homeless man with a history of alcohol use disorder presents to the emergency " + "department with a 5-day history of fever, productive cough with foul-smelling sputum, and " + "progressive dyspnea. He reports poor dental hygiene and recalls choking on food 2 weeks ago " + "followed by several days of coughing. He has been living in a shelter and denies recent " + "hospitalization or antibiotic use. Physical examination reveals a cachectic man with temperature " + "101.8Β°F (38.8Β°C), heart rate 105 bpm, blood pressure 95/60 mmHg, respiratory rate 26/min, " + "and oxygen saturation 91% on room air. Oral examination shows poor dentition with multiple " + "carious teeth. Lung examination reveals dullness and decreased breath sounds over the right " + "lower lobe with coarse crackles. Laboratory studies show: WBC 18,500/ΞΌL with 82% neutrophils " + "and 12% bands, hemoglobin 9.8 g/dL, and albumin 2.4 g/dL. Chest CT demonstrates a thick-walled " + "cavity in the right lower lobe with an air-fluid level and surrounding consolidation." + ) + + else: + # Generic fallback that's still better than placeholder text + realistic_vignette = ( + f"A patient presents to the hospital with a complex clinical syndrome. The presentation " + f"includes multiple clinical findings that require systematic analysis to establish the " + f"correct diagnosis. Laboratory studies, imaging findings, and epidemiologic factors " + f"provide important diagnostic clues. The case challenges clinical reasoning skills " + f"and requires integration of multiple data points to arrive at the most likely diagnosis." + ) + + return realistic_vignette + + def _generate_learning_objectives(self, topic: str, question_type: str) -> List[str]: + """Generate learning objectives for the question.""" + + objectives = [ + f"Recognize the clinical presentation of {topic}", + f"Differentiate {topic} from other common conditions", + f"Understand the pathophysiology underlying {topic}", + f"Apply appropriate diagnostic and management strategies" + ] + + if question_type == "pharmacology": + objectives.append("Understand medication mechanisms and contraindications") + elif question_type == "complications": + objectives.append("Identify and manage potential complications") + + return objectives + + def _generate_references(self, topic: str, guideline_sources: Optional[List[Dict[str, str]]] = None) -> List[str]: + """Generate reference suggestions for further reading, including discovered guidelines.""" + + references = [] + + # Add discovered guideline sources first + if guideline_sources: + for source in guideline_sources[:3]: # Limit to top 3 + title = source.get("title", "") + url = source.get("url", "") + if title and url: + references.append(f"{title} - {url}") + + # Add standard references + standard_refs = [ + f"Harrison's Principles of Internal Medicine - {topic.title()} chapter", + f"UpToDate - {topic.title()} clinical topic", + f"Relevant clinical practice guidelines for {topic}", + f"NEJM Clinical Practice articles on {topic}" + ] + + # Add standard references if we don't have many guideline sources + references.extend(standard_refs[:max(1, 4 - len(references))]) + + return references diff --git a/tools/generate_flash_cards.py b/tools/generate_flash_cards.py new file mode 100644 index 0000000000000000000000000000000000000000..b12ca300a0dc2eb743e55311e64609fdabc1bce0 --- /dev/null +++ b/tools/generate_flash_cards.py @@ -0,0 +1,324 @@ +""" +generate_flash_cards.py +----------------------- + +Tool for generating educational flash cards for medical topics. + +This tool creates flash card style educational content to help with memorization and +quick review of key medical concepts, facts, and clinical pearls. + +Key Features: +- Creates front/back flash card format +- Generates multiple cards per topic +- Includes mnemonics and memory aids +- Covers key facts, clinical pearls, and high-yield information +- Organizes cards by difficulty and subtopic +""" + +import asyncio +from typing import Any, Dict, List, Union +from tools.base import Tool +from tools.utils import ToolExecutionError, logger + +class GenerateFlashCardsTool(Tool): + """ + Tool for generating educational flash cards for medical topics. + + This tool creates flash card style educational content to help with memorization + and quick review of key medical concepts. + """ + + def __init__(self) -> None: + """Initialize the GenerateFlashCardsTool.""" + super().__init__() + self.name = "generate_flash_cards" + self.description = "Generate educational flash cards for medical topics to aid memorization and quick review." + self.args_schema = { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "The medical topic to create flash cards about (e.g., 'hypertension', 'diabetes medications', 'heart murmurs')" + }, + "number_of_cards": { + "type": "integer", + "description": "Number of flash cards to generate (default: 10)", + "default": 10, + "minimum": 5, + "maximum": 20 + }, + "card_type": { + "type": "string", + "description": "Type of flash cards to generate", + "enum": ["basic_facts", "clinical_pearls", "mnemonics", "differential_diagnosis", "medications", "mixed"], + "default": "mixed" + }, + "difficulty_level": { + "type": "string", + "description": "Difficulty level for the cards", + "enum": ["medical_student", "resident", "board_review", "advanced"], + "default": "medical_student" + } + }, + "required": ["topic"] + } + + def openai_spec(self, legacy=False): + """Return OpenAI function specification.""" + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + + async def run( + self, + topic: str, + number_of_cards: int = 10, + card_type: str = "mixed", + difficulty_level: str = "medical_student" + ) -> Dict[str, Any]: + """ + Generate educational flash cards for a medical topic. + + Args: + topic (str): The medical topic to create flash cards about + number_of_cards (int): Number of flash cards to generate + card_type (str): Type of flash cards (basic_facts, clinical_pearls, mnemonics, etc.) + difficulty_level (str): Difficulty level for the cards + + Returns: + Dict[str, Any]: Complete flash card set with organized cards + """ + try: + logger.info(f"Generating {number_of_cards} flash cards for topic: {topic}") + + # Generate flash cards + flash_cards = self._generate_flash_cards(topic, number_of_cards, card_type, difficulty_level) + + # Organize cards by subtopic + organized_cards = self._organize_cards_by_subtopic(flash_cards, topic) + + # Generate study tips and usage instructions + study_tips = self._generate_study_tips(topic, card_type) + + # Create the complete flash card set + flash_card_set = { + "topic": topic, + "card_type": card_type, + "difficulty_level": difficulty_level, + "total_cards": len(flash_cards), + "flash_cards": flash_cards, + "organized_by_subtopic": organized_cards, + "study_tips": study_tips, + "review_schedule": self._generate_review_schedule(), + "created_date": "2025-07-18" + } + + logger.info(f"Successfully generated {len(flash_cards)} flash cards for {topic}") + return flash_card_set + + except Exception as e: + logger.error(f"GenerateFlashCardsTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"Failed to generate flash cards: {e}") + + def _generate_flash_cards(self, topic: str, number_of_cards: int, card_type: str, difficulty_level: str) -> List[Dict[str, Any]]: + """Generate individual flash cards.""" + + flash_cards = [] + + # Define card templates based on topic and type + card_templates = self._get_card_templates(topic, card_type, difficulty_level) + + # Generate cards using templates + for i in range(number_of_cards): + if i < len(card_templates): + card = card_templates[i] + else: + # Generate additional cards if needed + card = self._generate_additional_card(topic, card_type, difficulty_level, i) + + flash_cards.append(card) + + return flash_cards + + def _get_card_templates(self, topic: str, card_type: str, difficulty_level: str) -> List[Dict[str, Any]]: + """Get predefined card templates for common topics.""" + + templates = { + "hypertension": [ + { + "card_id": 1, + "subtopic": "Definition", + "front": "What is the definition of hypertension?", + "back": "Blood pressure β‰₯140/90 mmHg on two separate occasions, or β‰₯130/80 mmHg for patients with diabetes or chronic kidney disease.", + "memory_aid": "Remember: 140/90 for general population, 130/80 for high-risk groups", + "difficulty": "basic" + }, + { + "card_id": 2, + "subtopic": "Classification", + "front": "What are the stages of hypertension according to AHA/ACC guidelines?", + "back": "Normal: <120/80\nElevated: 120-129/<80\nStage 1: 130-139/80-89\nStage 2: β‰₯140/90\nCrisis: >180/120", + "memory_aid": "Mnemonic: Never Ever Stop Managing Crises (Normal, Elevated, Stage 1, Stage 2, Crisis)", + "difficulty": "basic" + }, + { + "card_id": 3, + "subtopic": "First-line medications", + "front": "What are the first-line medications for hypertension?", + "back": "ACE inhibitors, ARBs, Calcium channel blockers, Thiazide diuretics", + "memory_aid": "Mnemonic: ACCT (ACE, ARB, CCB, Thiazide) - ACCounT for first-line HTN meds", + "difficulty": "basic" + }, + { + "card_id": 4, + "subtopic": "Complications", + "front": "What are the major complications of untreated hypertension?", + "back": "Stroke, Heart attack, Heart failure, Kidney disease, Vision loss, Peripheral artery disease", + "memory_aid": "Mnemonic: SHOCKS (Stroke, Heart attack, Heart failure, Kidney disease, Vision loss, PAD)", + "difficulty": "intermediate" + } + ], + "diabetes": [ + { + "card_id": 1, + "subtopic": "Diagnosis", + "front": "What are the diagnostic criteria for diabetes mellitus?", + "back": "Fasting glucose β‰₯126 mg/dL OR Random glucose β‰₯200 mg/dL + symptoms OR HbA1c β‰₯6.5% OR 2-hour OGTT β‰₯200 mg/dL", + "memory_aid": "Remember: 126 fasting, 200 random, 6.5% A1c, 200 OGTT", + "difficulty": "basic" + }, + { + "card_id": 2, + "subtopic": "HbA1c targets", + "front": "What is the HbA1c target for most adults with diabetes?", + "back": "<7% for most adults. <6.5% for selected patients if achievable without hypoglycemia. <8% for patients with limited life expectancy or high risk of hypoglycemia.", + "memory_aid": "Lucky 7: <7% for most, adjust based on individual factors", + "difficulty": "basic" + } + ] + } + + # Return templates for the topic or generate generic ones + if topic.lower() in templates: + return templates[topic.lower()] + else: + return self._generate_generic_templates(topic, card_type, difficulty_level) + + def _generate_generic_templates(self, topic: str, card_type: str, difficulty_level: str) -> List[Dict[str, Any]]: + """Generate generic card templates for any topic.""" + + generic_templates = [ + { + "card_id": 1, + "subtopic": "Definition", + "front": f"What is {topic}?", + "back": f"[Definition and key characteristics of {topic}]", + "memory_aid": f"Key concept: {topic}", + "difficulty": "basic" + }, + { + "card_id": 2, + "subtopic": "Clinical Presentation", + "front": f"What are the main signs and symptoms of {topic}?", + "back": f"[List of key clinical features of {topic}]", + "memory_aid": f"Clinical pearl for {topic}", + "difficulty": "basic" + }, + { + "card_id": 3, + "subtopic": "Diagnosis", + "front": f"How is {topic} diagnosed?", + "back": f"[Diagnostic criteria and tests for {topic}]", + "memory_aid": f"Diagnostic approach for {topic}", + "difficulty": "intermediate" + }, + { + "card_id": 4, + "subtopic": "Treatment", + "front": f"What is the treatment for {topic}?", + "back": f"[Treatment options and management for {topic}]", + "memory_aid": f"Treatment strategy for {topic}", + "difficulty": "intermediate" + }, + { + "card_id": 5, + "subtopic": "Complications", + "front": f"What are the complications of {topic}?", + "back": f"[Potential complications and monitoring for {topic}]", + "memory_aid": f"Watch for complications of {topic}", + "difficulty": "advanced" + } + ] + + return generic_templates + + def _generate_additional_card(self, topic: str, card_type: str, difficulty_level: str, card_number: int) -> Dict[str, Any]: + """Generate additional cards when more are needed.""" + + additional_subtopics = [ + "Pathophysiology", "Epidemiology", "Risk factors", "Differential diagnosis", + "Prognosis", "Prevention", "Monitoring", "Patient education", "Guidelines" + ] + + subtopic_index = (card_number - 5) % len(additional_subtopics) + subtopic = additional_subtopics[subtopic_index] + + return { + "card_id": card_number + 1, + "subtopic": subtopic, + "front": f"What should you know about {subtopic.lower()} of {topic}?", + "back": f"[Key information about {subtopic.lower()} related to {topic}]", + "memory_aid": f"Clinical pearl: {subtopic} in {topic}", + "difficulty": "intermediate" + } + + def _organize_cards_by_subtopic(self, flash_cards: List[Dict], topic: str) -> Dict[str, List[Dict]]: + """Organize cards by subtopic for structured learning.""" + + organized = {} + + for card in flash_cards: + subtopic = card.get("subtopic", "General") + if subtopic not in organized: + organized[subtopic] = [] + organized[subtopic].append(card) + + return organized + + def _generate_study_tips(self, topic: str, card_type: str) -> List[str]: + """Generate study tips for using the flash cards effectively.""" + + tips = [ + "Review cards daily for optimal retention", + "Use spaced repetition - review difficult cards more frequently", + "Study the memory aids and mnemonics separately", + "Test yourself by covering the back of the card first", + "Create your own examples for each concept", + "Review in both directions (front-to-back and back-to-front)", + "Focus on understanding, not just memorization", + "Connect concepts to real patient cases when possible" + ] + + if card_type == "mnemonics": + tips.append("Practice writing out the mnemonics from memory") + elif card_type == "clinical_pearls": + tips.append("Think of patient scenarios where each pearl would apply") + + return tips + + def _generate_review_schedule(self) -> Dict[str, str]: + """Generate a spaced repetition review schedule.""" + + schedule = { + "Day 1": "Initial learning - review all cards", + "Day 2": "Review all cards again", + "Day 4": "Review cards you found difficult", + "Day 7": "Review entire set", + "Day 14": "Review difficult cards only", + "Day 30": "Complete review of all cards", + "Day 60": "Final review and assessment" + } + + return schedule diff --git a/tools/history_taking.py b/tools/history_taking.py new file mode 100644 index 0000000000000000000000000000000000000000..b4a551fa18d2a85568840b57a70e9982c830c877 --- /dev/null +++ b/tools/history_taking.py @@ -0,0 +1,100 @@ +from tools.base import Tool +import json + + +from tools.utils import load_prompt, ToolExecutionError, logger + +def call_llm(prompt): + # Placeholder for the actual LLM call + return f"History for: {prompt}" + +from typing import Any + +class HistoryTakingTool(Tool): + + def openai_spec(self, legacy=False): + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + """ + Tool to gather patient history for a specific syndrome using a dynamic schema. + + This tool loads a knowledge base entry for a syndrome, builds a dynamic argument schema + based on required consult questions, and generates a prompt for an LLM or rules engine. + """ + def __init__(self, syndrome_key: str) -> None: + """ + Initialize the HistoryTakingTool for a given syndrome. + + Args: + syndrome_key (str): The key identifying the syndrome in the knowledge base. + + Raises: + ValueError: If the syndrome key is not found in the knowledge base. + """ + super().__init__() + self.name = "history_taking" + # 1) Load the KB and pick the right syndrome entry + import os + + # Try multiple possible locations for the knowledge base file + possible_paths = [ + "consult_kb_stepwise.json", + os.path.join(os.path.dirname(__file__), "..", "consult_kb_stepwise.json"), + os.path.join(os.path.dirname(__file__), "..", "archive", "consult_kb_stepwise.json") + ] + + kb_file = None + for path in possible_paths: + if os.path.exists(path): + kb_file = path + break + + if not kb_file: + raise FileNotFoundError("Could not find consult_kb_stepwise.json in any of the expected locations") + + with open(kb_file, 'r') as f: + kb = json.load(f) + entry = kb.get(syndrome_key) + if not entry: + raise ValueError(f"No KB entry for syndrome '{syndrome_key}'") + self.description = f"Gather history for {entry['syndrome_name']}" + # 2) Flatten all required questions + reqs = [] + for block in entry["common_consult_questions"]: + reqs += block["required_information"] + # 3) Build dynamic JSON schema + props = { + q.replace(" ", "_").lower(): {"type": "string", "description": q} + for q in reqs + } + self.args_schema = { + "type": "object", + "properties": props, + "required": list(props.keys()) + } + self._syndrome_query = entry["syndrome_name"] + + async def run(self, **kwargs: Any) -> str: + """ + Generate a history-taking prompt and return the LLM's response. + + Args: + **kwargs: Answers to the required consult questions for the syndrome. + + Returns: + str: The generated history-taking response from the LLM (placeholder). + """ + try: + # All required fields are now in kwargsβ€”render your final template + prompt: str = load_prompt( + "diagnostic_recommendation.j2", + syndrome_query=self._syndrome_query, + **kwargs + ) + return call_llm(prompt) + except Exception as e: + logger.error(f"HistoryTakingTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"HistoryTakingTool failed: {e}") diff --git a/tools/internet_search.py b/tools/internet_search.py new file mode 100644 index 0000000000000000000000000000000000000000..323997a5dfcb6e4ae21e5fcce56551a78c61566a --- /dev/null +++ b/tools/internet_search.py @@ -0,0 +1,122 @@ + +import os +import asyncio +import random +import requests +from tools.base import Tool +from tools.utils import ToolExecutionError, logger +from typing import Any, Dict, List, Union, Optional + +def fetch_and_search_links(links: List[str], query: str, max_results: int = 5) -> List[Dict[str, Any]]: + """ + Fetch the content of each link and return those that contain the query (case-insensitive substring match). + Returns a list of dicts with title, href, and snippet (first match context). + """ + results = [] + for url in links: + try: + resp = requests.get(url, timeout=10) + if resp.status_code != 200: + continue + text = resp.text + # Simple case-insensitive search + idx = text.lower().find(query.lower()) + if idx != -1: + # Try to extract a snippet around the match + start = max(0, idx - 60) + end = min(len(text), idx + 200) + snippet = text[start:end].replace('\n', ' ').replace('\r', ' ') + # Use the URL as title fallback + title = url + # Try to extract if present + import re + m = re.search(r'<title>(.*?)', text, re.IGNORECASE | re.DOTALL) + if m: + title = m.group(1).strip() + results.append({ + "title": title, + "href": url, + "snippet": snippet + }) + if len(results) >= max_results: + break + except Exception as e: + logger.warning(f"Failed to fetch/search trusted link {url}: {e}", exc_info=True) + return results + +SERPER_URL = "https://google.serper.dev/search" + +from typing import Any, Dict, List, Union + + +class InternetSearchTool(Tool): + + def openai_spec(self, legacy=False): + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + """ + Tool for searching the public web and returning top results using the Serper API. + + This tool uses the Serper API to search the web for a query and returns the top results. + """ + def __init__(self) -> None: + """ + Initialize the InternetSearchTool with its name, description, and argument schema. + """ + super().__init__() + self.name = "search_internet" + self.description = "Search the public web for a query and return top results." + self.args_schema = { + "type": "object", + "properties": { + "q": {"type": "string", "description": "search query"}, + "max_results": {"type": "integer", "default": 5} + }, + "required": ["q"] + } + + async def run(self, q: str, max_results: int = 5, trusted_links: Optional[List[str]] = None) -> Union[str, Dict[str, Any]]: + """ + Search the public web for a query and return a summarized answer with direct synthesis from the top results. + """ + try: + # 1. If trusted_links are provided, try searching them first + summary_parts = [] + if trusted_links: + trusted_results = fetch_and_search_links(trusted_links, q, max_results=max_results) + if trusted_results: + for res in trusted_results: + summary_parts.append(f"**{res['title']}**\n{res['snippet']}\n[Read more]({res['href']})\n") + # 2. Fallback to Serper API + api_key = os.getenv("SERPER_API_KEY") + if not api_key: + raise ToolExecutionError("SERPER_API_KEY missing in env settings.") + + payload = {"q": q, "num": max_results} + headers = {"X-API-KEY": api_key, "Content-Type": "application/json"} + + backoff = 2 + for attempt in range(3): + try: + resp = requests.post(SERPER_URL, json=payload, headers=headers, timeout=15) + if resp.status_code == 429 and attempt < 2: + await asyncio.sleep(backoff + random.random()) + backoff *= 2 + continue + resp.raise_for_status() + results = resp.json().get("organic", [])[:max_results] + for i in results: + summary_parts.append(f"**{i.get('title')}**\n{i.get('snippet')}\n[Read more]({i.get('link')})\n") + if summary_parts: + return "\n".join(summary_parts) + else: + return "No relevant results found." + except Exception as e: + logger.warning(f"InternetSearchTool attempt {attempt+1} failed: {e}", exc_info=True) + raise ToolExecutionError("Internet search failed after retries.") + except Exception as e: + logger.error(f"InternetSearchTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"InternetSearchTool failed: {e}") diff --git a/tools/ipc_reporting.py b/tools/ipc_reporting.py new file mode 100644 index 0000000000000000000000000000000000000000..bada6a20782975bc028a07d45573b7f2c503bf5e --- /dev/null +++ b/tools/ipc_reporting.py @@ -0,0 +1,779 @@ +from tools.base import Tool +import json +import re +from typing import Any, Dict, Optional + +from tools.utils import load_prompt, ToolExecutionError, logger + + +def load_jurisdiction_requirements(jurisdiction: str): + """ + Load required fields and format string for a given jurisdiction from JSON config. + Supports mapping facility names to jurisdictions. + """ + import os + import json + config_path = os.path.join(os.path.dirname(__file__), "ipc_reporting_requirements.json") + with open(config_path, "r", encoding="utf-8") as f: + data = json.load(f) + + # Facility to jurisdiction mapping + facility_mapping = { + "parkland": "Texas", + "parkland health": "Texas", + "parkland hospital": "Texas", + "dallas county": "Texas", + "ut southwestern": "Texas", + "texas health": "Texas", + "methodist": "Texas", + "baylor": "Texas", + "cleveland clinic": "Ohio", + "mayo clinic": "Minnesota", + "johns hopkins": "Maryland", + "mass general": "Massachusetts", + "ucla": "California", + "ucsf": "California", + "stanford": "California", + "cedars-sinai": "California", + "mount sinai": "New York", + "nyu": "New York", + "presbyterian": "New York", + "memorial sloan": "New York" + } + + # First try direct jurisdiction match + key = jurisdiction.strip().upper() + for k in data: + if k.upper() == key: + return data[k] + + # Check if it's a US state in the nested structure + if "US_States" in data: + for state_name, state_data in data["US_States"].items(): + if state_name.upper() == key: + return state_data + + # Try facility name mapping + jurisdiction_lower = jurisdiction.strip().lower() + for facility_name, mapped_jurisdiction in facility_mapping.items(): + if facility_name in jurisdiction_lower: + # Recursively look up the mapped jurisdiction + return load_jurisdiction_requirements(mapped_jurisdiction) + + return data.get("default", {}) + +async def search_facility_location(facility_name: str) -> Dict[str, Any]: + """ + Search the internet to find the location (city, state) of a healthcare facility. + Returns a dictionary with location information or an error. + """ + try: + from tools.internet_search import InternetSearchTool + search_tool = InternetSearchTool() + + # Search for the facility with location context + search_query = f"{facility_name} hospital medical center location city state address" + search_result = await search_tool.run(search_query) + + if isinstance(search_result, dict) and "error" in search_result: + return {"error": "Unable to search for facility location"} + + # Try to extract state information from the search results + search_text = str(search_result).lower() + + # Common state patterns in search results + us_states = [ + "alabama", "alaska", "arizona", "arkansas", "california", "colorado", "connecticut", + "delaware", "florida", "georgia", "hawaii", "idaho", "illinois", "indiana", "iowa", + "kansas", "kentucky", "louisiana", "maine", "maryland", "massachusetts", "michigan", + "minnesota", "mississippi", "missouri", "montana", "nebraska", "nevada", "new hampshire", + "new jersey", "new mexico", "new york", "north carolina", "north dakota", "ohio", + "oklahoma", "oregon", "pennsylvania", "rhode island", "south carolina", "south dakota", + "tennessee", "texas", "utah", "vermont", "virginia", "washington", "west virginia", + "wisconsin", "wyoming" + ] + + detected_state = None + for state in us_states: + if state in search_text: + detected_state = state.title() + break + + if detected_state: + return {"state": detected_state, "search_result": search_result} + else: + return {"error": "Could not determine state from search results", "search_result": search_result} + + except Exception as e: + return {"error": f"Search failed: {str(e)}"} + + +class IPCReportingTool(Tool): + + def openai_spec(self, legacy=False): + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + """ + Tool for generating infection prevention and control (IPC) reports with current, up-to-date requirements. + + This tool uses real-time internet search to find the most current reporting requirements for specific + organisms and jurisdictions, ensuring compliance with the latest public health guidelines. + + Features: + - Searches online for current reporting requirements by organism and location + - Extracts organism information from case summaries automatically + - Determines location from facility names or direct city/state input + - Generates comprehensive reports using current requirements + - Includes proper metadata and source attribution + + Use this tool when healthcare workers need to: + - Report infectious diseases to public health authorities + - Understand current reporting requirements for specific organisms + - Generate compliant infection control documentation + - Find up-to-date reporting timelines and contact information + """ + def __init__(self) -> None: + """ + Initialize the IPCReportingTool with its name, description, and argument schema. + """ + super().__init__() + self.name = "IPC_reporting" + self.description = ( + "Generate infection control reports using current, up-to-date reporting requirements found through online search. " + "Searches for the latest public health reporting requirements for specific organisms and locations. " + "Use this tool when users ask about reporting requirements, need current guidelines, " + "or want help with infection control reporting for any facility or jurisdiction. " + "IMPORTANT: When calling this tool, include ALL relevant information from the conversation in the case_summary, " + "especially the specific organism/pathogen mentioned (e.g., 'typhus fever', 'VRE', 'MRSA'). " + "Accepts facility names (e.g., 'Parkland Health', 'Mayo Clinic') or " + "jurisdictions (e.g., 'Texas', 'CDC', 'WHO'). " + "Automatically determines location and searches for current requirements online. " + "Provides real-time, accurate reporting information instead of outdated static data. " + "First discovers required fields for the jurisdiction, then generates formatted report." + ) + self.args_schema = { + "type": "object", + "properties": { + "case_summary": { + "type": "string", + "description": "Complete summary including ALL relevant context from the conversation, especially the specific organism/pathogen mentioned (e.g., 'User asked about typhus fever reporting in Dallas TX', 'VRE infection at facility', 'MRSA case'). Include the organism name prominently." + }, + "jurisdiction": { + "type": "string", + "description": "Facility name (e.g., 'Parkland Health') or jurisdiction (e.g., 'Texas', 'CDC', 'WHO'). If unknown, will search for facility location." + }, + "city_state": { + "type": "string", + "description": "City and state (e.g., 'Dallas, Texas') - use when facility name is unclear or search fails" + }, + "fields": { + "type": "object", + "description": "Dictionary of required field values for report generation" + } + }, + "required": ["case_summary", "jurisdiction"] + } + self._required_fields_cache: Optional[list[str]] = None + self._format_instructions_cache: Optional[str] = None + + async def run( + self, + case_summary: str, + jurisdiction: Optional[str] = None, + city_state: Optional[str] = None, + fields: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Generate an IPC report by searching online for current reporting requirements. + Uses real-time search to find up-to-date requirements for the specific organism and location. + """ + try: + if not jurisdiction or not str(jurisdiction).strip(): + return { + "missing_fields": ["jurisdiction"], + "message": "Please specify the facility name or reporting jurisdiction (e.g., 'Parkland Health', 'Texas', 'CDC', 'WHO')." + } + + logger.info(f"IPC Reporting: Processing jurisdiction '{jurisdiction}'") + + # Extract organism from case summary for targeted search + organism = await self._extract_organism_from_case(case_summary) + + # Determine location for reporting requirements + location = None + if city_state: + location = city_state + else: + # Try to detect location from facility name + search_result = await search_facility_location(jurisdiction) + if "state" in search_result and "city" in search_result: + location = f"{search_result['city']}, {search_result['state']}" + elif "state" in search_result: + location = search_result["state"] + else: + return { + "missing_fields": ["city_state"], + "message": f"I couldn't find location information for '{jurisdiction}'. Please provide the city and state (e.g., 'Dallas, Texas') so I can search for current reporting requirements." + } + + logger.info(f"IPC Reporting: Searching for current requirements - Organism: {organism}, Location: {location}") + + # Search for current reporting requirements online + requirements_result = await self._search_current_reporting_requirements(organism, location) + + if "error" in requirements_result: + return { + "error": requirements_result["error"], + "message": f"Unable to find current reporting requirements for {organism} in {location}. Please check the organism name and location." + } + + required_fields = requirements_result.get("required_fields", []) + reporting_info = requirements_result.get("reporting_info", "") + + if fields is None: + # Phase-1: return discovered requirements + return { + "required_fields": required_fields, + "organism": organism, + "location": location, + "reporting_info": reporting_info, + "message": f"Current reporting requirements for {organism} in {location}:\n{reporting_info}\n\nPlease provide: {', '.join(required_fields)}" + } + + # Phase-2: build the final report + missing = [f for f in required_fields if f not in fields or not fields[f]] + if missing: + return { + "missing_fields": missing, + "message": f"Still missing required fields: {', '.join(missing)}" + } + + # Generate the report using current requirements + report = await self._generate_current_report(organism, location, case_summary, fields, requirements_result) + + file_name = f"ipc_report_{organism.lower().replace(' ','_')}_{location.lower().replace(' ','_').replace(',','')}.md" + logger.info(f"IPC Reporting: Successfully generated current report") + return { + "report": report, + "file_name": file_name, + "organism": organism, + "location": location + } + + except Exception as e: + logger.error(f"IPCReportingTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"IPCReportingTool failed: {e}") + + async def _extract_organism_from_case(self, case_summary: str, conversation_context: Optional[list] = None) -> str: + """Extract the primary organism/pathogen from the case summary and conversation context.""" + # Use LLM to extract organism name from case summary + try: + from core.utils.llm_connector import call_llm + + # If case_summary is empty or very short, try to use conversation context + if not case_summary or len(case_summary.strip()) < 5: + logger.info(f"IPC Reporting: Case summary is empty or too short: '{case_summary}'") + + # Try to extract from recent conversation context + if conversation_context: + logger.info("IPC Reporting: Attempting to extract organism from conversation context") + recent_messages = [] + for msg in conversation_context[-5:]: # Last 5 messages + content = msg.get("content", "") + if content and len(content.strip()) > 3: + recent_messages.append(content) + + if recent_messages: + case_summary = "\n".join(recent_messages) + logger.info(f"IPC Reporting: Using conversation context as case summary: {case_summary[:100]}...") + else: + logger.info("IPC Reporting: No useful conversation context found") + return "Unknown organism" + else: + logger.info("IPC Reporting: No conversation context provided") + return "Unknown organism" + + prompt = f""" + Extract the primary organism or pathogen from this case summary or conversation. + + Special instructions: + - If "typhus" or "typhus fever" is mentioned, return "typhus fever" + - If "VRE" is mentioned, return "VRE" + - If "vancomycin-resistant enterococcus" is mentioned, return "VRE" + - If "Enterococcus" with resistance is mentioned, return "VRE" + - Otherwise return the specific organism name (e.g., "Candida albicans", "MRSA", "C. difficile") + + If multiple organisms are mentioned, return the most significant one for reporting purposes. + If no specific organism is mentioned, return "Unknown organism". + + Text to analyze: {case_summary} + + Organism:""" + + organism = await call_llm(prompt) + return organism.strip() + except Exception as e: + logger.error(f"Error extracting organism: {e}") + # Fallback: try to extract manually + search_text = case_summary.lower() if case_summary else "" + common_organisms = [ + "typhus fever", "typhus", "C. difficile", "Clostridium difficile", "MRSA", "VRE", "CRE", + "Candida albicans", "Aspergillus", "Tuberculosis", "TB", + "E. coli", "Klebsiella", "Pseudomonas", "Acinetobacter" + ] + for organism in common_organisms: + if organism.lower() in search_text: + return organism + return "Unknown organism" + + async def _search_current_reporting_requirements(self, organism: str, location: str) -> Dict[str, Any]: + """Search online for current reporting requirements for the specific organism and location.""" + try: + from tools.internet_search import InternetSearchTool + search_tool = InternetSearchTool() + + # Extract state from location for broader search + state = location.split(",")[-1].strip() if "," in location else location + + # Create multiple search strategies with different approaches + search_strategies = [ + # Strategy 1: Specific organism + state + [ + f"{organism} reportable disease {state} 2024 2025", + f"{organism} notifiable disease {state} health department", + f"VRE vancomycin resistant enterococcus reportable {state}" if "VRE" in organism else f"{organism} reportable {state}" + ], + # Strategy 2: General state reporting lists + [ + f"{state} reportable diseases list 2024 2025 health department", + f"{state} notifiable diseases surveillance CDC", + f"{state} public health reportable conditions list" + ], + # Strategy 3: CDC/national guidelines + [ + f"CDC reportable diseases {organism} surveillance", + f"NHSN {organism} reporting requirements healthcare", + f"vancomycin resistant enterococcus CDC reporting" if "VRE" in organism else f"{organism} CDC reporting" + ] + ] + + requirements_info = "" + found_relevant_info = False + + # Try each strategy until we find good information + for strategy_num, queries in enumerate(search_strategies, 1): + logger.info(f"IPC Reporting: Trying search strategy {strategy_num}") + + for query in queries: + logger.info(f"IPC Reporting: Searching with query: {query}") + search_result = await search_tool.run(query) + + # InternetSearchTool returns a formatted string, not a dict with "results" + if isinstance(search_result, str) and search_result.strip(): + # Check if this result contains reporting requirements + relevant_keywords = [ + "reportable", "notifiable", "reporting requirements", + "public health", "health department", "cdc", "nhsn", + "surveillance", "reporting", "notify", "submit" + ] + + search_text = search_result.lower() + if any(keyword in search_text for keyword in relevant_keywords): + logger.info(f"IPC Reporting: Found relevant search results for query: {query}") + requirements_info += f"\n--- Search Results for: {query} ---\n{search_result}\n" + found_relevant_info = True + else: + logger.info(f"IPC Reporting: Search results not relevant for query: {query}") + + elif isinstance(search_result, dict) and "results" in search_result: + # Handle legacy dict format if it exists + for result in search_result["results"][:5]: # Check top 5 results + content = result.get("content", "") + title = result.get("title", "") + url = result.get("url", "") + + # Check if this result contains reporting requirements + relevant_keywords = [ + "reportable", "notifiable", "reporting requirements", + "public health", "health department", "cdc", "nhsn", + "surveillance", "reporting", "notify", "submit" + ] + + if any(keyword in content.lower() for keyword in relevant_keywords): + requirements_info += f"\n--- {title} ---\n{content[:800]}...\nSource: {url}\n" + found_relevant_info = True + else: + logger.warning(f"IPC Reporting: Unexpected search result format: {type(search_result)}") + + # If we found good info in this strategy, stop searching + if found_relevant_info and len(requirements_info) > 500: + break + + if not requirements_info: + # Try fallback for common antimicrobial-resistant organisms + fallback_result = self._get_fallback_aro_requirements(organism, location) + if fallback_result.get("required_fields"): + logger.info(f"IPC Reporting: Using fallback requirements for {organism}") + return fallback_result + + return {"error": f"No current reporting requirements found for {organism} in {location}"} + + # Use LLM to extract structured requirements from search results + try: + from core.utils.llm_connector import call_llm + analysis_prompt = f""" + Based on the following search results about {organism} (Vancomycin-Resistant Enterococcus) reporting requirements in {location}, + extract the key information needed for infection control reporting. + + Search Results: + {requirements_info} + + Please analyze and provide: + 1. Is {organism} reportable in {location}? (Yes/No/Depends) + 2. What are the required fields for reporting? List them clearly + 3. What is the reporting timeline? (e.g., within 24 hours, immediately) + 4. Who should receive the report? (e.g., local health department, CDC, hospital IPC team) + 5. Any special requirements or notes? + 6. If not specifically mentioned, what would be the likely reporting requirements based on similar organisms? + + Be specific about {state} requirements if found. If exact requirements for {organism} aren't found, + extrapolate from general antimicrobial-resistant organism reporting requirements. + + Format your response as clear, actionable information that can guide healthcare workers. + """ + + analysis = await call_llm(analysis_prompt) + + except Exception as e: + logger.error(f"Error analyzing requirements: {e}") + analysis = f"Current reporting requirements for {organism} in {location} (analysis unavailable due to error: {e})" + + # Extract required fields from the analysis + required_fields = self._parse_required_fields_from_analysis(analysis) + + # Special handling for VRE if no specific info found + if "VRE" in organism and ("no" in analysis.lower() or "not found" in analysis.lower()): + # Provide general VRE reporting guidance + analysis += f""" + + **General VRE Reporting Guidance:** + VRE (Vancomycin-Resistant Enterococcus) is typically reportable to infection control teams and may be reportable + to public health authorities depending on the jurisdiction. In {state}: + + - Hospital infection control team should be notified immediately + - Contact isolation precautions should be implemented + - Many states require reporting of antimicrobial-resistant organisms + - CDC considers VRE a serious threat requiring surveillance + - Check with your local/state health department for specific requirements + + **Recommended immediate actions:** + 1. Implement contact precautions + 2. Notify infection control team + 3. Check facility-specific reporting protocols + 4. Consider environmental cleaning protocols + """ + + # Update required fields for VRE + required_fields = [ + "patient_demographics", + "infection_type", + "specimen_collection_date", + "specimen_type", + "antimicrobial_susceptibility", + "isolation_status", + "clinical_outcome", + "infection_control_measures" + ] + + return { + "required_fields": required_fields, + "reporting_info": analysis, + "source_info": requirements_info[:1000] + "..." if len(requirements_info) > 1000 else requirements_info + } + + except Exception as e: + logger.error(f"Error searching for reporting requirements: {e}") + return {"error": f"Search failed: {str(e)}"} + + def _parse_required_fields_from_analysis(self, analysis: str) -> list[str]: + """Parse required fields from LLM analysis of reporting requirements.""" + # Standard fields that are commonly required + standard_fields = [ + "patient_demographics", + "onset_date", + "specimen_collection_date", + "specimen_type", + "laboratory_results", + "clinical_syndrome", + "treatment_given", + "outcome", + "healthcare_facility", + "reporting_facility" + ] + + # Look for specific field mentions in the analysis + analysis_lower = analysis.lower() + found_fields = [] + + field_keywords = { + "patient_demographics": ["patient", "demographics", "age", "gender", "name", "identifier"], + "onset_date": ["onset", "symptom", "illness date", "onset date"], + "specimen_collection_date": ["specimen", "collection", "sample date"], + "specimen_type": ["specimen type", "sample type", "culture", "blood", "urine"], + "laboratory_results": ["lab", "laboratory", "culture", "sensitivity", "antimicrobial"], + "clinical_syndrome": ["syndrome", "diagnosis", "clinical", "infection type"], + "treatment_given": ["treatment", "therapy", "antibiotic", "antifungal"], + "outcome": ["outcome", "status", "recovery", "death", "discharge"], + "healthcare_facility": ["facility", "hospital", "clinic", "location"], + "reporting_facility": ["reporting", "laboratory", "reporter"] + } + + for field, keywords in field_keywords.items(): + if any(keyword in analysis_lower for keyword in keywords): + found_fields.append(field) + + # Ensure we have at least basic required fields + essential_fields = ["patient_demographics", "onset_date", "specimen_type", "laboratory_results"] + for field in essential_fields: + if field not in found_fields: + found_fields.append(field) + + return found_fields[:8] # Limit to reasonable number of fields + + async def _generate_current_report( + self, + organism: str, + location: str, + case_summary: str, + fields: Dict[str, Any], + requirements_result: Dict[str, Any] + ) -> str: + """Generate a comprehensive report using current requirements.""" + try: + from core.utils.llm_connector import call_llm + + reporting_info = requirements_result.get("reporting_info", "") + source_info = requirements_result.get("source_info", "") + + prompt = f""" + Generate a comprehensive infection control report for {organism} in {location}. + + Case Summary: {case_summary} + + Provided Information: + {json.dumps(fields, indent=2)} + + Current Reporting Requirements (from online search): + {reporting_info} + + Please generate a professional infection control report that includes: + 1. Case identification and basic demographics + 2. Clinical presentation and onset information + 3. Laboratory findings and organism details + 4. Treatment and management + 5. Infection control measures + 6. Reporting compliance information + 7. Contact tracing and follow-up as needed + + Format this as a formal report suitable for submission to public health authorities. + Include the current date and ensure all provided information is incorporated appropriately. + """ + + report = await call_llm(prompt) + + except Exception as e: + logger.error(f"Error generating report: {e}") + # Fallback: generate basic report template + report = f""" +INFECTION CONTROL REPORT + +Organism: {organism} +Location: {location} +Case Summary: {case_summary} + +Provided Information: +{json.dumps(fields, indent=2)} + +Current Requirements: {requirements_result.get('reporting_info', 'Unable to retrieve current requirements')} + +Note: Report generation encountered an error: {e} +""" + + # Add metadata footer + from datetime import datetime + current_date = datetime.now().strftime("%Y-%m-%d %H:%M") + + footer = f""" + +--- +**Report Generated:** {current_date} +**Organism:** {organism} +**Location:** {location} +**Generated by:** Infectious Diseases AI Assistant +**Requirements Source:** Current online search results + +*Note: This report was generated using current reporting requirements found through online search. +Please verify with local health department for any recent updates to reporting procedures.* +""" + + return report + footer + + def _get_fallback_aro_requirements(self, organism: str, location: str) -> Dict[str, Any]: + """Provide fallback requirements for common antimicrobial-resistant organisms (AROs).""" + + # Extract state for state-specific guidance + state = location.split(",")[-1].strip() if "," in location else location + + # Common ARO patterns and typical requirements + aro_patterns = { + "VRE": { + "full_name": "Vancomycin-Resistant Enterococcus", + "typical_reportable": True, + "urgency": "Contact isolation required immediately - Report within 24 hours", + "common_requirements": [ + "patient_demographics", + "infection_type_and_site", + "specimen_collection_date", + "specimen_type", + "vancomycin_mic_result", + "antimicrobial_susceptibility_panel", + "risk_factors_and_exposures", + "isolation_precautions_implemented", + "clinical_outcome", + "infection_control_measures", + "contact_tracing_performed", + "environmental_culture_results" + ] + }, + "MRSA": { + "full_name": "Methicillin-Resistant Staphylococcus aureus", + "typical_reportable": True, + "urgency": "Contact isolation required", + "common_requirements": [ + "patient_demographics", + "infection_type", + "specimen_collection_date", + "specimen_type", + "antimicrobial_susceptibility", + "healthcare_exposure", + "isolation_status" + ] + }, + "CRE": { + "full_name": "Carbapenem-Resistant Enterobacteriaceae", + "typical_reportable": True, + "urgency": "Immediate reporting required - CDC priority pathogen", + "common_requirements": [ + "patient_demographics", + "infection_type", + "specimen_collection_date", + "specimen_type", + "carbapenemase_testing", + "antimicrobial_susceptibility", + "contact_tracing", + "isolation_status" + ] + }, + "Typhus": { + "full_name": "Typhus Fever (Rickettsia species)", + "typical_reportable": True, + "urgency": "Immediate reporting required - CDC notifiable disease", + "common_requirements": [ + "patient_demographics", + "onset_date", + "clinical_presentation", + "laboratory_confirmation", + "epidemiological_factors", + "travel_history", + "vector_exposure_history", + "specimen_collection_date", + "treatment_given", + "outcome", + "contact_investigation" + ] + }, + "Rickettsia": { + "full_name": "Rickettsial Disease", + "typical_reportable": True, + "urgency": "Report within 24 hours - vector-borne disease surveillance", + "common_requirements": [ + "patient_demographics", + "onset_date", + "clinical_presentation", + "laboratory_confirmation", + "epidemiological_factors", + "travel_history", + "vector_exposure_history", + "specimen_type_and_results", + "treatment_response", + "outcome" + ] + } + } + + # Find matching organism - check for VRE patterns + organism_upper = organism.upper() + aro_info = None + + # Special check for VRE patterns + if any(term in organism_upper for term in ["VRE", "VANCOMYCIN", "RESISTANT", "ENTEROCOCCUS"]): + if any(vr_term in organism_upper for vr_term in ["VANCOMYCIN", "VRE"]) or any(ent_term in organism_upper for ent_term in ["ENTEROCOCCUS", "FAECIUM", "FAECALIS"]): + aro_info = aro_patterns["VRE"] + + # Check other patterns if not VRE + if not aro_info: + for key, info in aro_patterns.items(): + if key in organism_upper: + aro_info = info + break + + if not aro_info: + return { + "required_fields": ["patient_demographics", "specimen_type", "laboratory_results"], + "reporting_info": f"Standard reporting requirements for {organism} - contact your facility's infection control team and local health department for specific requirements.", + "source_info": "Fallback guidance" + } + + reporting_info = f""" +**{aro_info['full_name']} ({organism}) Reporting in {state}** + +**Immediate Requirements:** +- {aro_info['urgency']} +- Implement contact precautions immediately +- Notify facility infection control team within 24 hours +- Report to {state} Department of Health and Human Services (DHHS) + +**North Carolina Specific Guidance** (if {state} == "North Carolina"): +- NC requires reporting of multidrug-resistant organisms to DHHS +- Contact NC DHHS Communicable Disease Branch: (919) 733-3419 +- Use the NC Electronic Disease Surveillance System (NC EDSS) if available +- Follow NC infection control guidelines for healthcare facilities + +**Standard Reporting Elements:** +{chr(10).join([f"β€’ {field.replace('_', ' ').title()}" for field in aro_info['common_requirements']])} + +**Clinical Actions Required:** +1. Contact isolation precautions (gown, gloves, dedicated equipment) +2. Single room placement or cohorting with other VRE patients +3. Hand hygiene with soap and water or alcohol-based sanitizer +4. Environmental cleaning with appropriate disinfectants +5. Notify receiving facilities during transfers +6. Consider screening of contacts if outbreak suspected + +**Antimicrobial Stewardship Considerations:** +- Review patient's antibiotic history +- Optimize current antimicrobial therapy +- Consider linezolid, daptomycin, or tigecycline for treatment +- Avoid unnecessary vancomycin use to prevent further resistance + +*Note: This is general guidance based on standard practices. Always verify current requirements with your local health department and facility policies.* +""" + + return { + "required_fields": aro_info["common_requirements"], + "reporting_info": reporting_info, + "source_info": f"Fallback guidance for {aro_info['full_name']}" + } diff --git a/tools/ipc_reporting_requirements.json b/tools/ipc_reporting_requirements.json new file mode 100644 index 0000000000000000000000000000000000000000..ba78644ed518f3f53eae037483685bc949b77563 --- /dev/null +++ b/tools/ipc_reporting_requirements.json @@ -0,0 +1,216 @@ +{ + "CDC": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "reporter_name"], + "format": "# CDC IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "WHO": { + "required_fields": ["patient_id", "facility", "country", "diagnosis", "date_of_onset", "organism", "reporter_email"], + "format": "# WHO IPC Report\n- **Patient ID:** {patient_id}\n- **Facility:** {facility}\n- **Country:** {country}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "US_States": { + "Alabama": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_phone"], + "format": "# Alabama IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "Alaska": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "borough", "reporter_name", "reporter_email"], + "format": "# Alaska IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **Borough:** {borough}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Arizona": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Arizona IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Arkansas": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# Arkansas IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "California": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_phone"], + "format": "# California IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "Colorado": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Colorado IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Connecticut": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# Connecticut IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "Delaware": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Delaware IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Florida": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# Florida IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "Georgia": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_phone"], + "format": "# Georgia IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "Hawaii": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "island", "reporter_name", "reporter_email"], + "format": "# Hawaii IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **Island:** {island}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Idaho": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# Idaho IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "Illinois": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Illinois IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Indiana": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_phone"], + "format": "# Indiana IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "Iowa": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# Iowa IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "Kansas": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Kansas IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Kentucky": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Kentucky IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Louisiana": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "parish", "reporter_name", "reporter_phone"], + "format": "# Louisiana IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **Parish:** {parish}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "Maine": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# Maine IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "Maryland": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email", "reporter_phone"], + "format": "# Maryland IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "Massachusetts": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "city", "reporter_name", "reporter_email"], + "format": "# Massachusetts IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **City:** {city}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Michigan": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_phone"], + "format": "# Michigan IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "Minnesota": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Minnesota IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Mississippi": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# Mississippi IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "Missouri": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_phone"], + "format": "# Missouri IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "Montana": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# Montana IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "Nebraska": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Nebraska IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Nevada": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_phone"], + "format": "# Nevada IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "New_Hampshire": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# New Hampshire IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "New_Jersey": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# New Jersey IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "New_Mexico": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_phone"], + "format": "# New Mexico IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "New_York": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "borough", "reporter_name", "reporter_email"], + "format": "# New York IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **Borough:** {borough}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "North_Carolina": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_phone"], + "format": "# North Carolina IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "North_Dakota": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# North Dakota IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "Ohio": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Ohio IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Oklahoma": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_phone"], + "format": "# Oklahoma IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "Oregon": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# Oregon IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "Pennsylvania": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Pennsylvania IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Rhode_Island": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "city", "reporter_name", "reporter_phone"], + "format": "# Rhode Island IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **City:** {city}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "South_Carolina": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# South Carolina IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "South_Dakota": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# South Dakota IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Tennessee": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_phone"], + "format": "# Tennessee IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "Texas": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email", "reporter_phone"], + "format": "# Texas IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "Utah": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# Utah IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "Vermont": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Vermont IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Virginia": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_phone"], + "format": "# Virginia IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Phone:** {reporter_phone}\n- **Summary:** {case_summary}" + }, + "Washington": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Washington IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "West_Virginia": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# West Virginia IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + }, + "Wisconsin": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name", "reporter_email"], + "format": "# Wisconsin IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Reporter Email:** {reporter_email}\n- **Summary:** {case_summary}" + }, + "Wyoming": { + "required_fields": ["patient_name", "date_of_birth", "facility", "diagnosis", "date_of_onset", "organism", "county", "reporter_name"], + "format": "# Wyoming IPC Report\n- **Patient Name:** {patient_name}\n- **DOB:** {date_of_birth}\n- **Facility:** {facility}\n- **County:** {county}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + } + }, + "default": { + "required_fields": ["facility", "diagnosis", "date_of_onset", "organism", "reporter_name"], + "format": "# IPC Report\n- **Facility:** {facility}\n- **Diagnosis:** {diagnosis}\n- **Date of Onset:** {date_of_onset}\n- **Organism:** {organism}\n- **Reporter:** {reporter_name}\n- **Summary:** {case_summary}" + } +} diff --git a/tools/nhsn_criteria_evaluator.py b/tools/nhsn_criteria_evaluator.py new file mode 100644 index 0000000000000000000000000000000000000000..b99d0882727d5fb64a036448103c745c5fa254c7 --- /dev/null +++ b/tools/nhsn_criteria_evaluator.py @@ -0,0 +1,165 @@ +from tools.base import Tool + +def load_prompt(template_name, **kwargs): + # Placeholder for the actual prompt loading logic + pass + +def call_llm(prompt): + # Placeholder for the actual LLM call + return f"NHSN evaluation for: {prompt}" + +def safe_parse(raw): + # Placeholder for safe JSON parsing + return {"parsed": raw} + + +from tools.utils import ToolExecutionError, logger +from typing import Any, Dict, Optional + +class NHSNCriteriaEvaluatorTool(Tool): + + def openai_spec(self, legacy=False): + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + """ + Tool to fetch and evaluate NHSN definitions for healthcare-associated infection types. + + This tool can operate in two phases: (1) discovering required fields for a given definition, + and (2) evaluating whether a case meets the NHSN definition based on user-supplied data. + """ + def __init__(self) -> None: + """ + Initialize the NHSNCriteriaEvaluatorTool with its name, description, and argument schema. + """ + super().__init__() + self.name = "NHSN_criteria_evaluator" + self.description = ( + "Fetch the NHSN definition for a given HA-infection type, gather required data, " + "and evaluate whether the case meets it." + ) + self.args_schema = { + "type": "object", + "properties": { + "case_description": { + "type": "string", + "description": "Free-text summary of the patient’s presentation" + }, + "definition_type": { + "type": "string", + "description": "Which NHSN definition to apply (e.g. 'CLABSI','CAUTI','SSI')" + }, + "fields": { + "type": "object", + "description": "Map of field-name β†’ user-supplied value" + } + }, + "required": ["case_description", "definition_type"] + } + self._required_fields_cache: list[str] = [] + self._definition_logic_cache: str = "" + + # Comprehensive mapping of NHSN definitions to required fields and descriptions + NHSN_DEFINITION_FIELDS = { + "CAUTI": [ + {"field": "Patient age", "description": "Age of the patient at the time of event"}, + {"field": "Indwelling urinary catheter", "description": "Presence and duration of indwelling urinary catheter (must be in place >2 calendar days)"}, + {"field": "Signs and symptoms", "description": "Relevant clinical signs and symptoms (e.g., fever, suprapubic tenderness, costovertebral angle pain/tenderness, urgency, frequency, dysuria)"}, + {"field": "Urine culture result", "description": "Urine culture result with no more than 2 species of organisms, at least one of which is a bacterium of β‰₯10^5 CFU/mL"}, + {"field": "Alternative sources", "description": "Assessment for alternative sources of infection"}, + ], + "CLABSI": [ + {"field": "Patient age", "description": "Age of the patient at the time of event"}, + {"field": "Central line", "description": "Presence and duration of central line (must be in place >2 calendar days)"}, + {"field": "Signs and symptoms", "description": "Relevant clinical signs and symptoms (e.g., fever, chills, hypotension)"}, + {"field": "Blood culture result", "description": "Positive blood culture not related to infection at another site"}, + {"field": "Pathogen", "description": "Organism(s) identified in blood culture"}, + ], + "VAP": [ + {"field": "Patient age", "description": "Age of the patient at the time of event"}, + {"field": "Mechanical ventilation", "description": "Duration and presence of mechanical ventilation (>2 calendar days)"}, + {"field": "Radiologic evidence", "description": "New or progressive infiltrate, consolidation, cavitation, or pneumatoceles on chest imaging"}, + {"field": "Clinical criteria", "description": "Fever, leukopenia/leukocytosis, altered mental status (if >70), and at least two: new purulent sputum, worsening gas exchange, cough, rales, bronchial breath sounds"}, + {"field": "Microbiologic evidence", "description": "Positive culture from respiratory specimen (if available)"}, + ], + "SSI": [ + {"field": "Procedure type", "description": "Type of surgical procedure (NHSN code)"}, + {"field": "Date of procedure", "description": "Date of the surgical procedure"}, + {"field": "Infection criteria", "description": "Signs/symptoms of infection at or near the surgical site (e.g., purulent drainage, pain, swelling, redness, heat)"}, + {"field": "Microbiologic evidence", "description": "Organism(s) identified from aseptically obtained culture of fluid or tissue from the surgical site"}, + {"field": "Surgeon diagnosis", "description": "Diagnosis of SSI by the surgeon or attending physician"}, + ], + "MRSA BSI": [ + {"field": "Blood culture result", "description": "Positive blood culture for MRSA"}, + {"field": "Clinical criteria", "description": "Signs and symptoms consistent with bloodstream infection"}, + {"field": "Central line", "description": "Presence of central line (if applicable)"}, + ], + "LabID C. difficile": [ + {"field": "Stool test result", "description": "Positive laboratory test for C. difficile toxin or molecular assay"}, + {"field": "Diarrhea", "description": "Documentation of diarrhea (β‰₯3 unformed stools in 24 hours)"}, + {"field": "Onset", "description": "Date and location of onset (community vs. healthcare facility)"}, + ], + "VAE": [ + {"field": "Mechanical ventilation", "description": "Duration and presence of mechanical ventilation (>2 calendar days)"}, + {"field": "Worsening oxygenation", "description": "Increase in daily minimum PEEP or FiO2"}, + {"field": "Clinical criteria", "description": "Fever, leukopenia/leukocytosis, purulent respiratory secretions"}, + ], + "PNEU": [ + {"field": "Radiologic evidence", "description": "New or progressive infiltrate, consolidation, cavitation, or pneumatoceles on chest imaging"}, + {"field": "Clinical criteria", "description": "Fever, leukopenia/leukocytosis, altered mental status (if >70), and at least two: new purulent sputum, worsening gas exchange, cough, rales, bronchial breath sounds"}, + {"field": "Microbiologic evidence", "description": "Positive culture from respiratory specimen (if available)"}, + ], + "UTI": [ + {"field": "Signs and symptoms", "description": "Relevant clinical signs and symptoms (e.g., dysuria, urgency, frequency, suprapubic tenderness)"}, + {"field": "Urine culture result", "description": "Urine culture result with no more than 2 species of organisms, at least one of which is a bacterium of β‰₯10^5 CFU/mL"}, + ], + # Add more definitions as needed + } + + async def run( + self, + case_description: str, + definition_type: str, + fields: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Evaluate whether a case meets the NHSN definition, or return required fields if not provided. + + Args: + case_description (str): Free-text summary of the patient’s presentation. + definition_type (str): Which NHSN definition to apply (e.g. 'CLABSI','CAUTI','SSI'). + fields (Optional[Dict[str, Any]]): Map of field-name to user-supplied value. + + Returns: + Dict[str, Any]: Either a list of required fields or the evaluation result. + """ + try: + # Normalize definition_type for lookup + def_key = definition_type.strip().upper().replace("-", " ").replace("_", " ") + # Try exact match, then partial match + match = None + for k in self.NHSN_DEFINITION_FIELDS: + if def_key == k.upper(): + match = k + break + if not match: + for k in self.NHSN_DEFINITION_FIELDS: + if def_key in k.upper() or k.upper() in def_key: + match = k + break + if fields is None: + if match: + return {"required_fields": self.NHSN_DEFINITION_FIELDS[match]} + else: + # Fallback to placeholder if not found + return {"required_fields": [ + {"field": "field1", "description": "Unknown required field (definition not found)"}, + {"field": "field2", "description": "Unknown required field (definition not found)"} + ]} + # Phase 2: evaluate the completed case (not implemented here) + return {"meets_definition": True, "reasoning": "Sample reasoning."} + except Exception as e: + logger.error(f"NHSNCriteriaEvaluatorTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"NHSNCriteriaEvaluatorTool failed: {e}") diff --git a/tools/pubmed_search.py b/tools/pubmed_search.py new file mode 100644 index 0000000000000000000000000000000000000000..a543184127e94f9f74ddaedf003d4e9f24e0a364 --- /dev/null +++ b/tools/pubmed_search.py @@ -0,0 +1,111 @@ + +import os +import requests +from tools.base import Tool +from tools.utils import ToolExecutionError, logger + +ESEARCH_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi" +ESUMMARY_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi" + +from typing import Any, Dict, List, Union + +class PubMedSearchTool(Tool): + + def openai_spec(self, legacy=False): + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + """ + Tool for searching PubMed for articles and returning top results using NCBI E-utilities. + + This tool uses the NCBI E-utilities API to search PubMed for articles and returns the top results. + """ + def __init__(self) -> None: + """ + Initialize the PubMedSearchTool with its name, description, and argument schema. + """ + super().__init__() + self.name = "search_pubmed" + self.description = "Search PubMed for articles and return top results." + self.args_schema = { + "type": "object", + "properties": { + "q": {"type": "string", "description": "search query"}, + "max_results": {"type": "integer", "default": 5}, + "email": {"type": "string", "description": "user email for NCBI API", "default": os.getenv("NCBI_EMAIL", "")} + }, + "required": ["q"] + } + + async def run( + self, + q: str, + max_results: int = 5, + email: str = "" + ) -> Union[List[Dict[str, Any]], Dict[str, Any]]: + """ + Search PubMed for articles and return the top results. + + Args: + q (str): The search query. + max_results (int, optional): The maximum number of results to return. Defaults to 5. + email (str, optional): User email for NCBI API. Defaults to environment variable NCBI_EMAIL. + + Returns: + Union[List[Dict[str, Any]], Dict[str, Any]]: A list of article result dicts, or an error dict. + """ + try: + # Use provided email or fall back to environment variable or default + if not email: + email = os.getenv("NCBI_EMAIL", "") + + if not email: + # Use a default academic email for research purposes + email = "research@idweek.com" + logger.info("Using default email for NCBI API access") + + api_key = os.getenv("NCBI_API_KEY") + params_esearch = { + "db": "pubmed", + "term": q, + "retmax": max_results, + "retmode": "json", + "tool": "IDweekAgent", + "email": email + } + if api_key: + params_esearch["api_key"] = api_key + resp = requests.get(ESEARCH_URL, params=params_esearch, timeout=15) + resp.raise_for_status() + idlist = resp.json()["esearchresult"].get("idlist", []) + if not idlist: + return [] + params_esummary = { + "db": "pubmed", + "id": ",".join(idlist), + "retmode": "json", + "tool": "IDweekAgent", + "email": params_esearch["email"] + } + if api_key: + params_esummary["api_key"] = api_key + resp2 = requests.get(ESUMMARY_URL, params=params_esummary, timeout=15) + resp2.raise_for_status() + summary = resp2.json().get("result", {}) + results = [] + for uid in idlist: + item = summary.get(uid, {}) + results.append({ + "uid": uid, + "title": item.get("title"), + "authors": [a.get("name") for a in item.get("authors", [])], + "pubdate": item.get("pubdate"), + "source": item.get("source"), + "link": f"https://pubmed.ncbi.nlm.nih.gov/{uid}/" + }) + return results + except Exception as e: + logger.error(f"PubMedSearchTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"PubMedSearchTool failed: {e}") diff --git a/tools/recommend_deescalation.py b/tools/recommend_deescalation.py new file mode 100644 index 0000000000000000000000000000000000000000..f6731f328bcc6d3ed6b5f330b92df1f918ca0f65 --- /dev/null +++ b/tools/recommend_deescalation.py @@ -0,0 +1,86 @@ +from tools.base import Tool + +from tools.utils import load_prompt + +from typing import Any + +class RecommendDeescalationTool(Tool): + + def openai_spec(self, legacy=False): + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + """ + Tool to recommend narrowing (deescalation) of antibiotics based on culture and sensitivity results. + + This tool uses microbiology results and current antibiotic regimen to generate a prompt for an LLM or rules engine to recommend deescalation. + """ + def __init__(self) -> None: + """ + Initialize the RecommendDeescalationTool with its name, description, and argument schema. + """ + super().__init__() + self.name = "recommend_deescalation" + self.description = ( + "Recommend narrowing antibiotics based on culture & sensitivity." + ) + self.args_schema = { + "type": "object", + "properties": { + "culture": {"type": "string", "description": "Culture & sensitivity results"}, + "meds": {"type": "string", "description": "Current antibiotic regimen"}, + "site_of_infection": {"type": "string", "description": "Site of infection (e.g., lung, urine, blood, etc.)"}, + "risk_of_biofilm": {"type": "string", "description": "Risk or presence of biofilm (e.g., prosthetic material, indwelling device, etc.)"}, + "current_response": {"type": "string", "description": "Current response to antibiotics (e.g., improving, stable, worsening)"}, + "creatinine_clearance": {"type": "string", "description": "Current creatinine clearance or renal function"}, + "severity_of_infection": {"type": "string", "description": "Severity of infection (e.g., mild, moderate, severe, septic shock)"}, + "known_allergies": {"type": "string", "description": "Known drug allergies (comma-separated or free text)"} + }, + "required": ["culture", "meds", "site_of_infection", "risk_of_biofilm", "current_response", "creatinine_clearance", "severity_of_infection", "known_allergies"] + } + + async def run( + self, + culture: str, + meds: str, + site_of_infection: str, + risk_of_biofilm: str, + current_response: str, + creatinine_clearance: str, + severity_of_infection: str, + known_allergies: str + ) -> str: + """ + Generate a deescalation recommendation based on all relevant clinical variables. + + Args: + culture (str): Culture & sensitivity results. + meds (str): Current antibiotic regimen. + site_of_infection (str): Site of infection. + risk_of_biofilm (str): Risk or presence of biofilm. + current_response (str): Current response to antibiotics. + creatinine_clearance (str): Renal function. + severity_of_infection (str): Severity of infection. + + Returns: + str: The deescalation recommendation (placeholder). + """ + prompt: str = load_prompt( + "deescalation.j2", + culture=culture, + meds=meds, + site_of_infection=site_of_infection, + risk_of_biofilm=risk_of_biofilm, + current_response=current_response, + creatinine_clearance=creatinine_clearance, + severity_of_infection=severity_of_infection, + known_allergies=known_allergies + ) + # Placeholder for LLM call + return ( + f"Deescalation recommendation for: {culture}, {meds}, " + f"Site: {site_of_infection}, Biofilm: {risk_of_biofilm}, Response: {current_response}, " + f"CrCl: {creatinine_clearance}, Severity: {severity_of_infection}, Allergies: {known_allergies}" + ) diff --git a/tools/recommend_empiric_therapy.py b/tools/recommend_empiric_therapy.py new file mode 100644 index 0000000000000000000000000000000000000000..2147ae7ccf3999b33c9e62221b570fc865168f2c --- /dev/null +++ b/tools/recommend_empiric_therapy.py @@ -0,0 +1,84 @@ +from tools.base import Tool + + +from tools.utils import load_prompt, ToolExecutionError, logger +from typing import Any + +class RecommendEmpiricTherapyTool(Tool): + + def openai_spec(self, legacy=False): + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + """ + Tool to recommend empiric antibiotic therapy based on a patient's profile. + + This tool uses patient age, allergies, and laboratory results to generate a prompt + for an LLM or rules engine to recommend empiric therapy. + """ + def __init__(self) -> None: + """ + Initialize the RecommendEmpiricTherapyTool with its name, description, and argument schema. + """ + super().__init__() + self.name = "recommend_empiric_therapy" + self.description = ( + "Recommend empiric antibiotic therapy based on patient profile." + ) + self.args_schema = { + "type": "object", + "properties": { + "age": {"type": "string", "description": "Patient age or age bracket"}, + "allergies": {"type": "string", "description": "List of known drug allergies"}, + "labs": {"type": "string", "description": "Relevant laboratory results"}, + "culture": {"type": "string", "description": "Culture & sensitivity results"}, + "meds": {"type": "string", "description": "Current antibiotic regimen"}, + "site_of_infection": {"type": "string", "description": "Site of infection (e.g., lung, urine, blood, etc.)"}, + "risk_of_biofilm": {"type": "string", "description": "Risk or presence of biofilm (e.g., prosthetic material, indwelling device, etc.)"}, + "current_response": {"type": "string", "description": "Current response to antibiotics (e.g., improving, stable, worsening)"}, + "creatinine_clearance": {"type": "string", "description": "Current creatinine clearance or renal function"}, + "severity_of_infection": {"type": "string", "description": "Severity of infection (e.g., mild, moderate, severe, septic shock)"}, + }, + "required": ["age", "allergies", "labs", "culture", "meds", "site_of_infection", "risk_of_biofilm", "current_response", "creatinine_clearance", "severity_of_infection"] + } + + async def run(self, age: str, allergies: str, labs: str, + culture: str, meds: str, site_of_infection: str, risk_of_biofilm: str, + current_response: str, creatinine_clearance: str, severity_of_infection: str) -> str: + """ + Generate an empiric therapy recommendation based on patient data. + + Args: + age (str): Patient age or age bracket. + allergies (str): List of known drug allergies. + labs (str): Relevant laboratory results. + + Returns: + str: The empiric therapy recommendation (placeholder). + """ + try: + prompt: str = load_prompt( + "empiric_therapy.j2", + age=age, + allergies=allergies, + labs=labs, + culture=culture, + meds=meds, + site_of_infection=site_of_infection, + risk_of_biofilm=risk_of_biofilm, + current_response=current_response, + creatinine_clearance=creatinine_clearance, + severity_of_infection=severity_of_infection + ) + # Placeholder for LLM call + return f"Empiric therapy recommendation for: {age}, {allergies}, {labs}, {culture}, {meds}, {site_of_infection}, {risk_of_biofilm}, {current_response}, {creatinine_clearance}, {severity_of_infection}" + except Exception as e: + logger.error(f"EmpiricTherapyTool failed: {e}", exc_info=True) + raise ToolExecutionError( + message=f"EmpiricTherapyTool failed: {e}", + code="EMPIRIC_THERAPY_ERROR", + user_message="Unable to generate empiric therapy recommendation. Please try again or contact support.", + original_exception=e + ) diff --git a/tools/recommend_isolation_precautions.py b/tools/recommend_isolation_precautions.py new file mode 100644 index 0000000000000000000000000000000000000000..fd308ee8cffaeabee619bcf3174a7171cbb88e1b --- /dev/null +++ b/tools/recommend_isolation_precautions.py @@ -0,0 +1,67 @@ +from tools.base import Tool + +from tools.utils import load_prompt + +from typing import Any + +class RecommendIsolationPrecautionsTool(Tool): + + def openai_spec(self, legacy=False): + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + """ + Tool to interpret and recommend appropriate isolation precautions based on diagnosis and clinical context. + + This tool uses diagnosis, symptoms, and pathogen information to generate a prompt for an LLM or rules engine to recommend isolation precautions. + """ + def __init__(self) -> None: + """ + Initialize the RecommendIsolationPrecautionsTool with its name, description, and argument schema. + """ + super().__init__() + self.name = "recommend_isolation_precautions" + self.description = ( + "Interpret and recommend appropriate isolation precautions based on diagnosis." + ) + self.args_schema = { + "type": "object", + "properties": { + "diagnosis": {"type": "string", "description": "Patient diagnosis or suspected pathogen"}, + "symptoms": {"type": "string", "description": "Presenting symptoms relevant to transmission risk"}, + "pathogen_list": {"type": "string", "description": "List of known or suspected pathogens"} + }, + "required": ["diagnosis", "symptoms", "pathogen_list"] + } + + async def run(self, diagnosis: str, symptoms: str, pathogen_list: str) -> str: + """ + Generate an isolation precautions recommendation based on diagnosis and clinical context. + + Args: + diagnosis (str): Patient diagnosis or suspected pathogen. + symptoms (str): Presenting symptoms relevant to transmission risk. + pathogen_list (str): List of known or suspected pathogens. + + Returns: + str: The isolation precautions recommendation. + """ + try: + from core.utils.llm_connector import call_llm + + prompt: str = load_prompt( + "isolation_precautions.j2", + diagnosis=diagnosis, + symptoms=symptoms, + pathogen_list=pathogen_list + ) + + # Call LLM with the isolation precautions prompt + recommendation = await call_llm(prompt) + return recommendation + + except Exception as e: + # Fallback to basic recommendations if LLM call fails + return f"Based on diagnosis '{diagnosis}' with pathogens '{pathogen_list}', implement appropriate isolation precautions. Consult your facility's infection control team for specific guidance. Error: {str(e)}" diff --git a/tools/registry.py b/tools/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..0e038716c218585bfd14f24e1abb7435e8742ae3 --- /dev/null +++ b/tools/registry.py @@ -0,0 +1,42 @@ +from tools.internet_search import InternetSearchTool +from tools.pubmed_search import PubMedSearchTool +from tools.fhir_patient import FHIRPatientTool +from tools.recommend_deescalation import RecommendDeescalationTool +from tools.recommend_empiric_therapy import RecommendEmpiricTherapyTool +from tools.recommend_isolation_precautions import RecommendIsolationPrecautionsTool +from tools.stewardship_agent import StewardshipAgentTool +from tools.alert_prolonged_antibiotic_use import AlertProlongedAntibioticUseTool +from tools.history_taking import HistoryTakingTool +from tools.nhsn_criteria_evaluator import NHSNCriteriaEvaluatorTool +from tools.ipc_reporting import IPCReportingTool +from tools.suggest_journals_for_submission import SuggestJournalsForSubmissionTool +from tools.format_references import FormatReferencesTool +from tools.retrieve_guidelines import RetrieveGuidelinesTool +from tools.explain_in_layman_language import ExplainInLaymanLanguageTool +from tools.generate_board_exam_question import GenerateBoardExamQuestionTool +from tools.generate_flash_cards import GenerateFlashCardsTool +from tools.create_educational_presentation import CreateEducationalPresentationTool + +from typing import Type, Dict +from tools.base import Tool + +TOOL_REGISTRY: Dict[str, Type[Tool]] = { + "search_internet": InternetSearchTool, + "search_pubmed": PubMedSearchTool, + "synthetic_patient_lookup": FHIRPatientTool, + "recommend_deescalation": RecommendDeescalationTool, + "recommend_empiric_therapy": RecommendEmpiricTherapyTool, + "recommend_isolation_precautions": RecommendIsolationPrecautionsTool, + "invoke_stewardship_agent": StewardshipAgentTool, + "alert_prolonged_antibiotic_use": AlertProlongedAntibioticUseTool, + "history_taking": HistoryTakingTool, + "NHSN_criteria_evaluator": NHSNCriteriaEvaluatorTool, + "IPC_reporting": IPCReportingTool, + "suggest_journals_for_submission": SuggestJournalsForSubmissionTool, + "format_references": FormatReferencesTool, + "retrieve_guidelines": RetrieveGuidelinesTool, + "explain_in_layman_language": ExplainInLaymanLanguageTool, + "generate_board_exam_question": GenerateBoardExamQuestionTool, + "generate_flash_cards": GenerateFlashCardsTool, + "create_educational_presentation": CreateEducationalPresentationTool, +} diff --git a/tools/retrieve_guidelines.py b/tools/retrieve_guidelines.py new file mode 100644 index 0000000000000000000000000000000000000000..669d9f1f60649c874e3caa91429066f508ff5099 --- /dev/null +++ b/tools/retrieve_guidelines.py @@ -0,0 +1,541 @@ +""" +retrieve_guidelines.py +---------------------- + +Tool for retrieving clinical practice guidelines, with focus on IDSA (Infectious Diseases Society of America) guidelines. + +This tool searches for and retrieves the most current clinical guidelines based on user queries about specific +infectious disease topics, conditions, or pathogens. It leverages internet search to find official IDSA +guidelines and extracts key recommendations, treatment algorithms, and clinical guidance. + +Key Features: +- Searches official IDSA website and trusted medical sources +- Filters results by relevance to specific infectious disease topics +- Extracts key recommendations and treatment guidance +- Provides proper citations and publication dates +- Handles multiple guideline topics (pneumonia, UTI, sepsis, etc.) +""" + +import asyncio +import re +from typing import Any, Dict, List, Union +from tools.base import Tool +from tools.utils import ToolExecutionError, logger + +class RetrieveGuidelinesTool(Tool): + """ + Tool for retrieving clinical practice guidelines, with focus on IDSA guidelines. + + This tool searches for current IDSA guidelines based on user queries about specific + infectious disease conditions, pathogens, or clinical scenarios. + """ + + def __init__(self) -> None: + """Initialize the RetrieveGuidelinesTool.""" + super().__init__() + self.name = "retrieve_guidelines" + self.description = "Retrieve clinical practice guidelines for specific infectious disease topics, conditions, or pathogens, with focus on IDSA guidelines." + self.args_schema = { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "The infectious disease topic, condition, or pathogen to search for (e.g., 'pneumonia', 'UTI', 'sepsis', 'MRSA', 'C. difficile')" + }, + "specific_focus": { + "type": "string", + "description": "Optional: Specific aspect of the topic (e.g., 'treatment', 'diagnosis', 'prophylaxis', 'pediatric')", + "default": "" + } + }, + "required": ["topic"] + } + + def openai_spec(self, legacy=False): + """Return OpenAI function specification.""" + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + + async def run( + self, + topic: str, + specific_focus: str = "" + ) -> Union[List[Dict[str, Any]], Dict[str, Any]]: + """ + Retrieve the latest IDSA guidelines for the specified topic. + + Args: + topic (str): The infectious disease topic to search for + specific_focus (str, optional): Specific aspect to focus on + + Returns: + Union[List[Dict[str, Any]], Dict[str, Any]]: Guidelines information or error dict + """ + try: + # Import internet search tool + from tools.internet_search import InternetSearchTool + internet_tool = InternetSearchTool() + + # Construct search queries for IDSA guidelines + search_queries = self._build_search_queries(topic, specific_focus) + + guidelines_data = [] + + for query in search_queries: + try: + # Search for guidelines + search_results = await internet_tool.run(query) + + # Parse the string response into structured data + if isinstance(search_results, str): + parsed_results = self._parse_search_results(search_results) + # Filter and process results + relevant_guidelines = self._filter_idsa_guidelines(parsed_results, topic) + guidelines_data.extend(relevant_guidelines) + elif isinstance(search_results, list): + # Handle list format (if returned) + relevant_guidelines = self._filter_idsa_guidelines(search_results, topic) + guidelines_data.extend(relevant_guidelines) + + except Exception as e: + logger.warning(f"Search failed for query '{query}': {e}") + continue + + # Remove duplicates and sort by relevance + guidelines_data = self._deduplicate_and_rank(guidelines_data, topic) + + if not guidelines_data: + # Fallback: try broader search for general treatment guidelines + fallback_queries = [ + f"site:idsociety.org {topic} treatment", + f"site:idsociety.org {topic} management", + f"site:idsociety.org {topic} clinical", + f"IDSA {topic} therapy" + ] + + for query in fallback_queries: + try: + search_results = await internet_tool.run(query) + if isinstance(search_results, str): + parsed_results = self._parse_search_results(search_results) + relevant_guidelines = self._filter_idsa_guidelines(parsed_results, topic) + guidelines_data.extend(relevant_guidelines) + elif isinstance(search_results, list): + relevant_guidelines = self._filter_idsa_guidelines(search_results, topic) + guidelines_data.extend(relevant_guidelines) + except Exception as e: + continue + + guidelines_data = self._deduplicate_and_rank(guidelines_data, topic) + + if not guidelines_data: + return { + "error": f"No IDSA guidelines found for topic: {topic}", + "suggestion": "Try searching for broader terms like 'infectious diseases', 'antimicrobial therapy', or specific pathogens. Note: IDSA may not have specific guidelines for all conditions.", + "topic": topic, + "guidelines": [], + "note": "This search is limited to official IDSA guidelines only. For tuberculosis, IDSA may refer to CDC or WHO guidelines as the primary authorities." + } + + # Extract key information from top results + processed_guidelines = self._extract_guideline_info(guidelines_data[:3], topic) + + # Generate a summary that answers the user's question + question_summary = self._generate_question_summary(processed_guidelines, topic, specific_focus) + + return { + "topic": topic, + "specific_focus": specific_focus, + "guidelines_found": len(processed_guidelines), + "question_summary": question_summary, + "guidelines": processed_guidelines, + "search_timestamp": "2025-07-18", + "source": "IDSA (Infectious Diseases Society of America)" + } + + except Exception as e: + logger.error(f"RetrieveGuidelinesTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"Failed to retrieve guidelines: {e}") + + def _build_search_queries(self, topic: str, specific_focus: str) -> List[str]: + """Build comprehensive search queries for IDSA guidelines.""" + queries = [] + + # Map common terms to more specific medical terms + topic_mapping = { + 'tuberculosis': ['tuberculosis', 'TB', 'mycobacterium tuberculosis', 'pulmonary tuberculosis'], + 'pneumonia': ['pneumonia', 'community-acquired pneumonia', 'CAP', 'hospital-acquired pneumonia'], + 'sepsis': ['sepsis', 'severe sepsis', 'septic shock', 'bloodstream infection'], + 'meningitis': ['meningitis', 'bacterial meningitis', 'CNS infection'], + 'endocarditis': ['endocarditis', 'infective endocarditis', 'valve infection'], + 'uti': ['urinary tract infection', 'UTI', 'cystitis', 'pyelonephritis'] + } + + # Get all variations of the topic + topic_variations = topic_mapping.get(topic.lower(), [topic]) + + # Primary IDSA-specific queries + for variation in topic_variations: + queries.extend([ + f"IDSA guidelines {variation}", + f"IDSA clinical practice guidelines {variation}", + f"Infectious Diseases Society of America {variation} guidelines", + f"IDSA {variation} treatment guidelines", + f"IDSA {variation} management recommendations", + f"site:idsociety.org {variation} guidelines" + ]) + + # Add specific focus if provided + if specific_focus: + for variation in topic_variations: + queries.extend([ + f"IDSA guidelines {variation} {specific_focus}", + f"IDSA {variation} {specific_focus} recommendations" + ]) + + # Add broader searches for less common conditions + if topic.lower() in ['tuberculosis', 'tb']: + queries.extend([ + "IDSA mycobacterial infections guidelines", + "IDSA tuberculosis screening guidelines", + "IDSA latent tuberculosis treatment", + "site:idsociety.org tuberculosis guidelines", + "site:idsociety.org TB guidelines", + "site:idsociety.org mycobacterium tuberculosis" + ]) + + # Add year-specific searches for latest guidelines + current_year = 2025 + for year in [current_year, current_year-1, current_year-2]: + queries.append(f"IDSA {topic} guidelines {year}") + + return queries[:15] # Limit to 15 most relevant queries + + def _parse_search_results(self, search_results_str: str) -> List[Dict]: + """Parse the formatted search results string into structured data.""" + results = [] + + # Split by entries (each entry starts with **) + entries = re.split(r'\*\*([^*]+)\*\*', search_results_str) + + for i in range(1, len(entries), 2): # Skip first empty entry, then take every other + if i + 1 < len(entries): + title = entries[i].strip() + content_and_link = entries[i + 1].strip() + + # Extract the link + link_match = re.search(r'\[Read more\]\(([^)]+)\)', content_and_link) + url = link_match.group(1) if link_match else "" + + # Extract the content (everything before the link) + content = re.sub(r'\[Read more\]\([^)]+\)', '', content_and_link).strip() + + if title and url: + results.append({ + 'title': title, + 'url': url, + 'content': content, + 'snippet': content + }) + + return results + + def _filter_idsa_guidelines(self, search_results: List[Dict], topic: str) -> List[Dict]: + """Filter search results to focus ONLY on official IDSA guidelines.""" + filtered_results = [] + + for result in search_results: + url = result.get('url', '').lower() + title = result.get('title', '').lower() + content = result.get('content', '').lower() + + # Check if it's from official IDSA sources ONLY + is_official_idsa = any(domain in url for domain in [ + 'idsociety.org', + 'idsa.org', + 'academic.oup.com/cid' # Clinical Infectious Diseases journal (IDSA's official journal) + ]) + + # Check if it contains IDSA-specific guideline indicators + is_idsa_guideline = any(indicator in title or indicator in content for indicator in [ + 'idsa', 'infectious diseases society of america', 'infectious diseases society', + 'idsa guideline', 'idsa guidelines', 'idsa clinical practice' + ]) + + # Enhanced topic relevance check + topic_keywords = self._get_topic_keywords(topic) + topic_relevant = any(keyword in title or keyword in content for keyword in topic_keywords) + + # Only include if it's from official IDSA source AND contains guideline indicators AND is topic relevant + if topic_relevant and (is_official_idsa or is_idsa_guideline): + result['relevance_score'] = self._calculate_relevance_score(result, topic) + filtered_results.append(result) + + return filtered_results + + def _get_topic_keywords(self, topic: str) -> List[str]: + """Get relevant keywords for topic matching.""" + base_keywords = [topic.lower(), *topic.lower().split()] + + # Add specific synonyms and related terms + keyword_mapping = { + 'tuberculosis': ['tuberculosis', 'tb', 'mycobacterium', 'pulmonary tb', 'latent tb', 'active tb'], + 'pneumonia': ['pneumonia', 'cap', 'hospital-acquired', 'ventilator-associated', 'lung infection'], + 'sepsis': ['sepsis', 'septic shock', 'bloodstream infection', 'bacteremia'], + 'meningitis': ['meningitis', 'cns infection', 'bacterial meningitis', 'brain infection'], + 'endocarditis': ['endocarditis', 'infective endocarditis', 'valve infection', 'heart infection'], + 'uti': ['urinary tract infection', 'uti', 'cystitis', 'pyelonephritis', 'bladder infection'] + } + + if topic.lower() in keyword_mapping: + base_keywords.extend(keyword_mapping[topic.lower()]) + + return base_keywords + + def _calculate_relevance_score(self, result: Dict, topic: str) -> float: + """Calculate relevance score for a search result.""" + score = 0.0 + + url = result.get('url', '').lower() + title = result.get('title', '').lower() + content = result.get('content', '').lower() + + # Official IDSA sources get highest scores + if 'idsociety.org' in url: + score += 20.0 + elif 'idsa.org' in url: + score += 18.0 + elif 'academic.oup.com/cid' in url: + score += 15.0 + + # IDSA-specific terms get high scores + idsa_terms = ['idsa', 'infectious diseases society of america', 'infectious diseases society'] + for term in idsa_terms: + if term in title: + score += 10.0 + elif term in content: + score += 5.0 + + # Guideline-specific terms + guideline_terms = ['guideline', 'guidelines', 'clinical practice', 'recommendations'] + for term in guideline_terms: + if term in title: + score += 8.0 + elif term in content: + score += 4.0 + + # Topic relevance + topic_keywords = self._get_topic_keywords(topic) + for keyword in topic_keywords: + if keyword in title: + score += 6.0 + elif keyword in content: + score += 2.0 + + # Recency indicators + recent_years = ['2025', '2024', '2023', '2022', '2021'] + for year in recent_years: + if year in title or year in content: + score += 2.0 + break + + return score + + def _deduplicate_and_rank(self, guidelines_data: List[Dict], topic: str) -> List[Dict]: + """Remove duplicates and rank guidelines by relevance.""" + # Remove duplicates based on URL + seen_urls = set() + unique_guidelines = [] + + for guideline in guidelines_data: + url = guideline.get('url', '') + if url not in seen_urls: + seen_urls.add(url) + unique_guidelines.append(guideline) + + # Sort by relevance score + unique_guidelines.sort(key=lambda x: x.get('relevance_score', 0), reverse=True) + + return unique_guidelines + + def _extract_guideline_info(self, guidelines_data: List[Dict], topic: str) -> List[Dict]: + """Extract key information from guideline search results.""" + processed_guidelines = [] + + for guideline in guidelines_data: + try: + # Extract key information + title = guideline.get('title', '') + url = guideline.get('url', '') + content = guideline.get('content', '') + + # Extract publication year + pub_year = self._extract_publication_year(title, content) + + # Extract key recommendations + recommendations = self._extract_recommendations(content) + + # Extract authors/organization + authors = self._extract_authors(content) + + processed_guideline = { + 'title': title, + 'url': url, + 'publication_year': pub_year, + 'authors': authors, + 'key_recommendations': recommendations, + 'relevance_score': guideline.get('relevance_score', 0), + 'summary': self._generate_summary(content, topic) + } + + processed_guidelines.append(processed_guideline) + + except Exception as e: + logger.warning(f"Failed to process guideline: {e}") + continue + + return processed_guidelines + + def _extract_publication_year(self, title: str, content: str) -> str: + """Extract publication year from title or content.""" + # Look for years in title first + year_pattern = r'\b(20\d{2})\b' + + for text in [title, content]: + matches = re.findall(year_pattern, text) + if matches: + # Return the most recent year found + return max(matches) + + return "Unknown" + + def _extract_recommendations(self, content: str) -> List[str]: + """Extract key recommendations from guideline content.""" + recommendations = [] + + # Look for common recommendation patterns + recommendation_patterns = [ + r'recommend[s]?\s+([^.]+)', + r'should\s+([^.]+)', + r'we\s+recommend\s+([^.]+)', + r'grade\s+[AB]\s+recommendation[:\s]+([^.]+)', + r'strong\s+recommendation[:\s]+([^.]+)' + ] + + for pattern in recommendation_patterns: + matches = re.findall(pattern, content, re.IGNORECASE) + recommendations.extend(matches[:3]) # Limit to top 3 per pattern + + # Clean up recommendations + cleaned_recommendations = [] + for rec in recommendations: + cleaned = rec.strip() + if len(cleaned) > 20 and len(cleaned) < 200: # Reasonable length + cleaned_recommendations.append(cleaned) + + return cleaned_recommendations[:5] # Return top 5 recommendations + + def _extract_authors(self, content: str) -> str: + """Extract authors or organization from content.""" + # Look for IDSA or author patterns + author_patterns = [ + r'infectious\s+diseases\s+society\s+of\s+america', + r'idsa', + r'authored?\s+by\s+([^.]+)', + r'committee\s+([^.]+)' + ] + + for pattern in author_patterns: + match = re.search(pattern, content, re.IGNORECASE) + if match: + if 'idsa' in pattern or 'infectious' in pattern: + return "Infectious Diseases Society of America (IDSA)" + else: + return match.group(1).strip() + + return "IDSA" + + def _generate_summary(self, content: str, topic: str) -> str: + """Generate a brief summary of the guideline.""" + # Extract first few sentences that mention the topic + sentences = content.split('.') + relevant_sentences = [] + + for sentence in sentences[:10]: # Check first 10 sentences + if topic.lower() in sentence.lower(): + relevant_sentences.append(sentence.strip()) + if len(relevant_sentences) >= 2: + break + + if relevant_sentences: + return '. '.join(relevant_sentences) + '.' + else: + # Return first sentence if no topic-specific content found + return sentences[0].strip() + '.' if sentences else "IDSA clinical practice guideline." + + def _generate_question_summary(self, guidelines: List[Dict], topic: str, specific_focus: str) -> str: + """Generate a concise summary that answers the user's question based on the guidelines found.""" + if not guidelines: + return f"No IDSA guidelines found specifically addressing {topic}." + + # Build the summary based on the specific focus or general topic + if specific_focus: + question_context = f"{topic} {specific_focus}" + else: + question_context = topic + + # Extract key information from the guidelines + key_points = [] + recommendations = [] + + for guideline in guidelines: + # Get key recommendations + guideline_recs = guideline.get('key_recommendations', []) + recommendations.extend(guideline_recs[:2]) # Take top 2 from each guideline + + # Extract key points from summary + summary = guideline.get('summary', '') + if summary and len(summary) > 20: + key_points.append(summary) + + # Build the summary + summary_parts = [] + + # Start with context + summary_parts.append(f"Based on IDSA guidelines for {question_context}:") + + # Add key recommendations if available + if recommendations: + summary_parts.append("\n**Key Recommendations:**") + for i, rec in enumerate(recommendations[:3], 1): # Limit to top 3 + summary_parts.append(f"{i}. {rec.strip()}") + + # Add general guidance from guidelines + if key_points: + summary_parts.append(f"\n**Clinical Guidance:**") + # Combine and summarize key points + combined_guidance = ' '.join(key_points[:2]) # Use first 2 summaries + # Extract most relevant sentences + sentences = combined_guidance.split('.') + relevant_sentences = [s.strip() for s in sentences if len(s.strip()) > 30][:2] + for sentence in relevant_sentences: + if sentence: + summary_parts.append(f"β€’ {sentence}.") + + # Add specific guidance based on common scenarios + if topic.lower() in ['tuberculosis', 'tb']: + if 'quantiferon' in (specific_focus or '').lower() or 'igra' in (specific_focus or '').lower(): + summary_parts.append(f"\n**For undetermined IGRA/QuantiFERON results:** Consider clinical risk factors, repeat testing, or alternative diagnostic approaches as outlined in the guidelines.") + + # Combine all parts + full_summary = '\n'.join(summary_parts) + + # Ensure summary is not too long + if len(full_summary) > 500: + # Truncate and add ellipsis + full_summary = full_summary[:497] + "..." + + return full_summary diff --git a/tools/retrieve_latest_idsa_guidelines.py b/tools/retrieve_latest_idsa_guidelines.py new file mode 100644 index 0000000000000000000000000000000000000000..d6ed020a80a4784abc3d9e1f32a6d7bf499a6fac --- /dev/null +++ b/tools/retrieve_latest_idsa_guidelines.py @@ -0,0 +1,473 @@ +""" +retrieve_guidelines.py +---------------------- + +Tool for retrieving clinical practice guidelines, with focus on IDSA (Infectious Diseases Society of America) guidelines. + +This tool searches for and retrieves the most current clinical guidelines based on user queries about specific +infectious disease topics, conditions, or pathogens. It leverages internet search to find official IDSA +guidelines and extracts key recommendations, treatment algorithms, and clinical guidance. + +Key Features: +- Searches official IDSA website and trusted medical sources +- Filters results by relevance to specific infectious disease topics +- Extracts key recommendations and treatment guidance +- Provides proper citations and publication dates +- Handles multiple guideline topics (pneumonia, UTI, sepsis, etc.) +""" + +import asyncio +import re +from typing import Any, Dict, List, Union +from tools.base import Tool +from tools.utils import ToolExecutionError, logger + +class RetrieveGuidelinesTool(Tool): + """ + Tool for retrieving clinical practice guidelines, with focus on IDSA guidelines. + + This tool searches for current IDSA guidelines based on user queries about specific + infectious disease conditions, pathogens, or clinical scenarios. + """ + + def __init__(self) -> None: + """Initialize the RetrieveGuidelinesTool.""" + super().__init__() + self.name = "retrieve_guidelines" + self.description = "Retrieve clinical practice guidelines for specific infectious disease topics, conditions, or pathogens, with focus on IDSA guidelines." + self.args_schema = { + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "The infectious disease topic, condition, or pathogen to search for (e.g., 'pneumonia', 'UTI', 'sepsis', 'MRSA', 'C. difficile')" + }, + "specific_focus": { + "type": "string", + "description": "Optional: Specific aspect of the topic (e.g., 'treatment', 'diagnosis', 'prophylaxis', 'pediatric')", + "default": "" + } + }, + "required": ["topic"] + } + + def openai_spec(self, legacy=False): + """Return OpenAI function specification.""" + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + + async def run( + self, + topic: str, + specific_focus: str = "" + ) -> Union[List[Dict[str, Any]], Dict[str, Any]]: + """ + Retrieve the latest IDSA guidelines for the specified topic. + + Args: + topic (str): The infectious disease topic to search for + specific_focus (str, optional): Specific aspect to focus on + + Returns: + Union[List[Dict[str, Any]], Dict[str, Any]]: Guidelines information or error dict + """ + try: + # Import internet search tool + from tools.internet_search import InternetSearchTool + internet_tool = InternetSearchTool() + + # Construct search queries for IDSA guidelines + search_queries = self._build_search_queries(topic, specific_focus) + + guidelines_data = [] + + for query in search_queries: + try: + # Search for guidelines + search_results = await internet_tool.run(query) + + # Parse the string response into structured data + if isinstance(search_results, str): + parsed_results = self._parse_search_results(search_results) + # Filter and process results + relevant_guidelines = self._filter_idsa_guidelines(parsed_results, topic) + guidelines_data.extend(relevant_guidelines) + elif isinstance(search_results, list): + # Handle list format (if returned) + relevant_guidelines = self._filter_idsa_guidelines(search_results, topic) + guidelines_data.extend(relevant_guidelines) + + except Exception as e: + logger.warning(f"Search failed for query '{query}': {e}") + continue + + # Remove duplicates and sort by relevance + guidelines_data = self._deduplicate_and_rank(guidelines_data, topic) + + if not guidelines_data: + # Fallback: try broader search for general treatment guidelines + fallback_queries = [ + f"site:idsociety.org {topic} treatment", + f"site:idsociety.org {topic} management", + f"site:idsociety.org {topic} clinical", + f"IDSA {topic} therapy" + ] + + for query in fallback_queries: + try: + search_results = await internet_tool.run(query) + if isinstance(search_results, str): + parsed_results = self._parse_search_results(search_results) + relevant_guidelines = self._filter_idsa_guidelines(parsed_results, topic) + guidelines_data.extend(relevant_guidelines) + elif isinstance(search_results, list): + relevant_guidelines = self._filter_idsa_guidelines(search_results, topic) + guidelines_data.extend(relevant_guidelines) + except Exception as e: + continue + + guidelines_data = self._deduplicate_and_rank(guidelines_data, topic) + + if not guidelines_data: + return { + "error": f"No IDSA guidelines found for topic: {topic}", + "suggestion": "Try searching for broader terms like 'infectious diseases', 'antimicrobial therapy', or specific pathogens. Note: IDSA may not have specific guidelines for all conditions.", + "topic": topic, + "guidelines": [], + "note": "This search is limited to official IDSA guidelines only. For tuberculosis, IDSA may refer to CDC or WHO guidelines as the primary authorities." + } + + # Extract key information from top results + processed_guidelines = self._extract_guideline_info(guidelines_data[:5], topic) + + return { + "topic": topic, + "specific_focus": specific_focus, + "guidelines_found": len(processed_guidelines), + "guidelines": processed_guidelines, + "search_timestamp": "2025-07-18", + "source": "IDSA (Infectious Diseases Society of America)" + } + + except Exception as e: + logger.error(f"RetrieveGuidelinesTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"Failed to retrieve guidelines: {e}") + + def _build_search_queries(self, topic: str, specific_focus: str) -> List[str]: + """Build comprehensive search queries for IDSA guidelines.""" + queries = [] + + # Map common terms to more specific medical terms + topic_mapping = { + 'tuberculosis': ['tuberculosis', 'TB', 'mycobacterium tuberculosis', 'pulmonary tuberculosis'], + 'pneumonia': ['pneumonia', 'community-acquired pneumonia', 'CAP', 'hospital-acquired pneumonia'], + 'sepsis': ['sepsis', 'severe sepsis', 'septic shock', 'bloodstream infection'], + 'meningitis': ['meningitis', 'bacterial meningitis', 'CNS infection'], + 'endocarditis': ['endocarditis', 'infective endocarditis', 'valve infection'], + 'uti': ['urinary tract infection', 'UTI', 'cystitis', 'pyelonephritis'] + } + + # Get all variations of the topic + topic_variations = topic_mapping.get(topic.lower(), [topic]) + + # Primary IDSA-specific queries + for variation in topic_variations: + queries.extend([ + f"IDSA guidelines {variation}", + f"IDSA clinical practice guidelines {variation}", + f"Infectious Diseases Society of America {variation} guidelines", + f"IDSA {variation} treatment guidelines", + f"IDSA {variation} management recommendations", + f"site:idsociety.org {variation} guidelines" + ]) + + # Add specific focus if provided + if specific_focus: + for variation in topic_variations: + queries.extend([ + f"IDSA guidelines {variation} {specific_focus}", + f"IDSA {variation} {specific_focus} recommendations" + ]) + + # Add broader searches for less common conditions + if topic.lower() in ['tuberculosis', 'tb']: + queries.extend([ + "IDSA mycobacterial infections guidelines", + "IDSA tuberculosis screening guidelines", + "IDSA latent tuberculosis treatment", + "site:idsociety.org tuberculosis guidelines", + "site:idsociety.org TB guidelines", + "site:idsociety.org mycobacterium tuberculosis" + ]) + + # Add year-specific searches for latest guidelines + current_year = 2025 + for year in [current_year, current_year-1, current_year-2]: + queries.append(f"IDSA {topic} guidelines {year}") + + return queries[:15] # Limit to 15 most relevant queries + + def _parse_search_results(self, search_results_str: str) -> List[Dict]: + """Parse the formatted search results string into structured data.""" + results = [] + + # Split by entries (each entry starts with **) + entries = re.split(r'\*\*([^*]+)\*\*', search_results_str) + + for i in range(1, len(entries), 2): # Skip first empty entry, then take every other + if i + 1 < len(entries): + title = entries[i].strip() + content_and_link = entries[i + 1].strip() + + # Extract the link + link_match = re.search(r'\[Read more\]\(([^)]+)\)', content_and_link) + url = link_match.group(1) if link_match else "" + + # Extract the content (everything before the link) + content = re.sub(r'\[Read more\]\([^)]+\)', '', content_and_link).strip() + + if title and url: + results.append({ + 'title': title, + 'url': url, + 'content': content, + 'snippet': content + }) + + return results + + def _filter_idsa_guidelines(self, search_results: List[Dict], topic: str) -> List[Dict]: + """Filter search results to focus ONLY on official IDSA guidelines.""" + filtered_results = [] + + for result in search_results: + url = result.get('url', '').lower() + title = result.get('title', '').lower() + content = result.get('content', '').lower() + + # Check if it's from official IDSA sources ONLY + is_official_idsa = any(domain in url for domain in [ + 'idsociety.org', + 'idsa.org', + 'academic.oup.com/cid' # Clinical Infectious Diseases journal (IDSA's official journal) + ]) + + # Check if it contains IDSA-specific guideline indicators + is_idsa_guideline = any(indicator in title or indicator in content for indicator in [ + 'idsa', 'infectious diseases society of america', 'infectious diseases society', + 'idsa guideline', 'idsa guidelines', 'idsa clinical practice' + ]) + + # Enhanced topic relevance check + topic_keywords = self._get_topic_keywords(topic) + topic_relevant = any(keyword in title or keyword in content for keyword in topic_keywords) + + # Only include if it's from official IDSA source AND contains guideline indicators AND is topic relevant + if topic_relevant and (is_official_idsa or is_idsa_guideline): + result['relevance_score'] = self._calculate_relevance_score(result, topic) + filtered_results.append(result) + + return filtered_results + + def _get_topic_keywords(self, topic: str) -> List[str]: + """Get relevant keywords for topic matching.""" + base_keywords = [topic.lower(), *topic.lower().split()] + + # Add specific synonyms and related terms + keyword_mapping = { + 'tuberculosis': ['tuberculosis', 'tb', 'mycobacterium', 'pulmonary tb', 'latent tb', 'active tb'], + 'pneumonia': ['pneumonia', 'cap', 'hospital-acquired', 'ventilator-associated', 'lung infection'], + 'sepsis': ['sepsis', 'septic shock', 'bloodstream infection', 'bacteremia'], + 'meningitis': ['meningitis', 'cns infection', 'bacterial meningitis', 'brain infection'], + 'endocarditis': ['endocarditis', 'infective endocarditis', 'valve infection', 'heart infection'], + 'uti': ['urinary tract infection', 'uti', 'cystitis', 'pyelonephritis', 'bladder infection'] + } + + if topic.lower() in keyword_mapping: + base_keywords.extend(keyword_mapping[topic.lower()]) + + return base_keywords + + def _calculate_relevance_score(self, result: Dict, topic: str) -> float: + """Calculate relevance score for a search result.""" + score = 0.0 + + url = result.get('url', '').lower() + title = result.get('title', '').lower() + content = result.get('content', '').lower() + + # Official IDSA sources get highest scores + if 'idsociety.org' in url: + score += 20.0 + elif 'idsa.org' in url: + score += 18.0 + elif 'academic.oup.com/cid' in url: + score += 15.0 + + # IDSA-specific terms get high scores + idsa_terms = ['idsa', 'infectious diseases society of america', 'infectious diseases society'] + for term in idsa_terms: + if term in title: + score += 10.0 + elif term in content: + score += 5.0 + + # Guideline-specific terms + guideline_terms = ['guideline', 'guidelines', 'clinical practice', 'recommendations'] + for term in guideline_terms: + if term in title: + score += 8.0 + elif term in content: + score += 4.0 + + # Topic relevance + topic_keywords = self._get_topic_keywords(topic) + for keyword in topic_keywords: + if keyword in title: + score += 6.0 + elif keyword in content: + score += 2.0 + + # Recency indicators + recent_years = ['2025', '2024', '2023', '2022', '2021'] + for year in recent_years: + if year in title or year in content: + score += 2.0 + break + + return score + + def _deduplicate_and_rank(self, guidelines_data: List[Dict], topic: str) -> List[Dict]: + """Remove duplicates and rank guidelines by relevance.""" + # Remove duplicates based on URL + seen_urls = set() + unique_guidelines = [] + + for guideline in guidelines_data: + url = guideline.get('url', '') + if url not in seen_urls: + seen_urls.add(url) + unique_guidelines.append(guideline) + + # Sort by relevance score + unique_guidelines.sort(key=lambda x: x.get('relevance_score', 0), reverse=True) + + return unique_guidelines + + def _extract_guideline_info(self, guidelines_data: List[Dict], topic: str) -> List[Dict]: + """Extract key information from guideline search results.""" + processed_guidelines = [] + + for guideline in guidelines_data: + try: + # Extract key information + title = guideline.get('title', '') + url = guideline.get('url', '') + content = guideline.get('content', '') + + # Extract publication year + pub_year = self._extract_publication_year(title, content) + + # Extract key recommendations + recommendations = self._extract_recommendations(content) + + # Extract authors/organization + authors = self._extract_authors(content) + + processed_guideline = { + 'title': title, + 'url': url, + 'publication_year': pub_year, + 'authors': authors, + 'key_recommendations': recommendations, + 'relevance_score': guideline.get('relevance_score', 0), + 'summary': self._generate_summary(content, topic) + } + + processed_guidelines.append(processed_guideline) + + except Exception as e: + logger.warning(f"Failed to process guideline: {e}") + continue + + return processed_guidelines + + def _extract_publication_year(self, title: str, content: str) -> str: + """Extract publication year from title or content.""" + # Look for years in title first + year_pattern = r'\b(20\d{2})\b' + + for text in [title, content]: + matches = re.findall(year_pattern, text) + if matches: + # Return the most recent year found + return max(matches) + + return "Unknown" + + def _extract_recommendations(self, content: str) -> List[str]: + """Extract key recommendations from guideline content.""" + recommendations = [] + + # Look for common recommendation patterns + recommendation_patterns = [ + r'recommend[s]?\s+([^.]+)', + r'should\s+([^.]+)', + r'we\s+recommend\s+([^.]+)', + r'grade\s+[AB]\s+recommendation[:\s]+([^.]+)', + r'strong\s+recommendation[:\s]+([^.]+)' + ] + + for pattern in recommendation_patterns: + matches = re.findall(pattern, content, re.IGNORECASE) + recommendations.extend(matches[:3]) # Limit to top 3 per pattern + + # Clean up recommendations + cleaned_recommendations = [] + for rec in recommendations: + cleaned = rec.strip() + if len(cleaned) > 20 and len(cleaned) < 200: # Reasonable length + cleaned_recommendations.append(cleaned) + + return cleaned_recommendations[:5] # Return top 5 recommendations + + def _extract_authors(self, content: str) -> str: + """Extract authors or organization from content.""" + # Look for IDSA or author patterns + author_patterns = [ + r'infectious\s+diseases\s+society\s+of\s+america', + r'idsa', + r'authored?\s+by\s+([^.]+)', + r'committee\s+([^.]+)' + ] + + for pattern in author_patterns: + match = re.search(pattern, content, re.IGNORECASE) + if match: + if 'idsa' in pattern or 'infectious' in pattern: + return "Infectious Diseases Society of America (IDSA)" + else: + return match.group(1).strip() + + return "IDSA" + + def _generate_summary(self, content: str, topic: str) -> str: + """Generate a brief summary of the guideline.""" + # Extract first few sentences that mention the topic + sentences = content.split('.') + relevant_sentences = [] + + for sentence in sentences[:10]: # Check first 10 sentences + if topic.lower() in sentence.lower(): + relevant_sentences.append(sentence.strip()) + if len(relevant_sentences) >= 2: + break + + if relevant_sentences: + return '. '.join(relevant_sentences) + '.' + else: + # Return first sentence if no topic-specific content found + return sentences[0].strip() + '.' if sentences else "IDSA clinical practice guideline." diff --git a/tools/stewardship_agent.py b/tools/stewardship_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..d26b540bbe39c86a19605e485decf3b70a5302ab --- /dev/null +++ b/tools/stewardship_agent.py @@ -0,0 +1,55 @@ +from tools.base import Tool + + +from tools.utils import ToolExecutionError, logger +from typing import Any, List + +class StewardshipAgentTool(Tool): + + def openai_spec(self, legacy=False): + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + """ + Tool to delegate user prompts and selected stewardship skills to the Antimicrobial Stewardship agent. + + This tool receives a user prompt and a list of enabled stewardship skills, and delegates the request accordingly. + """ + def __init__(self) -> None: + """ + Initialize the StewardshipAgentTool with its name and description. + """ + super().__init__() + self.name = "invoke_stewardship_agent" + self.description = ( + "Delegate the user prompt and selected stewardship skills " + "to the Antimicrobial Stewardship agent." + ) + self.args_schema = { + "type": "object", + "properties": { + "user_prompt": {"type": "string", "description": "The original user request."}, + "enabled_skills": {"type": "array", "items": {"type": "string"}, "description": "List of enabled stewardship skills."} + }, + "required": ["user_prompt", "enabled_skills"] + } + + async def run(self, user_prompt: str, enabled_skills: List[str]) -> str: + """ + Delegate the user prompt and selected skills to the stewardship agent. + + Args: + user_prompt (str): The original user request. + enabled_skills (List[str]): List of enabled stewardship skills. + + Returns: + str: The result of the delegation (placeholder). + """ + try: + # Placeholder for actual skill invocation logic + return f"Delegated '{user_prompt}' to skills: {enabled_skills}" + except Exception as e: + logger.error(f"StewardshipAgentTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"StewardshipAgentTool failed: {e}") diff --git a/tools/suggest_journals_for_submission.py b/tools/suggest_journals_for_submission.py new file mode 100644 index 0000000000000000000000000000000000000000..fdd1b8213993a21b46381b389d47490bd1a16494 --- /dev/null +++ b/tools/suggest_journals_for_submission.py @@ -0,0 +1,737 @@ +from tools.base import Tool +from tools.utils import ToolExecutionError, logger +from typing import Any, Dict, List, Union +import re +import json + + +class SuggestJournalsForSubmissionTool(Tool): + + def openai_spec(self, legacy=False): + return { + "name": self.name, + "description": self.description, + "parameters": self.args_schema + } + + """ + Tool to suggest suitable academic journals for manuscript submission based on research content. + + This tool analyzes the title, abstract, and summary of research work and uses internet search + to find journals that publish similar content. It returns journal recommendations with + impact factors, publishers, and topic focus areas. + """ + + def __init__(self) -> None: + """ + Initialize the SuggestJournalsForSubmissionTool with its name, description, and argument schema. + """ + super().__init__() + self.name = "suggest_journals_for_submission" + self.description = ( + "Suggest suitable academic journals for manuscript submission based on research content. " + "Analyzes title, abstract, and research summary to find journals that publish similar work. " + "Returns journal recommendations with impact factors, publishers, and topic focus areas." + ) + self.args_schema = { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Title of the research paper or manuscript" + }, + "abstract": { + "type": "string", + "description": "Abstract or summary of the research work" + }, + "research_area": { + "type": "string", + "description": "Primary research area or field (e.g., infectious diseases, microbiology, epidemiology)" + }, + "study_type": { + "type": "string", + "description": "Type of study (e.g., clinical trial, case study, systematic review, laboratory research)" + }, + "target_audience": { + "type": "string", + "description": "Target audience (e.g., clinicians, researchers, public health professionals)", + "default": "researchers" + } + }, + "required": ["title", "abstract", "research_area"] + } + + async def run( + self, + title: str, + abstract: str, + research_area: str, + study_type: str = "", + target_audience: str = "researchers" + ) -> str: + """ + Suggest suitable journals for manuscript submission based on research content. + + Args: + title (str): Title of the research paper + abstract (str): Abstract or summary of the research + research_area (str): Primary research area or field + study_type (str): Type of study + target_audience (str): Target audience for the research + + Returns: + str: Formatted list of journal recommendations with details + """ + try: + # Extract key terms and concepts from the research content + search_terms = self._extract_key_terms(title, abstract, research_area, study_type) + + # Search for relevant journals using internet search + journal_suggestions = await self._search_for_journals(search_terms, research_area) + + # Format the results for user display + return self._format_journal_recommendations(journal_suggestions, title, research_area) + + except Exception as e: + logger.error(f"SuggestJournalsForSubmissionTool failed: {e}", exc_info=True) + raise ToolExecutionError(f"SuggestJournalsForSubmissionTool failed: {e}") + + def _extract_key_terms(self, title: str, abstract: str, research_area: str, study_type: str) -> List[str]: + """ + Extract key terms and concepts from the research content for journal searching. + """ + # Combine all text for analysis + combined_text = f"{title} {abstract} {research_area} {study_type}".lower() + + # Comprehensive medical/research terms to prioritize across all ID-related fields + medical_terms = [ + # Core ID terms + "antimicrobial", "antibiotic", "infection", "pathogen", "bacteria", "virus", "fungal", "parasitic", + "resistance", "stewardship", "clinical", "epidemiology", "microbiology", "infectious disease", + "outbreak", "surveillance", "treatment", "diagnosis", "therapy", "prevention", "control", + + # Healthcare settings + "hospital", "healthcare", "patient", "medical", "pharmaceutical", "nosocomial", "community-acquired", + "healthcare-associated", "ICU", "emergency", "outpatient", "inpatient", + + # Specific pathogens + "MRSA", "VRE", "ESBL", "CRE", "C. difficile", "candidemia", "aspergillus", "tuberculosis", + "HIV", "hepatitis", "influenza", "COVID", "SARS", "MERS", "zika", "dengue", "malaria", + + # Public health + "public health", "global health", "health policy", "implementation", "quality improvement", + "health systems", "health economics", "cost-effectiveness", "surveillance", "contact tracing", + + # Vaccines + "vaccine", "vaccination", "immunization", "immunology", "immune response", "antibody", + "immunocompromised", "herd immunity", "vaccine hesitancy", "vaccine safety", + + # Laboratory/Diagnostics + "diagnostic", "laboratory", "PCR", "culture", "sensitivity", "susceptibility", "biomarker", + "rapid test", "point-of-care", "molecular", "genomic", "sequencing", "proteomics", + + # Wound care + "wound", "wound healing", "tissue repair", "surgical site infection", "chronic wound", + "diabetic foot", "pressure ulcer", "biofilm", "debridement", "dressing", + + # Travel medicine + "travel medicine", "tropical disease", "neglected tropical disease", "vector-borne", + "malaria", "yellow fever", "typhoid", "traveler's diarrhea", "pre-travel", + + # Pediatric + "pediatric", "children", "infant", "neonatal", "adolescent", "childhood vaccination", + "pediatric HIV", "congenital infection", + + # HIV/STI + "HIV", "AIDS", "antiretroviral", "PrEP", "sexually transmitted", "STI", "STD", + "syphilis", "gonorrhea", "chlamydia", "herpes", "HPV", + + # One Health + "one health", "zoonotic", "zoonosis", "veterinary", "animal health", "environmental health", + "food safety", "antimicrobial use in animals", "wildlife disease", + + # Implementation/Policy + "guideline", "protocol", "best practice", "evidence-based", "clinical decision support", + "quality improvement", "patient safety", "health technology assessment" + ] + + # Extract relevant medical terms present in the text + found_terms = [term for term in medical_terms if term in combined_text] + + # Add research area and study type as key terms + search_terms = [research_area] + if study_type: + search_terms.append(study_type) + + # Add found medical terms + search_terms.extend(found_terms[:5]) # Limit to top 5 to avoid overly long queries + + return search_terms + + async def _search_for_journals(self, search_terms: List[str], research_area: str) -> List[Dict[str, Any]]: + """ + Search the internet for journals that publish work in the specified areas. + """ + from tools.internet_search import InternetSearchTool + + search_tool = InternetSearchTool() + journals_found = [] + + # Search queries for different aspects + search_queries = [ + f"{research_area} journals impact factor publisher", + f"academic journals {' '.join(search_terms[:3])} submission", + f"best journals publish {research_area} research", + f"infectious disease journals {' '.join(search_terms[:2])} scope" + ] + + for query in search_queries: + try: + search_result = await search_tool.run(query, max_results=5) + + if isinstance(search_result, str): + # Parse the search results to extract journal information + parsed_journals = self._parse_journal_info_from_search(search_result, search_terms) + journals_found.extend(parsed_journals) + + except Exception as e: + logger.warning(f"Search query failed: {query}, Error: {e}") + continue + + # Remove duplicates and return top 5 + unique_journals = self._deduplicate_journals(journals_found) + return unique_journals[:5] + + def _parse_journal_info_from_search(self, search_text: str, search_terms: List[str]) -> List[Dict[str, Any]]: + """ + Parse journal information from search results. + """ + journals = [] + + # Comprehensive journal database covering ID, microbiology, stewardship, public health, and related fields + known_journals = { + # High-Impact General Medical Journals + "Nature": {"publisher": "Nature Publishing Group", "estimated_if": "64.8"}, + "Science": {"publisher": "American Association for the Advancement of Science", "estimated_if": "56.9"}, + "The Lancet": {"publisher": "Elsevier", "estimated_if": "202.7"}, + "New England Journal of Medicine": {"publisher": "Massachusetts Medical Society", "estimated_if": "176.1"}, + "JAMA": {"publisher": "American Medical Association", "estimated_if": "157.3"}, + "The Lancet Global Health": {"publisher": "Elsevier", "estimated_if": "34.1"}, + "Nature Medicine": {"publisher": "Nature Publishing Group", "estimated_if": "87.2"}, + + # Core Infectious Diseases Journals + "Clinical Infectious Diseases": {"publisher": "Oxford University Press", "estimated_if": "20.0"}, + "Journal of Infectious Diseases": {"publisher": "Oxford University Press", "estimated_if": "7.9"}, + "The Lancet Infectious Diseases": {"publisher": "Elsevier", "estimated_if": "71.4"}, + "Emerging Infectious Diseases": {"publisher": "Centers for Disease Control and Prevention", "estimated_if": "16.1"}, + "International Journal of Infectious Diseases": {"publisher": "Elsevier", "estimated_if": "4.2"}, + "BMC Infectious Diseases": {"publisher": "BioMed Central", "estimated_if": "3.7"}, + "Infectious Diseases": {"publisher": "Taylor & Francis", "estimated_if": "2.8"}, + "Current Opinion in Infectious Diseases": {"publisher": "Wolters Kluwer", "estimated_if": "4.1"}, + "Expert Review of Anti-infective Therapy": {"publisher": "Taylor & Francis", "estimated_if": "4.8"}, + + # Antimicrobial/Stewardship Journals + "Antimicrobial Agents and Chemotherapy": {"publisher": "American Society for Microbiology", "estimated_if": "5.8"}, + "Journal of Antimicrobial Chemotherapy": {"publisher": "Oxford University Press", "estimated_if": "6.9"}, + "International Journal of Antimicrobial Agents": {"publisher": "Elsevier", "estimated_if": "6.3"}, + "Antibiotics": {"publisher": "MDPI", "estimated_if": "5.4"}, + "Journal of Global Antimicrobial Resistance": {"publisher": "Elsevier", "estimated_if": "4.1"}, + "Antimicrobial Resistance & Infection Control": {"publisher": "BioMed Central", "estimated_if": "4.8"}, + "JAC-Antimicrobial Resistance": {"publisher": "Oxford University Press", "estimated_if": "3.9"}, + + # Infection Prevention & Control + "Infection Control and Hospital Epidemiology": {"publisher": "Cambridge University Press", "estimated_if": "4.2"}, + "Journal of Hospital Infection": {"publisher": "Elsevier", "estimated_if": "4.9"}, + "American Journal of Infection Control": {"publisher": "Elsevier", "estimated_if": "3.8"}, + "Journal of Infection Prevention": {"publisher": "SAGE Publications", "estimated_if": "2.1"}, + "Infection Prevention in Practice": {"publisher": "Elsevier", "estimated_if": "2.5"}, + + # Microbiology Journals + "Nature Microbiology": {"publisher": "Nature Publishing Group", "estimated_if": "31.8"}, + "mBio": {"publisher": "American Society for Microbiology", "estimated_if": "7.9"}, + "Journal of Clinical Microbiology": {"publisher": "American Society for Microbiology", "estimated_if": "6.1"}, + "Applied and Environmental Microbiology": {"publisher": "American Society for Microbiology", "estimated_if": "4.8"}, + "Frontiers in Microbiology": {"publisher": "Frontiers Media", "estimated_if": "6.1"}, + "Microorganisms": {"publisher": "MDPI", "estimated_if": "4.5"}, + "Clinical Microbiology Reviews": {"publisher": "American Society for Microbiology", "estimated_if": "36.8"}, + "Journal of Medical Microbiology": {"publisher": "Microbiology Society", "estimated_if": "2.9"}, + "Diagnostic Microbiology and Infectious Disease": {"publisher": "Elsevier", "estimated_if": "3.0"}, + "European Journal of Clinical Microbiology & Infectious Diseases": {"publisher": "Springer", "estimated_if": "4.1"}, + "Medical Mycology": {"publisher": "Oxford University Press", "estimated_if": "3.0"}, + "Journal of Fungi": {"publisher": "MDPI", "estimated_if": "5.4"}, + + # HIV/AIDS Journals + "Journal of Acquired Immune Deficiency Syndromes": {"publisher": "Wolters Kluwer", "estimated_if": "3.2"}, + "AIDS": {"publisher": "Wolters Kluwer", "estimated_if": "4.6"}, + "The Lancet HIV": {"publisher": "Elsevier", "estimated_if": "15.8"}, + "Journal of the International AIDS Society": {"publisher": "Wiley", "estimated_if": "5.0"}, + "Current HIV/AIDS Reports": {"publisher": "Springer", "estimated_if": "4.5"}, + "AIDS Research and Human Retroviruses": {"publisher": "Mary Ann Liebert", "estimated_if": "2.5"}, + "HIV Medicine": {"publisher": "Wiley", "estimated_if": "3.1"}, + "Journal of HIV/AIDS & Social Services": {"publisher": "Taylor & Francis", "estimated_if": "1.8"}, + + # Public Health & Epidemiology + "The Lancet Public Health": {"publisher": "Elsevier", "estimated_if": "50.9"}, + "American Journal of Public Health": {"publisher": "American Public Health Association", "estimated_if": "9.9"}, + "American Journal of Epidemiology": {"publisher": "Oxford University Press", "estimated_if": "6.4"}, + "Epidemiology": {"publisher": "Wolters Kluwer", "estimated_if": "4.9"}, + "International Journal of Epidemiology": {"publisher": "Oxford University Press", "estimated_if": "9.7"}, + "Epidemiology and Infection": {"publisher": "Cambridge University Press", "estimated_if": "3.0"}, + "BMC Public Health": {"publisher": "BioMed Central", "estimated_if": "4.5"}, + "Global Health Action": {"publisher": "Taylor & Francis", "estimated_if": "3.8"}, + "PLOS Global Public Health": {"publisher": "Public Library of Science", "estimated_if": "4.2"}, + "Journal of Public Health Policy": {"publisher": "Palgrave Macmillan", "estimated_if": "2.4"}, + "Health Affairs": {"publisher": "Project HOPE", "estimated_if": "8.6"}, + + # Vaccine Journals + "Vaccine": {"publisher": "Elsevier", "estimated_if": "4.3"}, + "Human Vaccines & Immunotherapeutics": {"publisher": "Taylor & Francis", "estimated_if": "4.1"}, + "Vaccines": {"publisher": "MDPI", "estimated_if": "4.9"}, + "Expert Review of Vaccines": {"publisher": "Taylor & Francis", "estimated_if": "5.9"}, + "Vaccine: X": {"publisher": "Elsevier", "estimated_if": "3.5"}, + "NPJ Vaccines": {"publisher": "Nature Publishing Group", "estimated_if": "9.3"}, + "Clinical and Vaccine Immunology": {"publisher": "American Society for Microbiology", "estimated_if": "3.8"}, + + # Travel Medicine & Tropical Diseases + "Journal of Travel Medicine": {"publisher": "Oxford University Press", "estimated_if": "6.9"}, + "Travel Medicine and Infectious Disease": {"publisher": "Elsevier", "estimated_if": "6.6"}, + "Tropical Medicine & International Health": {"publisher": "Wiley", "estimated_if": "3.5"}, + "The American Journal of Tropical Medicine and Hygiene": {"publisher": "American Society of Tropical Medicine and Hygiene", "estimated_if": "2.9"}, + "Transactions of The Royal Society of Tropical Medicine and Hygiene": {"publisher": "Oxford University Press", "estimated_if": "2.1"}, + "PLOS Neglected Tropical Diseases": {"publisher": "Public Library of Science", "estimated_if": "4.0"}, + + # Wound Care & Tissue Repair + "Wound Repair and Regeneration": {"publisher": "Wiley", "estimated_if": "3.8"}, + "Wounds": {"publisher": "HMP Global", "estimated_if": "1.9"}, + "International Wound Journal": {"publisher": "Wiley", "estimated_if": "2.8"}, + "Journal of Wound Care": {"publisher": "Mark Allen Healthcare", "estimated_if": "1.5"}, + "Advances in Wound Care": {"publisher": "Mary Ann Liebert", "estimated_if": "4.2"}, + "Wound Medicine": {"publisher": "Elsevier", "estimated_if": "2.1"}, + "Journal of Tissue Viability": {"publisher": "Elsevier", "estimated_if": "2.5"}, + + # Pediatric Infectious Diseases + "Pediatric Infectious Disease Journal": {"publisher": "Wolters Kluwer", "estimated_if": "3.2"}, + "Journal of the Pediatric Infectious Diseases Society": {"publisher": "Oxford University Press", "estimated_if": "2.8"}, + "Current Opinion in Pediatric Infectious Diseases": {"publisher": "Wolters Kluwer", "estimated_if": "2.5"}, + + # Sexually Transmitted Infections + "Sexually Transmitted Infections": {"publisher": "BMJ Publishing Group", "estimated_if": "4.8"}, + "Sexually Transmitted Diseases": {"publisher": "Wolters Kluwer", "estimated_if": "3.2"}, + "International Journal of STD & AIDS": {"publisher": "SAGE Publications", "estimated_if": "1.8"}, + + # Laboratory Medicine & Diagnostics + "Clinical Chemistry": {"publisher": "Oxford University Press", "estimated_if": "8.3"}, + "Journal of Clinical Laboratory Analysis": {"publisher": "Wiley", "estimated_if": "2.9"}, + "Clinical Microbiology and Infection": {"publisher": "Elsevier", "estimated_if": "6.4"}, + "Point of Care": {"publisher": "Wolters Kluwer", "estimated_if": "2.1"}, + "Clinica Chimica Acta": {"publisher": "Elsevier", "estimated_if": "3.8"}, + + # Veterinary & One Health + "One Health": {"publisher": "Elsevier", "estimated_if": "7.5"}, + "EcoHealth": {"publisher": "Springer", "estimated_if": "3.8"}, + "Journal of Veterinary Internal Medicine": {"publisher": "Wiley", "estimated_if": "2.8"}, + "Veterinary Microbiology": {"publisher": "Elsevier", "estimated_if": "3.2"}, + "Preventive Veterinary Medicine": {"publisher": "Elsevier", "estimated_if": "2.9"}, + + # Open Access & Multidisciplinary + "PLOS ONE": {"publisher": "Public Library of Science", "estimated_if": "3.7"}, + "PLOS Medicine": {"publisher": "Public Library of Science", "estimated_if": "13.8"}, + "PLOS Pathogens": {"publisher": "Public Library of Science", "estimated_if": "7.2"}, + "Scientific Reports": {"publisher": "Nature Publishing Group", "estimated_if": "4.6"}, + "Journal of Clinical Medicine": {"publisher": "MDPI", "estimated_if": "4.9"}, + "International Journal of Environmental Research and Public Health": {"publisher": "MDPI", "estimated_if": "4.6"}, + "Pathogens": {"publisher": "MDPI", "estimated_if": "3.7"}, + + # Regional & Specialized Journals + "Journal of Infection and Chemotherapy": {"publisher": "Elsevier", "estimated_if": "4.1"}, + "Asian Pacific Journal of Tropical Medicine": {"publisher": "Wolters Kluwer", "estimated_if": "2.5"}, + "Brazilian Journal of Infectious Diseases": {"publisher": "Elsevier", "estimated_if": "2.8"}, + "International Journal of Infectious Diseases and Therapy": {"publisher": "SciTechnol", "estimated_if": "1.9"}, + "Infection and Drug Resistance": {"publisher": "Dove Medical Press", "estimated_if": "4.2"}, + "Journal of Infection in Developing Countries": {"publisher": "Journal of Infection in Developing Countries", "estimated_if": "1.8"}, + + # Implementation Science & Health Policy + "Implementation Science": {"publisher": "BioMed Central", "estimated_if": "5.4"}, + "Health Policy and Planning": {"publisher": "Oxford University Press", "estimated_if": "3.9"}, + "BMC Health Services Research": {"publisher": "BioMed Central", "estimated_if": "2.8"}, + "Journal of Hospital Medicine": {"publisher": "Wiley", "estimated_if": "2.9"} + } + + # Search for journal mentions in the text + search_text_lower = search_text.lower() + + for journal_name, details in known_journals.items(): + if journal_name.lower() in search_text_lower: + # Calculate relevance score based on search terms + relevance_score = self._calculate_relevance_score(search_text, journal_name, search_terms) + + journal_info = { + "name": journal_name, + "publisher": details["publisher"], + "impact_factor": details["estimated_if"], + "relevance_score": relevance_score, + "topics": self._get_journal_topics(journal_name), + "scope": self._get_journal_scope(journal_name) + } + journals.append(journal_info) + + return journals + + def _calculate_relevance_score(self, search_text: str, journal_name: str, search_terms: List[str]) -> float: + """ + Calculate relevance score based on how many search terms appear near the journal name. + """ + search_text_lower = search_text.lower() + journal_name_lower = journal_name.lower() + + # Find the position of the journal name + journal_pos = search_text_lower.find(journal_name_lower) + if journal_pos == -1: + return 0.0 + + # Count search terms within 200 characters of the journal name + context_start = max(0, journal_pos - 100) + context_end = min(len(search_text), journal_pos + len(journal_name) + 100) + context = search_text_lower[context_start:context_end] + + term_matches = sum(1 for term in search_terms if term.lower() in context) + return term_matches / len(search_terms) if search_terms else 0.0 + + def _get_journal_topics(self, journal_name: str) -> str: + """ + Get typical topic areas for known journals. + """ + topic_mapping = { + # Core Infectious Diseases + "Clinical Infectious Diseases": "Clinical infectious diseases, antimicrobial therapy, hospital epidemiology", + "Journal of Infectious Diseases": "Basic and clinical infectious disease research, pathogenesis, immunology", + "The Lancet Infectious Diseases": "High-impact infectious disease research, global health, epidemiology", + "Emerging Infectious Diseases": "Emerging pathogens, outbreak investigations, surveillance, zoonoses", + "International Journal of Infectious Diseases": "Clinical infectious diseases, tropical medicine, travel health", + "BMC Infectious Diseases": "Open access infectious disease research, clinical studies", + "Infectious Diseases": "Clinical infectious diseases, case reports, therapeutic advances", + "Current Opinion in Infectious Diseases": "Expert reviews, current trends in infectious diseases", + "Expert Review of Anti-infective Therapy": "Antimicrobial therapy, drug development, clinical applications", + + # Antimicrobial/Stewardship + "Antimicrobial Agents and Chemotherapy": "Antimicrobial research, drug development, resistance mechanisms", + "Journal of Antimicrobial Chemotherapy": "Antimicrobial therapy, resistance, pharmacokinetics", + "International Journal of Antimicrobial Agents": "Antimicrobial therapy, resistance, pharmacology", + "Antibiotics": "Antibiotic research, development, resistance, stewardship", + "Journal of Global Antimicrobial Resistance": "Antimicrobial resistance surveillance, global health", + "Antimicrobial Resistance & Infection Control": "AMR prevention, stewardship, infection control", + "JAC-Antimicrobial Resistance": "Open access antimicrobial resistance research", + + # Infection Prevention & Control + "Infection Control and Hospital Epidemiology": "Healthcare epidemiology, infection prevention, patient safety", + "Journal of Hospital Infection": "Healthcare-associated infections, infection control, stewardship", + "American Journal of Infection Control": "Infection prevention, control practices, healthcare safety", + "Journal of Infection Prevention": "Evidence-based infection prevention strategies", + "Infection Prevention in Practice": "Practical infection prevention, implementation science", + + # Microbiology + "Nature Microbiology": "High-impact microbiology, molecular mechanisms, microbiome", + "mBio": "Microbial pathogenesis, antimicrobial resistance, microbiome", + "Journal of Clinical Microbiology": "Clinical microbiology, diagnostics, laboratory medicine", + "Applied and Environmental Microbiology": "Environmental microbiology, biotechnology, ecology", + "Frontiers in Microbiology": "Microbial research, infectious diseases, antimicrobial resistance", + "Microorganisms": "Microbiology, infectious agents, host-pathogen interactions", + "Clinical Microbiology Reviews": "Comprehensive reviews in clinical microbiology", + "Journal of Medical Microbiology": "Medical microbiology, pathogenesis, diagnostics", + "Diagnostic Microbiology and Infectious Disease": "Diagnostic methods, clinical microbiology", + "European Journal of Clinical Microbiology & Infectious Diseases": "Clinical microbiology, infectious diseases, epidemiology", + "Medical Mycology": "Fungal infections, mycology, antifungal therapy", + "Journal of Fungi": "Fungal biology, pathogenic fungi, antifungal research", + + # HIV/AIDS + "Journal of Acquired Immune Deficiency Syndromes": "HIV/AIDS research, treatment, prevention", + "AIDS": "HIV/AIDS clinical research, epidemiology, social aspects", + "The Lancet HIV": "High-impact HIV research, global health, prevention", + "Journal of the International AIDS Society": "HIV research, prevention, treatment access", + "Current HIV/AIDS Reports": "HIV/AIDS reviews, current developments", + "AIDS Research and Human Retroviruses": "HIV research, retrovirology, pathogenesis", + "HIV Medicine": "Clinical HIV medicine, treatment, care management", + "Journal of HIV/AIDS & Social Services": "HIV social services, community health", + + # Public Health & Epidemiology + "The Lancet Public Health": "High-impact public health research, global health policy", + "American Journal of Public Health": "Public health research, policy, community health", + "American Journal of Epidemiology": "Epidemiological methods, disease surveillance", + "Epidemiology": "Epidemiological research, methodology, disease patterns", + "International Journal of Epidemiology": "Global epidemiology, population health", + "Epidemiology and Infection": "Infectious disease epidemiology, transmission dynamics", + "BMC Public Health": "Open access public health research", + "Global Health Action": "Global health initiatives, implementation research", + "PLOS Global Public Health": "Open access global public health research", + "Journal of Public Health Policy": "Health policy analysis, implementation", + "Health Affairs": "Health policy, healthcare systems, economics", + + # Vaccines + "Vaccine": "Vaccine development, immunization, vaccine safety", + "Human Vaccines & Immunotherapeutics": "Vaccine research, immunotherapy, clinical trials", + "Vaccines": "Open access vaccine research, development, policy", + "Expert Review of Vaccines": "Vaccine reviews, expert opinions, development", + "Vaccine: X": "Open access vaccine research, novel platforms", + "NPJ Vaccines": "High-impact vaccine research, novel approaches", + "Clinical and Vaccine Immunology": "Vaccine immunology, clinical studies", + + # Travel Medicine & Tropical Diseases + "Journal of Travel Medicine": "Travel health, tropical diseases, prevention", + "Travel Medicine and Infectious Disease": "Travel-related infections, prevention strategies", + "Tropical Medicine & International Health": "Tropical diseases, global health, resource-limited settings", + "The American Journal of Tropical Medicine and Hygiene": "Tropical medicine, parasitology, vector-borne diseases", + "Transactions of The Royal Society of Tropical Medicine and Hygiene": "Tropical medicine, global health research", + "PLOS Neglected Tropical Diseases": "Neglected tropical diseases, drug development", + + # Wound Care + "Wound Repair and Regeneration": "Wound healing, tissue regeneration, therapeutic approaches", + "Wounds": "Clinical wound care, management strategies, case studies", + "International Wound Journal": "Wound care research, healing mechanisms, treatments", + "Journal of Wound Care": "Evidence-based wound care, clinical practice", + "Advances in Wound Care": "Innovative wound care technologies, research", + "Wound Medicine": "Wound pathophysiology, therapeutic interventions", + "Journal of Tissue Viability": "Tissue viability, wound prevention, care strategies", + + # Pediatric Infectious Diseases + "Pediatric Infectious Disease Journal": "Pediatric infections, childhood diseases, vaccines", + "Journal of the Pediatric Infectious Diseases Society": "Pediatric ID research, clinical care", + "Current Opinion in Pediatric Infectious Diseases": "Pediatric ID reviews, expert opinions", + + # Sexually Transmitted Infections + "Sexually Transmitted Infections": "STI research, prevention, public health", + "Sexually Transmitted Diseases": "STD clinical research, epidemiology, treatment", + "International Journal of STD & AIDS": "STI/HIV research, global perspectives", + + # Laboratory Medicine + "Clinical Chemistry": "Clinical laboratory science, diagnostics, biomarkers", + "Journal of Clinical Laboratory Analysis": "Laboratory medicine, diagnostic methods", + "Clinical Microbiology and Infection": "Clinical microbiology, infectious diseases", + "Point of Care": "Point-of-care testing, rapid diagnostics", + "Clinica Chimica Acta": "Clinical chemistry, laboratory medicine", + + # One Health & Veterinary + "One Health": "Interdisciplinary health research, zoonoses, environmental health", + "EcoHealth": "Ecosystem health, zoonotic diseases, environmental factors", + "Journal of Veterinary Internal Medicine": "Veterinary medicine, animal health", + "Veterinary Microbiology": "Veterinary microbiology, animal pathogens", + "Preventive Veterinary Medicine": "Disease prevention in animals, epidemiology", + + # General Medical + "The Lancet": "High-impact clinical research, global health", + "New England Journal of Medicine": "High-impact clinical studies, therapeutic advances", + "JAMA": "Clinical research, public health, medical education", + "Nature Medicine": "Translational medicine, biomedical research", + "The Lancet Global Health": "Global health research, health equity", + + # Open Access + "PLOS ONE": "Multidisciplinary research including infectious diseases and microbiology", + "PLOS Medicine": "Open access clinical research, global health", + "PLOS Pathogens": "Pathogen biology, host-pathogen interactions", + "Scientific Reports": "Multidisciplinary scientific research", + "Journal of Clinical Medicine": "Clinical research across medical specialties including ID", + "International Journal of Environmental Research and Public Health": "Environmental health, public health research", + "Pathogens": "Pathogen research, infectious disease mechanisms", + + # Regional Journals + "Journal of Infection and Chemotherapy": "Infectious diseases, antimicrobial therapy (Asian focus)", + "Asian Pacific Journal of Tropical Medicine": "Tropical medicine, infectious diseases (Asia-Pacific)", + "Brazilian Journal of Infectious Diseases": "Infectious diseases (Latin American focus)", + "Infection and Drug Resistance": "Antimicrobial resistance, drug development", + "Journal of Infection in Developing Countries": "Infectious diseases in resource-limited settings", + + # Implementation Science + "Implementation Science": "Implementation research, evidence-based practice", + "Health Policy and Planning": "Health systems, policy implementation", + "BMC Health Services Research": "Health services research, quality improvement", + "Journal of Hospital Medicine": "Hospital medicine, quality improvement, patient safety" + } + return topic_mapping.get(journal_name, "General medical and scientific research") + + def _get_journal_scope(self, journal_name: str) -> str: + """ + Get the scope and focus of known journals. + """ + scope_mapping = { + # Core ID Journals + "Clinical Infectious Diseases": "Clinical research with immediate relevance to patient care", + "Journal of Infectious Diseases": "Basic science and clinical research in infectious diseases", + "The Lancet Infectious Diseases": "High-impact infectious disease research for global audience", + "Emerging Infectious Diseases": "CDC journal focusing on emerging and re-emerging infections", + "International Journal of Infectious Diseases": "Global infectious disease research with clinical focus", + "BMC Infectious Diseases": "Open access platform for infectious disease research", + + # Antimicrobial/Stewardship + "Antimicrobial Agents and Chemotherapy": "Laboratory and clinical antimicrobial research", + "Journal of Antimicrobial Chemotherapy": "Clinical antimicrobial research and stewardship", + "International Journal of Antimicrobial Agents": "Clinical and laboratory antimicrobial studies", + "Antibiotics": "Comprehensive antibiotic research from bench to bedside", + "Journal of Global Antimicrobial Resistance": "Global surveillance and resistance research", + "Antimicrobial Resistance & Infection Control": "Prevention and control of antimicrobial resistance", + + # Infection Prevention + "Infection Control and Hospital Epidemiology": "Healthcare epidemiology and infection prevention", + "Journal of Hospital Infection": "Hospital infection control and prevention strategies", + "American Journal of Infection Control": "Evidence-based infection prevention practices", + "Journal of Infection Prevention": "Practical infection prevention for healthcare workers", + + # Microbiology + "Nature Microbiology": "High-impact fundamental and applied microbiology research", + "mBio": "Broad scope microbiology with rapid publication", + "Journal of Clinical Microbiology": "Clinical microbiology and laboratory diagnostics", + "Applied and Environmental Microbiology": "Applied microbiology and environmental research", + "Frontiers in Microbiology": "Open access microbiology and infectious disease research", + "Clinical Microbiology Reviews": "Comprehensive reviews in clinical microbiology", + "Medical Mycology": "Medical and veterinary mycology research", + + # HIV/AIDS + "Journal of Acquired Immune Deficiency Syndromes": "Clinical HIV/AIDS research and care", + "AIDS": "Comprehensive HIV/AIDS research from basic to clinical", + "The Lancet HIV": "High-impact HIV research with global health focus", + "Journal of the International AIDS Society": "Global HIV research and advocacy", + "HIV Medicine": "Clinical practice and research in HIV medicine", + + # Public Health + "The Lancet Public Health": "High-impact public health research and policy", + "American Journal of Public Health": "Public health research, policy, and practice", + "American Journal of Epidemiology": "Epidemiological methods and population health", + "Epidemiology and Infection": "Infectious disease epidemiology and control", + "BMC Public Health": "Open access public health research platform", + "Global Health Action": "Global health implementation and policy research", + + # Vaccines + "Vaccine": "Comprehensive vaccine research from development to implementation", + "Human Vaccines & Immunotherapeutics": "Vaccine research and immunotherapy development", + "Vaccines": "Open access vaccine research and policy", + "Expert Review of Vaccines": "Expert analysis of vaccine development and policy", + "NPJ Vaccines": "High-impact vaccine research with novel approaches", + + # Travel Medicine + "Journal of Travel Medicine": "Travel health, pre-travel advice, and tropical diseases", + "Travel Medicine and Infectious Disease": "Travel-related infectious diseases and prevention", + "Tropical Medicine & International Health": "Health research in tropical and resource-limited settings", + "PLOS Neglected Tropical Diseases": "Research on neglected tropical diseases", + + # Wound Care + "Wound Repair and Regeneration": "Basic and clinical research in wound healing", + "International Wound Journal": "Clinical and research aspects of wound care", + "Advances in Wound Care": "Innovative approaches to wound management", + "Journal of Wound Care": "Evidence-based clinical wound care practice", + + # Pediatric ID + "Pediatric Infectious Disease Journal": "Pediatric infectious diseases and immunizations", + "Journal of the Pediatric Infectious Diseases Society": "Pediatric ID research and clinical practice", + + # STI + "Sexually Transmitted Infections": "STI research, prevention, and public health", + "Sexually Transmitted Diseases": "Clinical and epidemiological STD research", + + # Laboratory Medicine + "Clinical Chemistry": "Clinical laboratory science and diagnostic medicine", + "Journal of Clinical Laboratory Analysis": "Laboratory medicine and diagnostic methods", + "Clinical Microbiology and Infection": "Clinical microbiology and infectious diseases", + + # One Health + "One Health": "Interdisciplinary research connecting human, animal, and environmental health", + "EcoHealth": "Ecosystem approaches to health and disease", + + # General Medical + "The Lancet": "High-impact clinical research with global reach", + "New England Journal of Medicine": "Premier clinical research and medical advances", + "JAMA": "Clinical research, public health, and medical education", + "Nature Medicine": "Translational and clinical medical research", + + # Open Access + "PLOS ONE": "Rigorous science across all disciplines with open access", + "PLOS Medicine": "Open access clinical and public health research", + "PLOS Pathogens": "Open access research on pathogenic organisms", + "Scientific Reports": "Multidisciplinary scientific research with open access", + "Journal of Clinical Medicine": "Clinical research across medical specialties", + + # Regional + "Journal of Infection and Chemotherapy": "Infectious diseases with Asian perspective", + "Brazilian Journal of Infectious Diseases": "Infectious diseases in Latin America", + "Infection and Drug Resistance": "Drug resistance research and clinical implications", + + # Implementation Science + "Implementation Science": "Research on implementing evidence-based practices", + "Health Policy and Planning": "Health systems research and policy implementation", + "Journal of Hospital Medicine": "Hospital-based clinical research and quality improvement" + } + return scope_mapping.get(journal_name, "Peer-reviewed research in relevant medical fields") + + def _deduplicate_journals(self, journals: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Remove duplicate journals and sort by relevance score. + """ + seen_journals = set() + unique_journals = [] + + for journal in journals: + journal_name = journal.get("name", "").lower() + if journal_name not in seen_journals: + seen_journals.add(journal_name) + unique_journals.append(journal) + + # Sort by relevance score (descending) + unique_journals.sort(key=lambda x: x.get("relevance_score", 0), reverse=True) + return unique_journals + + def _format_journal_recommendations(self, journals: List[Dict[str, Any]], title: str, research_area: str) -> str: + """ + Format the journal recommendations for user display. + """ + if not journals: + return f""" +## πŸ“š Journal Suggestions for: "{title}" + +**Research Area:** {research_area} + +⚠️ **No specific journal matches found in search results.** + +### General Recommendations: +1. **PLOS ONE** - Multidisciplinary open access journal +2. **BMC Infectious Diseases** - Open access infectious disease research +3. **Frontiers in Microbiology** - Open access microbiology research +4. **Journal of Clinical Medicine** - Broad clinical research scope +5. **Microorganisms** - General microbiology and infectious agents + +πŸ’‘ **Tip:** Consider refining your search terms or consulting journal finder tools from major publishers like Elsevier, Springer, or Wiley. +""" + + result = f""" +## πŸ“š Journal Suggestions for: "{title}" + +**Research Area:** {research_area} + +### 🎯 Recommended Journals (Ranked by Relevance): + +""" + + for i, journal in enumerate(journals, 1): + result += f""" +**{i}. {journal['name']}** +- **Publisher:** {journal['publisher']} +- **Impact Factor:** {journal['impact_factor']} (approximate) +- **Topics:** {journal['topics']} +- **Scope:** {journal['scope']} +- **Relevance Score:** {journal['relevance_score']:.1f}/1.0 + +--- +""" + + result += """ +### πŸ’‘ Additional Tips: +- Check each journal's recent publications to confirm fit +- Review submission guidelines and formatting requirements +- Consider open access policies and publication fees +- Look at editorial board expertise alignment +- Check typical review timelines for your research urgency + +### πŸ” Further Research: +- Use publisher journal finder tools (Elsevier, Springer, Wiley) +- Check Journal Citation Reports for updated impact factors +- Review journal social media and recent highlights +- Consider preprint servers (bioRxiv, medRxiv) for rapid dissemination +""" + + return result diff --git a/tools/utils.py b/tools/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..abd72c02720bddc3231ef4f508391b96d4373857 --- /dev/null +++ b/tools/utils.py @@ -0,0 +1,62 @@ +import logging + +from typing import Optional + +class ToolExecutionError(Exception): + """ + Exception raised when a tool fails to execute as expected. + + Args: + message (str): User-facing error message. + code (str, optional): Machine-readable error code. + user_message (Optional[str], optional): Message safe to show to end users. + original_exception (Optional[Exception], optional): The original exception, if any. + """ + def __init__(self, message: str, code: str = "TOOL_EXECUTION_ERROR", user_message: Optional[str] = None, original_exception: Optional[Exception] = None): + super().__init__(message) + self.message = message + self.code = code + self.user_message = user_message or "An error occurred while executing the tool." + self.original_exception = original_exception + +# Set up a basic logger for the tools package +logger = logging.getLogger("tools") +logger.setLevel(logging.INFO) +handler = logging.StreamHandler() +formatter = logging.Formatter('[%(asctime)s] %(levelname)s %(name)s: %(message)s') +handler.setFormatter(formatter) +if not logger.hasHandlers(): + logger.addHandler(handler) +import json +import re +from jinja2 import Environment, FileSystemLoader + +# Set up Jinja2 environment for prompt templates +env = Environment(loader=FileSystemLoader("prompts")) + +def load_prompt(template_name: str, **vars) -> str: + tpl = env.get_template(template_name) + return tpl.render(**vars) + +def clean_json(text: str) -> dict | list: + """Remove ASCII control bytes and return parsed JSON.""" + safe = re.sub(r"[\x00-\x1F\x7F]", "", text) + return json.loads(safe) + +def extract_fenced_json(raw: str) -> str: + """Get the text between …, or fallback to raw.""" + m = re.search(r"([\s\S]*?)", raw) + return m.group(1).strip() if m else raw + +def safe_parse(raw: str) -> dict | list: + """Strip control chars then json.loads, with fallback.""" + fragment = extract_fenced_json(raw) + clean = re.sub(r"[\x00-\x1F\x7F]", "", fragment) + try: + return json.loads(clean) + except json.JSONDecodeError: + # try to grab first {...} or [...] + m = re.search(r"(\{[\s\S]*\}|\[[\s\S]*\])", clean) + if m: + return json.loads(m.group(1)) + raise