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"]