Spaces:
Runtime error
Runtime error
Create agent.py
Browse files
agent.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import string
|
| 3 |
+
import re
|
| 4 |
+
import datetime
|
| 5 |
+
import base64
|
| 6 |
+
import pandas as pd
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import os
|
| 9 |
+
import logging
|
| 10 |
+
from langchain_core.tools import tool
|
| 11 |
+
from langchain_groq import ChatGroq
|
| 12 |
+
from langgraph.prebuilt import create_react_agent
|
| 13 |
+
from langgraph.checkpoint.memory import MemorySaver
|
| 14 |
+
|
| 15 |
+
import cv2
|
| 16 |
+
import numpy as np
|
| 17 |
+
from PIL import Image
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
# Global variables
|
| 22 |
+
_SESSIONS = None
|
| 23 |
+
_CURRENT_THREAD_ID = None
|
| 24 |
+
_API_CLIENT = None
|
| 25 |
+
_RETRIEVER = None
|
| 26 |
+
|
| 27 |
+
def set_session_storage(sessions_dict: dict) -> None:
|
| 28 |
+
global _SESSIONS
|
| 29 |
+
_SESSIONS = sessions_dict
|
| 30 |
+
|
| 31 |
+
def set_current_thread_id(thread_id: str) -> None:
|
| 32 |
+
global _CURRENT_THREAD_ID
|
| 33 |
+
_CURRENT_THREAD_ID = thread_id
|
| 34 |
+
|
| 35 |
+
def set_api_client(api_client) -> None:
|
| 36 |
+
global _API_CLIENT
|
| 37 |
+
_API_CLIENT = api_client
|
| 38 |
+
|
| 39 |
+
def get_latest_image(thread_id: str) -> dict | None:
|
| 40 |
+
"""Get latest image from external API"""
|
| 41 |
+
if _API_CLIENT is None:
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
result = _API_CLIENT.get_patient_image(thread_id)
|
| 45 |
+
if result.get('ok') and result.get('base64'):
|
| 46 |
+
# Decode base64 image
|
| 47 |
+
try:
|
| 48 |
+
img_data = base64.b64decode(result['base64'])
|
| 49 |
+
image = Image.open(io.BytesIO(img_data))
|
| 50 |
+
return {"image": image}
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error(f"Error decoding image: {e}")
|
| 53 |
+
return None
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
def _store_image_blobs(blobs: dict) -> None:
|
| 57 |
+
"""Store image blobs in session"""
|
| 58 |
+
if not _SESSIONS or not _CURRENT_THREAD_ID:
|
| 59 |
+
return
|
| 60 |
+
if _CURRENT_THREAD_ID not in _SESSIONS:
|
| 61 |
+
_SESSIONS[_CURRENT_THREAD_ID] = {}
|
| 62 |
+
existing = _SESSIONS[_CURRENT_THREAD_ID].get("_image_blobs", {})
|
| 63 |
+
existing.update(blobs)
|
| 64 |
+
_SESSIONS[_CURRENT_THREAD_ID]["_image_blobs"] = existing
|
| 65 |
+
names = list(_SESSIONS[_CURRENT_THREAD_ID].get("_session_image_names", []))
|
| 66 |
+
for n in blobs.keys():
|
| 67 |
+
if n not in names:
|
| 68 |
+
names.append(n)
|
| 69 |
+
_SESSIONS[_CURRENT_THREAD_ID]["_session_image_names"] = names
|
| 70 |
+
|
| 71 |
+
# System Prompt
|
| 72 |
+
SYSTEM_PROMPT = """You are DermaScan AI, a dermatology assistant on a dataset of 1000 skin cancer patients. Built by Eng. Youssef Bastawisy.
|
| 73 |
+
|
| 74 |
+
IMAGES: handled by tools only, on the image already uploaded this session. NEVER say you can't see/read images. On any image request, call the matching tool immediately:
|
| 75 |
+
- segment_my_image (segment/mask/overlay), reanalyze_my_image (analyze/re-analyze), get_my_uploaded_image (show/view photo)
|
| 76 |
+
No image is ever downloaded from the internet or from a patient_id — matching an uploaded image to a dataset record is handled internally by the tool. NEVER mention how the matching works internally (no "feature vectors", "global_features", "similarity", "threshold", "columns", etc.) — just state plainly whether a matching record was found or not.
|
| 77 |
+
If a tool says no matching record was found (new patient), you MUST say clearly this is a new patient not in the database, and STOP there for anything hereditary/genetic — never mention a mutation, family history, or risk score for them, and never reuse a different (previously matched) patient's name or data for this new image.
|
| 78 |
+
After a tool returns [ANALYSIS_RESULT:{data}], discuss only that data (label, confidence, affected area) — never say "shown"/"displayed".
|
| 79 |
+
|
| 80 |
+
LANGUAGE: reply in the same language/dialect the user just used (Arabic or English). Never mix in Russian/Thai/Chinese/Japanese/Korean script — if unsure of a term, describe simply instead.
|
| 81 |
+
|
| 82 |
+
PATIENT MODE STYLE: no jargon (no "asymmetry index", "ABCDE"). Plain everyday words, max 4-6 short sentences/bullets. Never mix English medical terms into Arabic sentences.
|
| 83 |
+
|
| 84 |
+
ROLE ACCESS:
|
| 85 |
+
- You operate in PATIENT mode only. Never reveal other patients' data, only their own uploaded result.
|
| 86 |
+
- If the result is Malignant: stress urgency + next steps + suggest the "📅 Book appointment" button (create a referral ticket only if explicitly asked).
|
| 87 |
+
- If the result is Benign: prevention + monitoring tips.
|
| 88 |
+
|
| 89 |
+
MATCHED PATIENT RECORD: if this block appears, the uploaded image matched an existing patient (same person uploading). Use it to answer diagnosis/family-history questions directly and specifically — summarize relevant parts, don't dump verbatim.
|
| 90 |
+
|
| 91 |
+
STRICT RAG RULE: your knowledge is limited to (1) patient records via matched block, (2) knowledge-base via search_knowledge_base, (3) AI image results, (4) this conversation. Never invent symptoms, history, numbers. If info isn't available say exactly: "This information is not available in the current patient record." (or Arabic equivalent).
|
| 92 |
+
|
| 93 |
+
TOOLS:
|
| 94 |
+
- search_knowledge_base is the default first call for general clinical/knowledge questions (cite the source filename).
|
| 95 |
+
- Use segment_my_image for segmentation/mask/overlay requests.
|
| 96 |
+
- Use reanalyze_my_image for classification + segmentation analysis.
|
| 97 |
+
- STOP after one tool call for a simple lookup, and at most two tool calls for a combined request.
|
| 98 |
+
- Never call the same tool twice.
|
| 99 |
+
- Never call a second different tool just because the first one returned a valid answer.
|
| 100 |
+
- Keep replies SHORT by default (2–4 sentences) unless the user explicitly asks for more detail.
|
| 101 |
+
|
| 102 |
+
QUICK ROUTING (pick one, don't overthink):
|
| 103 |
+
- General symptom/skin question, no specific image → search_knowledge_base only.
|
| 104 |
+
- "my/this image", "analyze it", "the result" → segment_my_image/reanalyze_my_image/get_my_uploaded_image.
|
| 105 |
+
- Own history/family/risk → answer from the [MATCHED PATIENT RECORD] block if present; if absent, say the info isn't available yet.
|
| 106 |
+
|
| 107 |
+
Booking is via the "📅 Book appointment" button. Never print booking IDs, tickets, or debug/system text yourself.
|
| 108 |
+
|
| 109 |
+
ANALYSIS MARKER: if a tool response starts with `[ANALYSIS_RESULT:{...}]`, keep that exact marker at the very start of your reply, unmodified. After it, briefly cover: classification + confidence, affected area, what it means in plain terms, relevant family/hereditary context if present, and next steps. Keep it short — a routine benign result needs only 2-3 sentences; a malignant one with family history can go a bit longer. Remind patients once (not every message) that this doesn't replace a professional diagnosis.
|
| 110 |
+
|
| 111 |
+
If a user gives a local file path, tell them to use the 📎 upload button instead.
|
| 112 |
+
"""
|
| 113 |
+
|
| 114 |
+
# Tools
|
| 115 |
+
@tool
|
| 116 |
+
def search_knowledge_base(query: str) -> str:
|
| 117 |
+
"""Search the dermatology knowledge base for clinical information."""
|
| 118 |
+
if _RETRIEVER is None:
|
| 119 |
+
return "Retriever not initialized."
|
| 120 |
+
docs = _RETRIEVER.invoke(query)
|
| 121 |
+
if not docs:
|
| 122 |
+
return "No relevant information found."
|
| 123 |
+
formatted = []
|
| 124 |
+
for i, d in enumerate(docs, 1):
|
| 125 |
+
source = d.metadata.get("source", "unknown").replace("\\", "/").split("/")[-1]
|
| 126 |
+
formatted.append(f"[Source {i}: {source}]\n{d.page_content}")
|
| 127 |
+
return "\n\n---\n\n".join(formatted)
|
| 128 |
+
|
| 129 |
+
@tool
|
| 130 |
+
def get_my_uploaded_image() -> str:
|
| 131 |
+
"""[PATIENT MODE] Tell patient to use the Show My Image button."""
|
| 132 |
+
return "Your image is stored for this session. Use the 📷 Show My Image button to view it."
|
| 133 |
+
|
| 134 |
+
@tool
|
| 135 |
+
def segment_my_image() -> str:
|
| 136 |
+
"""[PATIENT MODE] Run U-Net segmentation on the patient's uploaded image."""
|
| 137 |
+
if not _SESSIONS or not _CURRENT_THREAD_ID:
|
| 138 |
+
return "No session found."
|
| 139 |
+
|
| 140 |
+
if _API_CLIENT is None:
|
| 141 |
+
return "API client not initialized."
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
result = _API_CLIENT.segment_patient_image(_CURRENT_THREAD_ID)
|
| 145 |
+
|
| 146 |
+
if not result.get('ok'):
|
| 147 |
+
return f"Error: {result.get('error', 'Segmentation failed')}"
|
| 148 |
+
|
| 149 |
+
# Store images
|
| 150 |
+
if 'images' in result:
|
| 151 |
+
_store_image_blobs(result['images'])
|
| 152 |
+
|
| 153 |
+
import json as _json
|
| 154 |
+
result_data = {
|
| 155 |
+
"label": "",
|
| 156 |
+
"confidence_pct": 0,
|
| 157 |
+
"infection_pct": result.get('infection_pct', 0)
|
| 158 |
+
}
|
| 159 |
+
marker = f"[ANALYSIS_RESULT:{_json.dumps(result_data)}]"
|
| 160 |
+
|
| 161 |
+
return f"{marker}\n\nSEGMENTATION OF YOUR IMAGE\nAffected Area: {result['infection_pct']}%"
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
logger.error(f"Error in segment_my_image: {e}", exc_info=True)
|
| 165 |
+
return f"Error segmenting image: {str(e)}"
|
| 166 |
+
|
| 167 |
+
@tool
|
| 168 |
+
def reanalyze_my_image() -> str:
|
| 169 |
+
"""[PATIENT MODE] Full AI analysis (classification + segmentation) on the patient's
|
| 170 |
+
most recently uploaded image."""
|
| 171 |
+
if not _SESSIONS or not _CURRENT_THREAD_ID:
|
| 172 |
+
return "No session found."
|
| 173 |
+
|
| 174 |
+
if _API_CLIENT is None:
|
| 175 |
+
return "API client not initialized."
|
| 176 |
+
|
| 177 |
+
try:
|
| 178 |
+
result = _API_CLIENT.reanalyze_patient_image(_CURRENT_THREAD_ID)
|
| 179 |
+
|
| 180 |
+
if not result.get('ok'):
|
| 181 |
+
return f"Error: {result.get('error', 'Analysis failed')}"
|
| 182 |
+
|
| 183 |
+
# Store images
|
| 184 |
+
if 'images' in result:
|
| 185 |
+
_store_image_blobs(result['images'])
|
| 186 |
+
|
| 187 |
+
import json as _json
|
| 188 |
+
result_data = {
|
| 189 |
+
"label": result.get("label", ""),
|
| 190 |
+
"confidence_pct": result.get("confidence_pct", 0),
|
| 191 |
+
"infection_pct": result.get("infection_pct", 0),
|
| 192 |
+
}
|
| 193 |
+
marker = f"[ANALYSIS_RESULT:{_json.dumps(result_data)}]"
|
| 194 |
+
|
| 195 |
+
lines = [
|
| 196 |
+
marker,
|
| 197 |
+
"",
|
| 198 |
+
"ANALYSIS OF YOUR IMAGE",
|
| 199 |
+
f"Classification: {result['label']} ({result['confidence_pct']}%)",
|
| 200 |
+
f"Affected Area: {result['infection_pct']}%"
|
| 201 |
+
]
|
| 202 |
+
|
| 203 |
+
return "\n".join(lines)
|
| 204 |
+
|
| 205 |
+
except Exception as e:
|
| 206 |
+
logger.error(f"Error in reanalyze_my_image: {e}", exc_info=True)
|
| 207 |
+
return f"Error analyzing image: {str(e)}"
|
| 208 |
+
|
| 209 |
+
@tool
|
| 210 |
+
def create_referral_ticket(issue_summary: str, urgency: str = "routine") -> str:
|
| 211 |
+
"""Create a dermatologist referral ticket."""
|
| 212 |
+
ticket_id = "DS-" + "".join(random.choices(string.digits, k=6))
|
| 213 |
+
times = {
|
| 214 |
+
"routine": "within 2–4 weeks",
|
| 215 |
+
"urgent": "within 48–72 hours",
|
| 216 |
+
"emergency": "within 24 hours — go to nearest dermatology clinic",
|
| 217 |
+
}
|
| 218 |
+
timeframe = times.get(urgency, "within 2–4 weeks")
|
| 219 |
+
|
| 220 |
+
DEFAULT_CLINIC = {
|
| 221 |
+
"name": "مركز القاهرة للأمراض الجلدية",
|
| 222 |
+
"address": "شارع التحرير، الدقي، الجيزة",
|
| 223 |
+
"phone": "01011112222",
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
return (
|
| 227 |
+
f"Referral created. Ticket: {ticket_id} | Urgency: {urgency.upper()} | "
|
| 228 |
+
f"Timeframe: {timeframe} | Summary: {issue_summary} | "
|
| 229 |
+
f"Clinic: {DEFAULT_CLINIC['name']} — {DEFAULT_CLINIC['address']}"
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
def build_agent(retriever, model_name: str = "llama-3.1-8b-instant", temperature: float = 0.25):
|
| 233 |
+
global _RETRIEVER
|
| 234 |
+
_RETRIEVER = retriever
|
| 235 |
+
|
| 236 |
+
groq_key = os.environ.get("GROQ_API_KEY")
|
| 237 |
+
if not groq_key:
|
| 238 |
+
raise ValueError("GROQ_API_KEY not set in environment variables.")
|
| 239 |
+
|
| 240 |
+
llm = ChatGroq(
|
| 241 |
+
groq_api_key=groq_key,
|
| 242 |
+
model_name=model_name,
|
| 243 |
+
temperature=temperature,
|
| 244 |
+
max_tokens=512,
|
| 245 |
+
request_timeout=120,
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
tools = [
|
| 249 |
+
search_knowledge_base,
|
| 250 |
+
get_my_uploaded_image,
|
| 251 |
+
segment_my_image,
|
| 252 |
+
reanalyze_my_image,
|
| 253 |
+
create_referral_ticket,
|
| 254 |
+
]
|
| 255 |
+
|
| 256 |
+
memory = MemorySaver()
|
| 257 |
+
|
| 258 |
+
return create_react_agent(
|
| 259 |
+
model=llm,
|
| 260 |
+
tools=tools,
|
| 261 |
+
prompt=SYSTEM_PROMPT,
|
| 262 |
+
checkpointer=memory,
|
| 263 |
+
)
|