diff --git a/.env b/.env new file mode 100644 index 000000000..5325bb430 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +GEMINI_API_KEY=Enter Key diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..26d33521a --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/agentic-gemini-ai-react.iml b/.idea/agentic-gemini-ai-react.iml new file mode 100644 index 000000000..8e5446ac9 --- /dev/null +++ b/.idea/agentic-gemini-ai-react.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/googol.iml b/.idea/googol.iml new file mode 100644 index 000000000..d0876a78d --- /dev/null +++ b/.idea/googol.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..f19474821 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,65 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 000000000..105ce2da2 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..d1e22ecb8 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..c325f830f --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..94a25f7f4 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/docs/diagrams/archetecture.txt b/docs/diagrams/archetecture.txt new file mode 100644 index 000000000..252d95c45 --- /dev/null +++ b/docs/diagrams/archetecture.txt @@ -0,0 +1,21 @@ + ┌──────────────┐ + │ TCIA API │ + └──────┬───────┘ + │ + Async Batch Ingestion + │ + ┌──────▼───────┐ + │ DICOM FS │ ← src/data/ + └──────┬───────┘ + │ + Metadata Extraction (no pixels) + │ + ┌──────▼───────┐ + │ Vector DB │ ← FAISS / Chroma + └──────┬───────┘ + │ + Retrieval-Augmented Context + │ + ┌──────▼───────┐ + │ Gemini LLM │ ← ontology reasoning + └──────────────┘ diff --git a/eval/__init__.py b/eval/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/eval/eval_runner.py b/eval/eval_runner.py new file mode 100644 index 000000000..efb8523ad --- /dev/null +++ b/eval/eval_runner.py @@ -0,0 +1,21 @@ +from typing import List + + +class EvalCase: + def __init__(self, prompt, expected_keywords: List[str]): + self.prompt = prompt + self.expected_keywords = expected_keywords + + + class Evaluator: + def run(self, agent, cases: List[EvalCase]): + results = [] + for case in cases: + response = agent(case.prompt) + score = sum(1 for k in case.expected_keywords if k in response.lower()) + results.append({ + "prompt": case.prompt, + "score": score, + "response": response + }) + return results \ No newline at end of file diff --git a/eval/evaluate_rag.py b/eval/evaluate_rag.py new file mode 100644 index 000000000..6ea9edf4c --- /dev/null +++ b/eval/evaluate_rag.py @@ -0,0 +1,16 @@ +# eval/evaluate_rag.py + +import asyncio + +QUERIES = [ + ("lung CT", "CHEST"), + ("brain MRI", "HEAD") +] + +async def evaluate(store): + score = 0 + for query, expected in QUERIES: + results = await store.similarity_search(query) + if expected in str(results[0].metadata): + score += 1 + return score / len(QUERIES) diff --git a/requirements.txt b/requirements.txt index 403af0282..e69de29bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,28 +0,0 @@ -# Core Dependencies -fastapi==0.115.6 -uvicorn[standard]==0.34.0 -streamlit==1.41.1 -python-multipart==0.0.20 - -# Google AI -google-generativeai==0.8.3 -google-cloud-aiplatform==1.75.0 - -# Image Processing -Pillow==11.0.0 -opencv-python==4.10.0.84 - -# Data Handling -pydantic==2.10.5 -pydantic-settings==2.7.0 -python-dotenv==1.0.1 - -# Utilities -aiofiles==24.1.0 -httpx==0.28.1 - -# Development -pytest==8.3.4 -pytest-asyncio==0.24.0 -black==24.10.0 -flake8==7.1.1 diff --git a/src/agent/__pycache__/__init__.cpython-312.pyc b/src/agent/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..c95070104 Binary files /dev/null and b/src/agent/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/agent/__pycache__/agent.cpython-312.pyc b/src/agent/__pycache__/agent.cpython-312.pyc new file mode 100644 index 000000000..1700cbb8f Binary files /dev/null and b/src/agent/__pycache__/agent.cpython-312.pyc differ diff --git a/src/agent/__pycache__/executor.cpython-312.pyc b/src/agent/__pycache__/executor.cpython-312.pyc new file mode 100644 index 000000000..f95372512 Binary files /dev/null and b/src/agent/__pycache__/executor.cpython-312.pyc differ diff --git a/src/agent/__pycache__/planner.cpython-312.pyc b/src/agent/__pycache__/planner.cpython-312.pyc new file mode 100644 index 000000000..64aa4963d Binary files /dev/null and b/src/agent/__pycache__/planner.cpython-312.pyc differ diff --git a/src/agent/agent.py b/src/agent/agent.py new file mode 100644 index 000000000..e0b280f34 --- /dev/null +++ b/src/agent/agent.py @@ -0,0 +1,14 @@ +from core.planner import Planner +from core.executor import Executor +from core.memory import VectorMemory + +class Agent: + def __init__(self): + self.planner = Planner() + self.executor = Executor() + self.memory = VectorMemory() + + def run(self, user_input): + plan = self.planner.create_plan(user_input) + answer, trace = self.executor.execute(plan, self.memory, user_input) + return answer, trace diff --git a/src/agent/coordinator.py b/src/agent/coordinator.py new file mode 100644 index 000000000..fa2098b70 --- /dev/null +++ b/src/agent/coordinator.py @@ -0,0 +1,9 @@ +# src/dicom_agent/agents/coordinator.py +from pipeline.async_ingest import ingest_collection +from tools.dicom_ontology_llm_tool import explain_metadata + + +async def run_pipeline(collection: str, store): + await ingest_collection(collection, store) + results = await store.similarity_search("lung CT") + return await explain_metadata(results[0].metadata) \ No newline at end of file diff --git a/src/agent/executor.py b/src/agent/executor.py new file mode 100644 index 000000000..55dd1ed59 --- /dev/null +++ b/src/agent/executor.py @@ -0,0 +1,28 @@ +# agents/executor.py +class ExecutorAgent: + def __init__(self, tools: dict): + self.tools = tools + self.context = {} + + def resolve_args(self, args: dict): + resolved = {} + for k, v in args.items(): + if isinstance(v, str) and v.startswith("$previous."): + key = v.replace("$previous.", "") + resolved[k] = self.context.get(key) + else: + resolved[k] = v + return resolved + + def execute(self, plan: dict): + for step in plan["steps"]: + tool_name = step["tool"] + tool = self.tools[tool_name] + + args = self.resolve_args(step["args"]) + result = tool.run(**args) + + # Save outputs for downstream steps + self.context.update(result) + + return self.context diff --git a/src/agent/planner.py b/src/agent/planner.py new file mode 100644 index 000000000..bfa79c068 --- /dev/null +++ b/src/agent/planner.py @@ -0,0 +1,42 @@ +# agents/planner.py +class PlannerAgent: + def plan(self, goal: str) -> dict: + """ + Convert a goal into a structured execution plan + """ + # In production, this is an LLM call + return { + "steps": [ + { + "tool": "download_files", + "args": { + "url": "https://example.com/dicom", + "output_dir": "data" + } + }, + { + "tool": "extract_metadata", + "args": { + "files": "$previous.files" + } + }, + { + "tool": "create_vector_store", + "args": { + "metadata": "$previous.metadata" + } + }, + { + "tool": "image_to_dicom", + "args": { + "image_path": "annotated_output.png", + "output_dcm": "annotated_output.dcm", + "patient_name": "DOE^JANE", + "patient_id": "A12345", + "study_description": "AI Annotated Image", + "modality": "OT" + } + } + + ] + } diff --git a/src/annotate_main.py b/src/annotate_main.py new file mode 100644 index 000000000..bb1019fbc --- /dev/null +++ b/src/annotate_main.py @@ -0,0 +1,15 @@ +# main.py +from tools.image_to_dicom_tool import ImageToDICOMTool + + +def main(): + tool = ImageToDICOMTool() + + result = tool.run( + image_path="input.jpg", + output_dcm="output.dcm", + study_description="AI Generated Image" + ) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/core/__pycache__/__init__.cpython-312.pyc b/src/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..a0f43aec4 Binary files /dev/null and b/src/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/core/__pycache__/executor.cpython-312.pyc b/src/core/__pycache__/executor.cpython-312.pyc new file mode 100644 index 000000000..52062cb77 Binary files /dev/null and b/src/core/__pycache__/executor.cpython-312.pyc differ diff --git a/src/core/__pycache__/memory.cpython-312.pyc b/src/core/__pycache__/memory.cpython-312.pyc new file mode 100644 index 000000000..d4bf4a659 Binary files /dev/null and b/src/core/__pycache__/memory.cpython-312.pyc differ diff --git a/src/core/__pycache__/planner.cpython-312.pyc b/src/core/__pycache__/planner.cpython-312.pyc new file mode 100644 index 000000000..e48807400 Binary files /dev/null and b/src/core/__pycache__/planner.cpython-312.pyc differ diff --git a/src/core/executor.py b/src/core/executor.py new file mode 100644 index 000000000..1f7b37080 --- /dev/null +++ b/src/core/executor.py @@ -0,0 +1,36 @@ +from tools.gemini_client import GeminiClient +from tools.search_tool import SearchTool +from utils.logger import logger + +class Executor: + def __init__(self): + self.llm = GeminiClient() + self.search = SearchTool() + + def execute(self, plan, memory, user_input): + reasoning_trace = [] + context = memory.retrieve(user_input) + + for step in plan: + reasoning_trace.append(f"Thought: {step['thought']}") + if step["action"] == "search": + result = self.search.search(user_input) + reasoning_trace.append(f"Observation: {result}") + memory.store(result) + + prompt = f"""You are a ReAct-style agent. + +Context: +{context} + +Reasoning: +{chr(10).join(reasoning_trace)} + +Question: +{user_input} + +Provide final answer. +""" + answer = self.llm.generate(prompt) + memory.store(answer) + return answer, reasoning_trace diff --git a/src/core/memory.py b/src/core/memory.py new file mode 100644 index 000000000..6ddfd61c5 --- /dev/null +++ b/src/core/memory.py @@ -0,0 +1,20 @@ +import faiss +from sentence_transformers import SentenceTransformer + +class VectorMemory: + def __init__(self): + self.model = SentenceTransformer("all-MiniLM-L6-v2") + self.index = faiss.IndexFlatL2(384) + self.texts = [] + + def store(self, text): + emb = self.model.encode([text]) + self.index.add(emb) + self.texts.append(text) + + def retrieve(self, query, k=3): + if self.index.ntotal == 0: + return "" + emb = self.model.encode([query]) + _, idx = self.index.search(emb, k) + return "\n".join(self.texts[i] for i in idx[0]) diff --git a/src/core/planner.py b/src/core/planner.py new file mode 100644 index 000000000..15e81d897 --- /dev/null +++ b/src/core/planner.py @@ -0,0 +1,7 @@ +class Planner: + def create_plan(self, goal): + return [ + {"thought": "User asked a question", "action": "analyze"}, + {"thought": "May need external info", "action": "search"}, + {"thought": "Answer using Gemini", "action": "final"} + ] diff --git a/src/dicom_main.py b/src/dicom_main.py new file mode 100644 index 000000000..8c1c4e1f2 --- /dev/null +++ b/src/dicom_main.py @@ -0,0 +1,29 @@ +# main.py +from agent.planner import PlannerAgent +from agent.executor import ExecutorAgent +from tools.download_tool import DownloadTool +from tools.metadata_tool import MetadataTool +from tools.vector_tool import VectorStoreTool + +def main(): + tools = { + "download_files": DownloadTool(), + "extract_metadata": MetadataTool(), + "create_vector_store": VectorStoreTool(), + } + + planner = PlannerAgent() + executor = ExecutorAgent(tools) + + goal = "Download DICOM files, extract metadata, and build a vector database" + + plan = planner.plan(goal) + result = executor.execute(plan) + + if result: + #print(result) + print(f"Final vector store contains {len(result.get('vector_store', []))} entries.") + + +if __name__ == "__main__": + main() diff --git a/src/llm/__init__.py b/src/llm/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/llm/gemini_client b/src/llm/gemini_client new file mode 100644 index 000000000..e512798fc --- /dev/null +++ b/src/llm/gemini_client @@ -0,0 +1,201 @@ +""" +Gemini LLM client wrapper for agentic AI systems. + +Supports: +- Planning prompts +- Reasoning / execution prompts +- JSON-safe outputs +- Tool-aware prompting +""" + +from typing import Optional, Dict, Any +import json +import os + +try: + import google.generativeai as genai +except ImportError: + genai = None + + +class GeminiClient: + """ + Lightweight Gemini client for agent planners and reasoners. + """ + + def __init__( + self, + model: str = "gemini-1.5-pro", + api_key: Optional[str] = None, + temperature: float = 0.2, + max_output_tokens: int = 2048 + ): + if genai is None: + raise ImportError( + "google-generativeai is not installed. " + "Run: pip install google-generativeai" + ) + + self.api_key = api_key or os.getenv("GEMINI_API_KEY") + if not self.api_key: + raise ValueError("GEMINI_API_KEY is not set") + + genai.configure(api_key=self.api_key) + + self.model_name = model + self.temperature = temperature + self.max_output_tokens = max_output_tokens + + self.model = genai.GenerativeModel( + model_name=self.model_name, + generation_config={ + "temperature": self.temperature, + "max_output_tokens": self.max_output_tokens, + }, + ) + + # ========================================================== + # Core Generation + # ========================================================== + + def generate( + self, + prompt: str, + system_prompt: Optional[str] = None + ) -> str: + """ + Generate raw text output from Gemini. + """ + full_prompt = prompt + if system_prompt: + full_prompt = f"{system_prompt}\n\n{prompt}" + + response = self.model.generate_content(full_prompt) + return response.text.strip() + + # ========================================================== + # Planner Helper + # ========================================================== + + def generate_plan( + self, + goal: str, + tools_schema: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Generate a structured execution plan in JSON. + """ + + system_prompt = ( + "You are a planner agent. " + "Break the user's goal into ordered steps. " + "Each step must call exactly one tool. " + "Output ONLY valid JSON." + ) + + tools_description = "" + if tools_schema: + tools_description = ( + "\nAvailable tools:\n" + + json.dumps(tools_schema, indent=2) + ) + + user_prompt = f""" +Goal: +{goal} + +Create a JSON plan with this structure: + +{{ + "steps": [ + {{ + "tool": "", + "args": {{ ... }} + }} + ] +}} +{tools_description} +""" + + text = self.generate(user_prompt, system_prompt) + return self._safe_json_parse(text) + + # ========================================================== + # Reasoning Helper + # ========================================================== + + def reason( + self, + context: str, + question: str + ) -> str: + """ + Use Gemini for reasoning over retrieved context. + """ + + system_prompt = ( + "You are a reasoning agent. " + "Use the provided context to answer the question. " + "If the answer is not present, say so." + ) + + prompt = f""" +Context: +{context} + +Question: +{question} +""" + + return self.generate(prompt, system_prompt) + + # ========================================================== + # Tool-Calling Helper + # ========================================================== + + def tool_decision( + self, + state: Dict[str, Any], + tools_schema: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Decide the next tool call based on current agent state. + """ + + system_prompt = ( + "You are an execution agent. " + "Based on the current state, decide the next tool call. " + "Return ONLY valid JSON." + ) + + prompt = f""" +Current state: +{json.dumps(state, indent=2)} + +Available tools: +{json.dumps(tools_schema, indent=2)} + +Return JSON: +{{ + "tool": "", + "args": {{ ... }} +}} +""" + + text = self.generate(prompt, system_prompt) + return self._safe_json_parse(text) + + # ========================================================== + # Utilities + # ========================================================== + + def _safe_json_parse(self, text: str) -> Dict[str, Any]: + """ + Safely parse JSON from LLM output. + """ + try: + return json.loads(text) + except json.JSONDecodeError: + raise ValueError( + f"Gemini returned invalid JSON:\n{text}" + ) diff --git a/src/main.py b/src/main.py new file mode 100644 index 000000000..333c39e99 --- /dev/null +++ b/src/main.py @@ -0,0 +1,17 @@ +from agent.agent import Agent + +def main(): + agent = Agent() + print("Gemini ReAct Agent (type exit to quit)") + while True: + q = input("User: ") + if q.lower() in ("exit", "quit"): + break + answer, trace = agent.run(q) + print("\n--- ReAct Trace ---") + for t in trace: + print(t) + print("\nAgent:", answer) + +if __name__ == "__main__": + main() diff --git a/src/memory/__init__.py b/src/memory/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/memory/vector_store.py b/src/memory/vector_store.py new file mode 100644 index 000000000..91c45ba2c --- /dev/null +++ b/src/memory/vector_store.py @@ -0,0 +1,82 @@ +""" +Vector store tool for agentic AI pipelines. + +Wraps memory.vector_store.VectorStore so it can be: +- Planned +- Executed +- Evaluated +- Swapped between backends +""" + +from typing import List, Dict, Any, Optional +from tools.base import Tool +from memory.vector_store import VectorStore + + +class VectorStoreTool(Tool): + """ + Agent-facing vector memory tool. + """ + + name = "vector_store" + description = ( + "Store text embeddings in vector memory and perform similarity search. " + "Supports add_texts and similarity_search actions." + ) + + def __init__(self, embedding_fn): + self.store = VectorStore(embedding_fn) + + # ============================================================== + # Tool Interface + # ============================================================== + + def run(self, action: str, **kwargs) -> Dict[str, Any]: + """ + Entry point for agent execution. + + action: + - add_texts + - similarity_search + - count + - clear + """ + + if action == "add_texts": + return self._add_texts(**kwargs) + + if action == "similarity_search": + return self._similarity_search(**kwargs) + + if action == "count": + return {"count": self.store.count()} + + if action == "clear": + self.store.clear() + return {"status": "cleared"} + + raise ValueError(f"Unsupported vector_store action: {action}") + + # ============================================================== + # Actions + # ============================================================== + + def _add_texts( + self, + texts: List[str], + metadatas: Optional[List[Dict[str, Any]]] = None + ) -> Dict[str, Any]: + return self.store.add_texts( + texts=texts, + metadatas=metadatas + ) + + def _similarity_search( + self, + query: str, + k: int = 5 + ) -> Dict[str, Any]: + return self.store.similarity_search( + query=query, + k=k + ) diff --git a/src/pipeline/__init__.py b/src/pipeline/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pipeline/async_ingest.py b/src/pipeline/async_ingest.py new file mode 100644 index 000000000..d0050b407 --- /dev/null +++ b/src/pipeline/async_ingest.py @@ -0,0 +1,9 @@ +# src/dicom_agent/pipeline/async_ingest.py +import asyncio +from tools.dicom_download_tool import download_dicom_from_tcia +from tools.dicom_vector_index_tool import index_dicom_to_vector_db + + +async def ingest_collection(collection: str, store): + dicoms = await asyncio.to_thread(download_dicom_from_tcia, collection) + await index_dicom_to_vector_db(dicoms, store) \ No newline at end of file diff --git a/src/tools/__pycache__/__init__.cpython-312.pyc b/src/tools/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..4caddb107 Binary files /dev/null and b/src/tools/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/tools/__pycache__/base.cpython-312.pyc b/src/tools/__pycache__/base.cpython-312.pyc new file mode 100644 index 000000000..3b52838ba Binary files /dev/null and b/src/tools/__pycache__/base.cpython-312.pyc differ diff --git a/src/tools/__pycache__/dicom_download_tool.cpython-312.pyc b/src/tools/__pycache__/dicom_download_tool.cpython-312.pyc new file mode 100644 index 000000000..d64c0a3ba Binary files /dev/null and b/src/tools/__pycache__/dicom_download_tool.cpython-312.pyc differ diff --git a/src/tools/__pycache__/dicom_ontology_presenter.cpython-312.pyc b/src/tools/__pycache__/dicom_ontology_presenter.cpython-312.pyc new file mode 100644 index 000000000..2183f9540 Binary files /dev/null and b/src/tools/__pycache__/dicom_ontology_presenter.cpython-312.pyc differ diff --git a/src/tools/__pycache__/download_tool.cpython-312.pyc b/src/tools/__pycache__/download_tool.cpython-312.pyc new file mode 100644 index 000000000..bcab89838 Binary files /dev/null and b/src/tools/__pycache__/download_tool.cpython-312.pyc differ diff --git a/src/tools/__pycache__/gemini_client.cpython-312.pyc b/src/tools/__pycache__/gemini_client.cpython-312.pyc new file mode 100644 index 000000000..ca3388161 Binary files /dev/null and b/src/tools/__pycache__/gemini_client.cpython-312.pyc differ diff --git a/src/tools/__pycache__/metadata_tool.cpython-312.pyc b/src/tools/__pycache__/metadata_tool.cpython-312.pyc new file mode 100644 index 000000000..dc6db78ac Binary files /dev/null and b/src/tools/__pycache__/metadata_tool.cpython-312.pyc differ diff --git a/src/tools/__pycache__/search_tool.cpython-312.pyc b/src/tools/__pycache__/search_tool.cpython-312.pyc new file mode 100644 index 000000000..05de48209 Binary files /dev/null and b/src/tools/__pycache__/search_tool.cpython-312.pyc differ diff --git a/src/tools/__pycache__/vector_tool.cpython-312.pyc b/src/tools/__pycache__/vector_tool.cpython-312.pyc new file mode 100644 index 000000000..ecd6ab6a5 Binary files /dev/null and b/src/tools/__pycache__/vector_tool.cpython-312.pyc differ diff --git a/src/tools/base.py b/src/tools/base.py new file mode 100644 index 000000000..6e0997c32 --- /dev/null +++ b/src/tools/base.py @@ -0,0 +1,11 @@ +# tools/base.py +from typing import Dict, Any +from abc import ABC, abstractmethod + +class Tool(ABC): + name: str + description: str + + @abstractmethod + def run(self, **kwargs) -> Dict[str, Any]: + pass diff --git a/src/tools/dicom_download_tool.py b/src/tools/dicom_download_tool.py new file mode 100644 index 000000000..b011f6383 --- /dev/null +++ b/src/tools/dicom_download_tool.py @@ -0,0 +1,88 @@ +# src/tools/dicom_download_tool.py + +import os +from typing import List +from nbiatoolkit import NBIAClient + +DEFAULT_FILE_PATTERN = "%PatientID/%StudyInstanceUID/%SeriesInstanceUID/%InstanceNumber.dcm" + +def download_dicom_from_tcia( + collection: str, + output_dir: str = "src/data", + n_parallel: int = 5 +) -> List[str]: + """ + Downloads DICOM series from TCIA into src/data directory. + Returns list of downloaded DICOM file paths. + """ + + os.makedirs(output_dir, exist_ok=True) + + downloaded_files = [] + + with NBIAClient(return_type="dataframe") as client: + patients = client.getPatients(Collection=collection) + if patients.empty: + raise RuntimeError(f"No patients found for collection: {collection}") + + # Pick first patient for demo (agent can choose later) + patient = patients.iloc[0] + + series_df = client.getSeries(PatientID=patient.PatientId) + if series_df.empty: + raise RuntimeError("No series found for patient") + + series_uid = series_df.SeriesInstanceUID.iloc[0] + + client.downloadSeries( + series_uid, + output_dir, + DEFAULT_FILE_PATTERN, + n_parallel + ) + + + +def file_generator(DOWNLOAD_PATH, TARGET_COLLECTION): + """ + A generator that yields the full path of every file in a directory + and its subdirectories. + """ + + download_dir = os.path.join(DOWNLOAD_PATH) + print(f"Download Dir: {download_dir}") + + dicom_files = get_all_files_os_walk(download_dir) + print(f"Successfully Found files: {len(dicom_files)}") + + for f in dicom_files: + yield f + + +def get_all_files_os_walk(directory_path): + file_list = [] + for root, directories, files in os.walk(directory_path): + for filename in files: + # Construct the full file path + full_path = os.path.join(root, filename) + if full_path.endswith('.dcm'): + file_list.append(full_path) + return file_list + +def get_all_files_map_os_walk(directory_path): + file_list = {} + for root, directories, files in os.walk(directory_path): + for filename in files: + # Construct the full file path + full_path = os.path.join(root, filename) + if full_path.endswith('.dcm'): + file_list.update({filename: full_path}) + return file_list + +def get_all_files_os_walk_generator(directory_path): + for root, directories, files in os.walk(directory_path): + for filename in files: + # Construct the full file path + full_path = os.path.join(root, filename) + if full_path.endswith('.dcm'): + yield full_path diff --git a/src/tools/dicom_ontology_llm_tool.py b/src/tools/dicom_ontology_llm_tool.py new file mode 100644 index 000000000..310ea73f6 --- /dev/null +++ b/src/tools/dicom_ontology_llm_tool.py @@ -0,0 +1,19 @@ +# src/dicom_agent/tools/dicom_ontology_llm_tool.py +from llm.gemini_client import GeminiClient + + +PROMPT = """ +You are a medical ontology assistant. +Translate the following DICOM metadata into plain English +using anatomy and imaging terminology. + + +Metadata: +{metadata} +""" + + +async def explain_metadata(metadata: dict) -> str: + gemini = GeminiClient() + prompt = PROMPT.format(metadata=metadata) + return await gemini.generate(prompt) \ No newline at end of file diff --git a/src/tools/dicom_ontology_presenter.py b/src/tools/dicom_ontology_presenter.py new file mode 100644 index 000000000..412299e02 --- /dev/null +++ b/src/tools/dicom_ontology_presenter.py @@ -0,0 +1,58 @@ +# src/tools/dicom_ontology_presenter.py + +import pydicom +from typing import Dict, Any + +ONTOLOGY_LOOKUP = { + "CT": "a standard X-ray-based scan (CT scan).", + "MR": "a scan using magnetic fields (MRI).", + "DX": "a digital X-ray image.", + "CHEST": "the chest and lungs.", + "ABDOMEN": "the abdominal cavity.", + "HEAD": "the head and brain.", + "T1": "an MRI sequence highlighting fatty tissue.", +} + +def translate(term: str) -> str: + if term in ONTOLOGY_LOOKUP: + return ONTOLOGY_LOOKUP[term] + for k, v in ONTOLOGY_LOOKUP.items(): + if k in term: + return v + return f"{term} (a specialized medical term)" + +def extract_metadata(ds: pydicom.Dataset) -> Dict[str, Any]: + return { + "PatientID": getattr(ds, "PatientID", "N/A"), + "StudyDate": getattr(ds, "StudyDate", "N/A"), + "Modality": getattr(ds, "Modality", "N/A"), + "BodyPart": getattr(ds, "BodyPartExamined", "N/A"), + "Protocol": getattr(ds, "ProtocolName", + getattr(ds, "SeriesDescription", "Generic Scan")) + } + +def present_dicom_using_ontology(dicom_file: str) -> str: + ds = pydicom.dcmread(dicom_file, stop_before_pixels=True) + meta = extract_metadata(ds) + + return f""" +--- DICOM Image Explanation --- + +Image Type: +• {meta['Modality']} — {translate(meta['Modality'])} + +Anatomy: +• {meta['BodyPart']} — {translate(meta['BodyPart'])} + +Scan Technique: +• {meta['Protocol']} — {translate(meta['Protocol'])} + +Study Date: +• {meta['StudyDate']} + +Patient Context: +• Internal ID: {meta['PatientID']} + +This description translates technical imaging metadata into +human-readable clinical context. +""".strip() diff --git a/src/tools/dicom_vector_index_tool.py b/src/tools/dicom_vector_index_tool.py new file mode 100644 index 000000000..551bcf00e --- /dev/null +++ b/src/tools/dicom_vector_index_tool.py @@ -0,0 +1,20 @@ +# src/dicom_agent/tools/dicom_vector_index_tool.py +import pydicom +from memory.vector_store import VectorStore +from utils.metadata import extract_metadata, metadata_to_text +from tools.dicom_ontology_presenter import present_dicom_using_ontology + + +async def index_dicom_files_to_vector_db(dicom_files: list[str], store: VectorStore): + texts, metadatas = [], [] + for f in dicom_files: + ds = pydicom.dcmread(f, stop_before_pixels=True) + meta = extract_metadata(ds) + ont_meta = present_dicom_using_ontology(f) + texts.append(metadata_to_text(ont_meta)) + metadatas.append({"path": f, **meta}) + + + await store.add_texts(texts, metadatas) + + diff --git a/src/tools/download_tool.py b/src/tools/download_tool.py new file mode 100644 index 000000000..24f050be1 --- /dev/null +++ b/src/tools/download_tool.py @@ -0,0 +1,33 @@ +# tools/download_tool.py +from tools.base import Tool +from tools.dicom_download_tool import download_dicom_from_tcia, get_all_files_os_walk_generator + +class DownloadTool(Tool): + name = "download_files" + description = "Download files from a remote endpoint" + + def __init__(self): + self.TARGET_COLLECTION = 'LGG-18' # A small, publicly accessible collection + self.TARGET_COLLECTION = 'COVID-19-AR' + self.TARGET_COLLECTION = 'TCGA-KIRC' + self.DOWNLOAD_PATH = 'data/tcia_presentation_dat' + + def run(self, url: str, output_dir: str): + # placeholder logic + + download_dicom_from_tcia(collection=self.TARGET_COLLECTION, output_dir=self.DOWNLOAD_PATH) + file_map = self.get_all_files_map_os_walk(directory_path=self.DOWNLOAD_PATH) + return file_map + + + def get_all_files_map_os_walk(self, directory_path): + file_list = [] + + for f in get_all_files_os_walk_generator(directory_path=self.DOWNLOAD_PATH): + file_list.append(f) + + + return { + "status": "success", + "files": file_list + } diff --git a/src/tools/gemini_client.py b/src/tools/gemini_client.py new file mode 100644 index 000000000..6142d2aaf --- /dev/null +++ b/src/tools/gemini_client.py @@ -0,0 +1,10 @@ +import google.generativeai as genai +from utils.config import GEMINI_API_KEY +genai.configure(api_key=GEMINI_API_KEY) + +class GeminiClient: + def __init__(self, model="gemini-1.5-flash"): + self.model = genai.GenerativeModel(model) + + def generate(self, prompt): + return self.model.generate_content(prompt).text diff --git a/src/tools/image_to_dicom.py b/src/tools/image_to_dicom.py new file mode 100644 index 000000000..ea3e83dc4 --- /dev/null +++ b/src/tools/image_to_dicom.py @@ -0,0 +1,133 @@ +""" +Agent tool: Convert an image file into a DICOM (Secondary Capture). + +Use cases: +- AI-generated images → PACS-compatible DICOM +- Annotation overlays → DICOM +- Synthetic data generation +- Healthcare RAG / imaging pipelines +""" + +from typing import Dict, Any +from tools.base import Tool + +import datetime +import numpy as np +from PIL import Image +import pydicom +from pydicom.dataset import Dataset, FileDataset +from pydicom.uid import ( + ExplicitVRLittleEndian, + SecondaryCaptureImageStorage, + generate_uid +) + + +class ImageToDICOMTool(Tool): + """ + Agent-compatible image → DICOM conversion tool. + """ + + name = "image_to_dicom" + description = ( + "Convert a standard image (JPEG/PNG) into a DICOM Secondary Capture file. " + "Useful for AI annotations, synthetic images, and imaging pipelines." + ) + + # ========================================================== + # Tool Entry Point + # ========================================================== + + def run( + self, + image_path: str, + output_dcm: str, + patient_name: str = "DOE^JOHN", + patient_id: str = "123456", + study_description: str = "Image converted to DICOM", + modality: str = "OT" + ) -> Dict[str, Any]: + """ + Convert image to DICOM and save to disk. + """ + + self._image_to_dicom( + image_path=image_path, + output_dcm=output_dcm, + patient_name=patient_name, + patient_id=patient_id, + study_description=study_description, + modality=modality + ) + + return { + "status": "success", + "output_dcm": output_dcm, + "patient_id": patient_id, + "modality": modality + } + + # ========================================================== + # Core Logic + # ========================================================== + + def _image_to_dicom( + self, + image_path: str, + output_dcm: str, + patient_name: str, + patient_id: str, + study_description: str, + modality: str + ): + # Load image + img = Image.open(image_path).convert("RGB") + pixel_array = np.array(img) + + # ---- File Meta ---- + file_meta = Dataset() + file_meta.MediaStorageSOPClassUID = SecondaryCaptureImageStorage + file_meta.MediaStorageSOPInstanceUID = generate_uid() + file_meta.TransferSyntaxUID = ExplicitVRLittleEndian + file_meta.ImplementationClassUID = generate_uid() + + # ---- DICOM Dataset ---- + ds = FileDataset( + output_dcm, + {}, + file_meta=file_meta, + preamble=b"\0" * 128 + ) + + # ---- Patient / Study ---- + ds.PatientName = patient_name + ds.PatientID = patient_id + ds.StudyInstanceUID = generate_uid() + ds.SeriesInstanceUID = generate_uid() + ds.SOPInstanceUID = file_meta.MediaStorageSOPInstanceUID + ds.SOPClassUID = file_meta.MediaStorageSOPClassUID + + ds.Modality = modality + ds.StudyDescription = study_description + ds.SeriesDescription = "Image-to-DICOM Conversion" + + now = datetime.datetime.now() + ds.StudyDate = now.strftime("%Y%m%d") + ds.StudyTime = now.strftime("%H%M%S") + + # ---- Image Tags ---- + ds.Rows, ds.Columns = pixel_array.shape[:2] + ds.SamplesPerPixel = 3 + ds.PhotometricInterpretation = "RGB" + ds.PlanarConfiguration = 0 + ds.BitsAllocated = 8 + ds.BitsStored = 8 + ds.HighBit = 7 + ds.PixelRepresentation = 0 + + ds.PixelData = pixel_array.tobytes() + + # ---- Save ---- + ds.is_little_endian = True + ds.is_implicit_VR = False + ds.save_as(output_dcm) diff --git a/src/tools/metadata_tool.py b/src/tools/metadata_tool.py new file mode 100644 index 000000000..15c593e80 --- /dev/null +++ b/src/tools/metadata_tool.py @@ -0,0 +1,17 @@ +# tools/metadata_tool.py +from tools.base import Tool +from tools.dicom_ontology_presenter import present_dicom_using_ontolog + +class MetadataTool(Tool): + name = "extract_metadata" + description = "Extract metadata from DICOM files" + + def run(self, files: list): + + return { + "status": "success", + "metadata": [ + {"file": f, "patient_id": "123", "modality": "CT"} + for f in files + ] + } diff --git a/src/tools/search_tool.py b/src/tools/search_tool.py new file mode 100644 index 000000000..267993c5d --- /dev/null +++ b/src/tools/search_tool.py @@ -0,0 +1,6 @@ +import requests + +class SearchTool: + def search(self, query): + url = f"https://duckduckgo.com/?q={query}&format=json" + return f"Search results for: {query} (mocked)" diff --git a/src/tools/test.py b/src/tools/test.py new file mode 100644 index 000000000..e96ee0057 --- /dev/null +++ b/src/tools/test.py @@ -0,0 +1 @@ +def download_dicom_from_tcia(*args, **kwargs): return [] \ No newline at end of file diff --git a/src/tools/vector_tool.py b/src/tools/vector_tool.py new file mode 100644 index 000000000..a083b9de6 --- /dev/null +++ b/src/tools/vector_tool.py @@ -0,0 +1,15 @@ +# tools/vector_tool.py +from tools.base import Tool +from tools.dicom_vector_index_tool import VectorStore + +class VectorStoreTool(Tool): + name = "create_vector_store" + description = "Create vector embeddings from metadata" + + def run(self, metadata: list): + + + return { + "status": "success", + "vector_db": "vector_db_id_abc" + } diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/utils/__pycache__/__init__.cpython-312.pyc b/src/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 000000000..d99249b3e Binary files /dev/null and b/src/utils/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/utils/__pycache__/config.cpython-312.pyc b/src/utils/__pycache__/config.cpython-312.pyc new file mode 100644 index 000000000..5cf657c6f Binary files /dev/null and b/src/utils/__pycache__/config.cpython-312.pyc differ diff --git a/src/utils/__pycache__/logger.cpython-312.pyc b/src/utils/__pycache__/logger.cpython-312.pyc new file mode 100644 index 000000000..1607456d9 Binary files /dev/null and b/src/utils/__pycache__/logger.cpython-312.pyc differ diff --git a/src/utils/config.py b/src/utils/config.py new file mode 100644 index 000000000..2d1151fbb --- /dev/null +++ b/src/utils/config.py @@ -0,0 +1,6 @@ +import os +from dotenv import load_dotenv +load_dotenv() +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") +if not GEMINI_API_KEY: + raise RuntimeError("Missing GEMINI_API_KEY") diff --git a/src/utils/logger.py b/src/utils/logger.py new file mode 100644 index 000000000..3a039e852 --- /dev/null +++ b/src/utils/logger.py @@ -0,0 +1,3 @@ +import logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s") +logger = logging.getLogger("agentic-react") diff --git a/src/utils/metadata.py b/src/utils/metadata.py new file mode 100644 index 000000000..008ad291a --- /dev/null +++ b/src/utils/metadata.py @@ -0,0 +1,149 @@ +""" +Metadata extraction and normalization tool for agentic AI pipelines. + +Features: +- DICOM metadata extraction (via pydicom) +- Generic file metadata fallback +- Text normalization for LLMs / embeddings / RAG +""" + +from typing import List, Dict, Any +from pathlib import Path +from datetime import datetime + +try: + import pydicom +except ImportError: + pydicom = None + +from tools.base import Tool + + +class MetadataTool(Tool): + """ + Agent-compatible metadata extraction tool. + """ + + name = "extract_metadata" + description = ( + "Extract structured metadata from DICOM or generic files and " + "optionally convert it into LLM-friendly text." + ) + + # ============================================================== + # Public Agent Methods + # ============================================================== + + def run(self, files: List[str]) -> Dict[str, Any]: + """ + Required Tool interface. + """ + metadata = self.extract_metadata(files) + + return { + "status": "success", + "metadata": metadata, + "count": len(metadata), + "extracted_at": datetime.utcnow().isoformat() + } + + def extract_metadata(self, files: List[str]) -> List[Dict[str, Any]]: + """ + Extract structured metadata from a list of files. + """ + results = [] + + for file_path in files: + path = Path(file_path) + + if not path.exists(): + results.append({ + "file": file_path, + "error": "File not found" + }) + continue + + if path.suffix.lower() == ".dcm": + results.append(self._extract_dicom_metadata(path)) + else: + results.append(self._extract_generic_metadata(path)) + + return results + + def metadata_to_text(self, metadata: List[Dict[str, Any]]) -> List[str]: + """ + Convert structured metadata into natural language text suitable + for embeddings, RAG, or LLM reasoning. + """ + texts = [] + + for item in metadata: + if "error" in item: + texts.append( + f"File {item.get('file')} encountered an error: {item.get('error')}" + ) + continue + + lines = [] + + for key, value in item.items(): + if value is None or key in {"file", "type"}: + continue + lines.append(f"{key.replace('_', ' ')}: {value}") + + summary = f"Metadata for {item.get('file')}:\n" + "; ".join(lines) + texts.append(summary) + + return texts + + # ============================================================== + # Internal Helpers + # ============================================================== + + def _extract_dicom_metadata(self, path: Path) -> Dict[str, Any]: + if pydicom is None: + return { + "file": str(path), + "type": "dicom", + "error": "pydicom not installed" + } + + try: + ds = pydicom.dcmread(str(path), stop_before_pixels=True) + + return { + "file": str(path), + "type": "dicom", + "patient_id": getattr(ds, "PatientID", None), + "patient_sex": getattr(ds, "PatientSex", None), + "patient_birth_date": getattr(ds, "PatientBirthDate", None), + "study_instance_uid": getattr(ds, "StudyInstanceUID", None), + "series_instance_uid": getattr(ds, "SeriesInstanceUID", None), + "study_date": getattr(ds, "StudyDate", None), + "modality": getattr(ds, "Modality", None), + "body_part_examined": getattr(ds, "BodyPartExamined", None), + "manufacturer": getattr(ds, "Manufacturer", None), + "institution_name": getattr(ds, "InstitutionName", None), + "rows": getattr(ds, "Rows", None), + "columns": getattr(ds, "Columns", None) + } + + except Exception as e: + return { + "file": str(path), + "type": "dicom", + "error": str(e) + } + + def _extract_generic_metadata(self, path: Path) -> Dict[str, Any]: + stat = path.stat() + + return { + "file": str(path), + "type": "generic", + "filename": path.name, + "extension": path.suffix, + "size_bytes": stat.st_size, + "created_at": datetime.fromtimestamp(stat.st_ctime).isoformat(), + "modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat() + } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 000000000..d1987657c --- /dev/null +++ b/tests/test_basic.py @@ -0,0 +1,4 @@ + +def test_import(): + import dicom_agent + assert True diff --git a/tests/test_ontology_explanation.py b/tests/test_ontology_explanation.py new file mode 100644 index 000000000..606c1f893 --- /dev/null +++ b/tests/test_ontology_explanation.py @@ -0,0 +1,15 @@ +# tests/test_ontology_explanation.py + +import asyncio +from tools.dicom_ontology_llm_tool import explain_metadata + +async def test_explanation_guardrails(): + metadata = { + "Modality": "CT", + "BodyPart": "CHEST" + } + + explanation = await explain_metadata(metadata) + + assert "CT" in explanation + assert "diagnostic" not in explanation.lower() # guardrail diff --git a/tests/test_vector_index.py b/tests/test_vector_index.py new file mode 100644 index 000000000..a0954e6e3 --- /dev/null +++ b/tests/test_vector_index.py @@ -0,0 +1,19 @@ +# tests/test_vector_index.py + +import asyncio +from memory.vector_store import VectorStore +from tools.dicom_vector_index_tool import index_dicom_to_vector_db + +async def test_indexing_pipeline(tmp_path): + store = VectorStore(dim=384) + + # fake DICOM metadata text + texts = ["CT Chest Lung Nodule"] + metas = [{"Modality": "CT", "BodyPart": "CHEST"}] + + await store.add_texts(texts, metas) + + results = await store.similarity_search("lung CT") + + assert len(results) > 0 + assert "CT" in results[0].metadata["Modality"]