diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..6ab3ca7406ba21b85464d1c136ba7bb4fe2c8917 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +venv/ +.venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.env + +# Frontend dev artifacts (only dist is needed) +web/node_modules/ +web/.vite/ + +# Local data — don't ship patient records or uploads +data/uploads/ +data/patient_chats/ +data/lesions/ +data/patients.json + +# Misc +.git/ +.gitignore +*.md +test*.py +test*.jpg +test*.png +frontend/ +mcp_server/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..ea682035070fc91c967f66389a66c38553559907 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +guidelines/index/faiss.index filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ee6ce71fac66695b681eb052959b35da965337eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +*.egg-info/ +.Python +venv/ +.venv/ +*.egg + +# Environment +.env +.env.* + +# Node +web/node_modules/ +web/dist/ +web/.vite/ + +# Patient data — never commit +data/uploads/ +data/patient_chats/ +data/lesions/ +data/patients.json + +# Model weights (large binaries — store separately) +models/*.pt +models/*.pth +models/*.bin +models/*.safetensors + +# macOS +.DS_Store + +# Test artifacts +test*.jpg +test*.png +*.log + +# Clinical guidelines PDFs (copyrighted — obtain separately) +guidelines/*.pdf + +# Temp +/tmp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..3cf5237c037518d6a9118f706690b2aed7e918e6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM python:3.10-slim + +WORKDIR /app + +# Install Node.js for building the React frontend +RUN apt-get update && \ + apt-get install -y curl && \ + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install -y nodejs && \ + rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt ml-requirements.txt +COPY backend/requirements.txt api-requirements.txt +RUN pip install --no-cache-dir -r ml-requirements.txt -r api-requirements.txt + +# Build React frontend +COPY web/ web/ +WORKDIR /app/web +RUN npm ci && npm run build + +WORKDIR /app + +# Copy application source +COPY models/ models/ +COPY backend/ backend/ +COPY data/case_store.py data/case_store.py +COPY guidelines/ guidelines/ + +# Runtime directories (writable by the app) +RUN mkdir -p data/uploads data/patient_chats data/lesions && \ + echo '{"patients": []}' > data/patients.json + +# HF Spaces runs as a non-root user — ensure data dirs are writable +RUN chmod -R 777 data/ + +# HF Spaces uses port 7860 +EXPOSE 7860 + +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c1a360b808a23964e2a109509c5d54a7d6525e68 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +--- +title: SkinProAI +emoji: 🔬 +colorFrom: blue +colorTo: indigo +sdk: docker +app_port: 7860 +pinned: false +--- + +# SkinProAI + +AI-assisted dermoscopic lesion analysis for clinical decision support. + +## Features + +- **Patient management** — create and select patient profiles +- **Image analysis** — upload dermoscopic images for automated assessment via MedGemma visual examination, MONET feature extraction, and ConvNeXt classification +- **Temporal comparison** — sequential images are automatically compared to detect change over time +- **Grad-CAM visualisation** — attention maps highlight regions driving the classification +- **Persistent chat history** — full analysis cascade is stored and replayed on reload + +## Architecture + +| Layer | Technology | +|-------|-----------| +| Frontend | React 18 + TypeScript (Vite) | +| Backend | FastAPI + uvicorn | +| Vision-language model | MedGemma (Google) via Hugging Face | +| Classifier | ConvNeXt fine-tuned on ISIC HAM10000 | +| Feature extraction | MONET skin concept probes | +| Explainability | Grad-CAM | + +## Usage + +1. Open the app and create a patient record +2. Click the patient card to open the chat +3. Attach a dermoscopic image and send — analysis runs automatically +4. Upload further images for the same patient to trigger temporal comparison +5. Ask follow-up questions in text to query the AI about the findings + +## Disclaimer + +SkinProAI is a research prototype intended for educational and investigational use only. It is **not** a certified medical device and must not be used as a substitute for professional clinical judgement. diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000000000000000000000000000000000000..be1ce0cbfeeaee97265cc210b4704e7869d8a596 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,63 @@ +""" +SkinProAI FastAPI Backend +""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pathlib import Path +import sys + +# Add project root to path for model imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from backend.routes import patients, lesions, analysis, chat + +app = FastAPI(title="SkinProAI API", version="1.0.0") + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API routes — analysis must be registered BEFORE patients so the literal +# /gradcam route is not shadowed by the parameterised /{patient_id} route. +app.include_router(analysis.router, prefix="/api/patients", tags=["analysis"]) +app.include_router(chat.router, prefix="/api/patients", tags=["chat"]) +app.include_router(patients.router, prefix="/api/patients", tags=["patients"]) +app.include_router(lesions.router, prefix="/api/patients", tags=["lesions"]) + +# Ensure upload directories exist +UPLOADS_DIR = Path(__file__).parent.parent / "data" / "uploads" +UPLOADS_DIR.mkdir(parents=True, exist_ok=True) + +# Serve uploaded images +if UPLOADS_DIR.exists(): + app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads") + +# Serve React build (production) +BUILD_DIR = Path(__file__).parent.parent / "web" / "dist" +if BUILD_DIR.exists(): + app.mount("/", StaticFiles(directory=str(BUILD_DIR), html=True), name="static") + + +@app.on_event("shutdown") +async def shutdown_event(): + from backend.services.analysis_service import get_analysis_service + svc = get_analysis_service() + if svc.agent.mcp_client: + svc.agent.mcp_client.stop() + + +@app.get("/api/health") +def health_check(): + return {"status": "ok"} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..838ed0db67894b61dcb15b89cd33ba2e8d714b65 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.100.0 +uvicorn[standard]>=0.23.0 +python-multipart>=0.0.6 diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..364a8a03debe6b59b432f4658c50bf8992e8eaad --- /dev/null +++ b/backend/routes/__init__.py @@ -0,0 +1 @@ +from . import patients, lesions, analysis diff --git a/backend/routes/analysis.py b/backend/routes/analysis.py new file mode 100644 index 0000000000000000000000000000000000000000..b006f854763296a2dc5cc600589ad32a5ec2667a --- /dev/null +++ b/backend/routes/analysis.py @@ -0,0 +1,181 @@ +""" +Analysis Routes - Image analysis with SSE streaming +""" + +from fastapi import APIRouter, Query, HTTPException +from fastapi.responses import StreamingResponse, FileResponse +from pathlib import Path +import json +import tempfile + +from backend.services.analysis_service import get_analysis_service +from data.case_store import get_case_store + +router = APIRouter() + + +@router.get("/gradcam") +def get_gradcam_by_path(path: str = Query(...)): + """Serve a temp visualization image (GradCAM or comparison overlay)""" + if not path: + raise HTTPException(status_code=400, detail="No path provided") + + temp_dir = Path(tempfile.gettempdir()).resolve() + resolved_path = Path(path).resolve() + if not str(resolved_path).startswith(str(temp_dir)): + raise HTTPException(status_code=403, detail="Access denied") + + allowed_suffixes = ("_gradcam.png", "_comparison.png") + if not any(resolved_path.name.endswith(s) for s in allowed_suffixes): + raise HTTPException(status_code=400, detail="Invalid image path") + + if resolved_path.exists(): + return FileResponse(str(resolved_path), media_type="image/png") + raise HTTPException(status_code=404, detail="Image not found") + + +@router.post("/{patient_id}/lesions/{lesion_id}/images/{image_id}/analyze") +async def analyze_image( + patient_id: str, + lesion_id: str, + image_id: str, + question: str = Query(None) +): + """Analyze an image with SSE streaming""" + store = get_case_store() + + # Verify image exists + img = store.get_image(patient_id, lesion_id, image_id) + if not img: + raise HTTPException(status_code=404, detail="Image not found") + if not img.image_path: + raise HTTPException(status_code=400, detail="Image has no file uploaded") + + service = get_analysis_service() + + async def generate(): + try: + for chunk in service.analyze(patient_id, lesion_id, image_id, question): + yield f"data: {json.dumps(chunk)}\n\n" + yield "data: [DONE]\n\n" + except Exception as e: + yield f"data: {json.dumps(f'[ERROR]{str(e)}[/ERROR]')}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + } + ) + + +@router.post("/{patient_id}/lesions/{lesion_id}/images/{image_id}/confirm") +async def confirm_diagnosis( + patient_id: str, + lesion_id: str, + image_id: str, + confirmed: bool = Query(...), + feedback: str = Query(None) +): + """Confirm or reject diagnosis and get management guidance""" + service = get_analysis_service() + + async def generate(): + try: + for chunk in service.confirm(patient_id, lesion_id, image_id, confirmed, feedback): + yield f"data: {json.dumps(chunk)}\n\n" + yield "data: [DONE]\n\n" + except Exception as e: + yield f"data: {json.dumps(f'[ERROR]{str(e)}[/ERROR]')}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + } + ) + + +@router.post("/{patient_id}/lesions/{lesion_id}/images/{image_id}/compare") +async def compare_to_previous( + patient_id: str, + lesion_id: str, + image_id: str +): + """Compare this image to the previous one in the timeline""" + store = get_case_store() + + # Get current and previous images + current_img = store.get_image(patient_id, lesion_id, image_id) + if not current_img: + raise HTTPException(status_code=404, detail="Image not found") + + previous_img = store.get_previous_image(patient_id, lesion_id, image_id) + if not previous_img: + raise HTTPException(status_code=400, detail="No previous image to compare") + + service = get_analysis_service() + + async def generate(): + try: + for chunk in service.compare_images( + patient_id, lesion_id, + previous_img.image_path, + current_img.image_path, + image_id + ): + yield f"data: {json.dumps(chunk)}\n\n" + yield "data: [DONE]\n\n" + except Exception as e: + yield f"data: {json.dumps(f'[ERROR]{str(e)}[/ERROR]')}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + } + ) + + +@router.post("/{patient_id}/lesions/{lesion_id}/chat") +async def chat_message( + patient_id: str, + lesion_id: str, + message: dict +): + """Send a chat message with SSE streaming response""" + store = get_case_store() + + lesion = store.get_lesion(patient_id, lesion_id) + if not lesion: + raise HTTPException(status_code=404, detail="Lesion not found") + + service = get_analysis_service() + content = message.get("content", "") + + async def generate(): + try: + for chunk in service.chat_followup(patient_id, lesion_id, content): + yield f"data: {json.dumps(chunk)}\n\n" + yield "data: [DONE]\n\n" + except Exception as e: + yield f"data: {json.dumps(f'[ERROR]{str(e)}[/ERROR]')}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + } + ) diff --git a/backend/routes/chat.py b/backend/routes/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..27165392883dbecf4225c12aaf1889ff9b3868b8 --- /dev/null +++ b/backend/routes/chat.py @@ -0,0 +1,97 @@ +""" +Chat Routes - Patient-level chat with image analysis tools +""" + +import asyncio +import json +import threading +from typing import Optional + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from fastapi.responses import StreamingResponse + +from data.case_store import get_case_store +from backend.services.chat_service import get_chat_service + +router = APIRouter() + + +@router.get("/{patient_id}/chat") +def get_chat_history(patient_id: str): + """Get patient-level chat history""" + store = get_case_store() + if not store.get_patient(patient_id): + raise HTTPException(status_code=404, detail="Patient not found") + messages = store.get_patient_chat_history(patient_id) + return {"messages": messages} + + +@router.delete("/{patient_id}/chat") +def clear_chat(patient_id: str): + """Clear patient-level chat history""" + store = get_case_store() + if not store.get_patient(patient_id): + raise HTTPException(status_code=404, detail="Patient not found") + store.clear_patient_chat_history(patient_id) + return {"success": True} + + +@router.post("/{patient_id}/chat") +async def post_chat_message( + patient_id: str, + content: str = Form(""), + image: Optional[UploadFile] = File(None), +): + """Send a chat message, optionally with an image — SSE streaming response. + + The sync ML generator runs in a background thread so it never blocks the + event loop. Events flow through an asyncio.Queue, so each SSE event is + flushed to the browser the moment it is produced (spinner shows instantly). + """ + store = get_case_store() + if not store.get_patient(patient_id): + raise HTTPException(status_code=404, detail="Patient not found") + + image_bytes = None + if image and image.filename: + image_bytes = await image.read() + + chat_service = get_chat_service() + + async def generate(): + loop = asyncio.get_event_loop() + queue: asyncio.Queue = asyncio.Queue() + + _SENTINEL = object() + + def run_sync(): + try: + for event in chat_service.stream_chat(patient_id, content, image_bytes): + loop.call_soon_threadsafe(queue.put_nowait, event) + except Exception as e: + loop.call_soon_threadsafe( + queue.put_nowait, + {"type": "error", "message": str(e)}, + ) + finally: + loop.call_soon_threadsafe(queue.put_nowait, _SENTINEL) + + thread = threading.Thread(target=run_sync, daemon=True) + thread.start() + + while True: + event = await queue.get() + if event is _SENTINEL: + break + yield f"data: {json.dumps(event)}\n\n" + + yield f"data: {json.dumps({'type': 'done'})}\n\n" + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) diff --git a/backend/routes/lesions.py b/backend/routes/lesions.py new file mode 100644 index 0000000000000000000000000000000000000000..ca8f2f8f88be9b9bef20d6bd407c0f646863b787 --- /dev/null +++ b/backend/routes/lesions.py @@ -0,0 +1,241 @@ +""" +Lesion Routes - CRUD for lesions and images +""" + +from fastapi import APIRouter, HTTPException, UploadFile, File +from fastapi.responses import FileResponse +from pydantic import BaseModel +from dataclasses import asdict +from pathlib import Path +from PIL import Image +import io + +from data.case_store import get_case_store + +router = APIRouter() + + +class CreateLesionRequest(BaseModel): + name: str + location: str = "" + + +class UpdateLesionRequest(BaseModel): + name: str = None + location: str = None + + +# ------------------------------------------------------------------------- +# Lesion CRUD +# ------------------------------------------------------------------------- + +@router.get("/{patient_id}/lesions") +def list_lesions(patient_id: str): + """List all lesions for a patient""" + store = get_case_store() + + patient = store.get_patient(patient_id) + if not patient: + raise HTTPException(status_code=404, detail="Patient not found") + + lesions = store.list_lesions(patient_id) + + result = [] + for lesion in lesions: + images = store.list_images(patient_id, lesion.id) + # Get the most recent image as thumbnail + latest_image = images[-1] if images else None + + result.append({ + "id": lesion.id, + "patient_id": lesion.patient_id, + "name": lesion.name, + "location": lesion.location, + "created_at": lesion.created_at, + "image_count": len(images), + "latest_image": asdict(latest_image) if latest_image else None + }) + + return {"lesions": result} + + +@router.post("/{patient_id}/lesions") +def create_lesion(patient_id: str, req: CreateLesionRequest): + """Create a new lesion for a patient""" + store = get_case_store() + + patient = store.get_patient(patient_id) + if not patient: + raise HTTPException(status_code=404, detail="Patient not found") + + lesion = store.create_lesion(patient_id, req.name, req.location) + return { + "lesion": { + **asdict(lesion), + "image_count": 0, + "images": [] + } + } + + +@router.get("/{patient_id}/lesions/{lesion_id}") +def get_lesion(patient_id: str, lesion_id: str): + """Get a lesion with all its images""" + store = get_case_store() + + lesion = store.get_lesion(patient_id, lesion_id) + if not lesion: + raise HTTPException(status_code=404, detail="Lesion not found") + + images = store.list_images(patient_id, lesion_id) + + return { + "lesion": { + **asdict(lesion), + "image_count": len(images), + "images": [asdict(img) for img in images] + } + } + + +@router.patch("/{patient_id}/lesions/{lesion_id}") +def update_lesion(patient_id: str, lesion_id: str, req: UpdateLesionRequest): + """Update a lesion's name or location""" + store = get_case_store() + + lesion = store.get_lesion(patient_id, lesion_id) + if not lesion: + raise HTTPException(status_code=404, detail="Lesion not found") + + store.update_lesion(patient_id, lesion_id, req.name, req.location) + + # Return updated lesion + lesion = store.get_lesion(patient_id, lesion_id) + images = store.list_images(patient_id, lesion_id) + + return { + "lesion": { + **asdict(lesion), + "image_count": len(images), + "images": [asdict(img) for img in images] + } + } + + +@router.delete("/{patient_id}/lesions/{lesion_id}") +def delete_lesion(patient_id: str, lesion_id: str): + """Delete a lesion and all its images""" + store = get_case_store() + + lesion = store.get_lesion(patient_id, lesion_id) + if not lesion: + raise HTTPException(status_code=404, detail="Lesion not found") + + store.delete_lesion(patient_id, lesion_id) + return {"success": True} + + +# ------------------------------------------------------------------------- +# Image CRUD +# ------------------------------------------------------------------------- + +@router.post("/{patient_id}/lesions/{lesion_id}/images") +async def upload_image(patient_id: str, lesion_id: str, image: UploadFile = File(...)): + """Upload a new image to a lesion's timeline""" + store = get_case_store() + + lesion = store.get_lesion(patient_id, lesion_id) + if not lesion: + raise HTTPException(status_code=404, detail="Lesion not found") + + try: + # Create image record + img_record = store.add_image(patient_id, lesion_id) + + # Save the actual image file + pil_image = Image.open(io.BytesIO(await image.read())).convert("RGB") + image_path = store.save_lesion_image(patient_id, lesion_id, img_record.id, pil_image) + + # Update image record with path + store.update_image(patient_id, lesion_id, img_record.id, image_path=image_path) + + # Return updated record + img_record = store.get_image(patient_id, lesion_id, img_record.id) + return {"image": asdict(img_record)} + + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to upload image: {e}") + + +@router.get("/{patient_id}/lesions/{lesion_id}/images/{image_id}") +def get_image_record(patient_id: str, lesion_id: str, image_id: str): + """Get an image record""" + store = get_case_store() + + img = store.get_image(patient_id, lesion_id, image_id) + if not img: + raise HTTPException(status_code=404, detail="Image not found") + + return {"image": asdict(img)} + + +@router.get("/{patient_id}/lesions/{lesion_id}/images/{image_id}/file") +def get_image_file(patient_id: str, lesion_id: str, image_id: str): + """Get the actual image file""" + store = get_case_store() + + img = store.get_image(patient_id, lesion_id, image_id) + if not img or not img.image_path: + raise HTTPException(status_code=404, detail="Image not found") + + path = Path(img.image_path) + if not path.exists(): + raise HTTPException(status_code=404, detail="Image file not found") + + return FileResponse(str(path), media_type="image/png") + + +@router.get("/{patient_id}/lesions/{lesion_id}/images/{image_id}/gradcam") +def get_gradcam_file(patient_id: str, lesion_id: str, image_id: str): + """Get the GradCAM visualization for an image""" + store = get_case_store() + + img = store.get_image(patient_id, lesion_id, image_id) + if not img or not img.gradcam_path: + raise HTTPException(status_code=404, detail="GradCAM not found") + + path = Path(img.gradcam_path) + if not path.exists(): + raise HTTPException(status_code=404, detail="GradCAM file not found") + + return FileResponse(str(path), media_type="image/png") + + +# ------------------------------------------------------------------------- +# Chat +# ------------------------------------------------------------------------- + +@router.get("/{patient_id}/lesions/{lesion_id}/chat") +def get_chat_history(patient_id: str, lesion_id: str): + """Get chat history for a lesion""" + store = get_case_store() + + lesion = store.get_lesion(patient_id, lesion_id) + if not lesion: + raise HTTPException(status_code=404, detail="Lesion not found") + + messages = store.get_chat_history(patient_id, lesion_id) + return {"messages": [asdict(m) for m in messages]} + + +@router.delete("/{patient_id}/lesions/{lesion_id}/chat") +def clear_chat_history(patient_id: str, lesion_id: str): + """Clear chat history for a lesion""" + store = get_case_store() + + lesion = store.get_lesion(patient_id, lesion_id) + if not lesion: + raise HTTPException(status_code=404, detail="Lesion not found") + + store.clear_chat_history(patient_id, lesion_id) + return {"success": True} diff --git a/backend/routes/patients.py b/backend/routes/patients.py new file mode 100644 index 0000000000000000000000000000000000000000..9f3fb900bb0008288aa3992093a1c197ece3bb15 --- /dev/null +++ b/backend/routes/patients.py @@ -0,0 +1,72 @@ +""" +Patient Routes - CRUD for patients +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from dataclasses import asdict + +from data.case_store import get_case_store + +router = APIRouter() + + +class CreatePatientRequest(BaseModel): + name: str + + +@router.get("") +def list_patients(): + """List all patients with lesion counts""" + store = get_case_store() + patients = store.list_patients() + + result = [] + for p in patients: + result.append({ + **asdict(p), + "lesion_count": store.get_patient_lesion_count(p.id) + }) + + return {"patients": result} + + +@router.post("") +def create_patient(req: CreatePatientRequest): + """Create a new patient""" + store = get_case_store() + patient = store.create_patient(req.name) + return { + "patient": { + **asdict(patient), + "lesion_count": 0 + } + } + + +@router.get("/{patient_id}") +def get_patient(patient_id: str): + """Get a patient by ID""" + store = get_case_store() + patient = store.get_patient(patient_id) + if not patient: + raise HTTPException(status_code=404, detail="Patient not found") + + return { + "patient": { + **asdict(patient), + "lesion_count": store.get_patient_lesion_count(patient_id) + } + } + + +@router.delete("/{patient_id}") +def delete_patient(patient_id: str): + """Delete a patient and all their lesions""" + store = get_case_store() + patient = store.get_patient(patient_id) + if not patient: + raise HTTPException(status_code=404, detail="Patient not found") + + store.delete_patient(patient_id) + return {"success": True} diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/services/analysis_service.py b/backend/services/analysis_service.py new file mode 100644 index 0000000000000000000000000000000000000000..2fa87cc321d71bb0d316e522f8f7cebb578972b2 --- /dev/null +++ b/backend/services/analysis_service.py @@ -0,0 +1,146 @@ +""" +Analysis Service - Wraps MedGemmaAgent for API use +""" + +from pathlib import Path +from dataclasses import asdict +from typing import Optional, Generator + +from models.medgemma_agent import MedGemmaAgent +from data.case_store import get_case_store + + +class AnalysisService: + """Singleton service for managing analysis operations""" + + _instance = None + + def __init__(self): + self.agent = MedGemmaAgent(verbose=True) + self.store = get_case_store() + self._loaded = False + + def _ensure_loaded(self): + """Lazy load the ML models""" + if not self._loaded: + self.agent.load_model() + self._loaded = True + + def analyze( + self, + patient_id: str, + lesion_id: str, + image_id: str, + question: Optional[str] = None + ) -> Generator[str, None, None]: + """Run analysis on an image, yielding streaming chunks""" + self._ensure_loaded() + + image = self.store.get_image(patient_id, lesion_id, image_id) + if not image or not image.image_path: + yield "[ERROR]No image uploaded[/ERROR]" + return + + # Update stage + self.store.update_image(patient_id, lesion_id, image_id, stage="analyzing") + + # Reset agent state for new analysis + self.agent.reset_state() + + # Run analysis with question + for chunk in self.agent.analyze_image_stream(image.image_path, question=question or ""): + yield chunk + + # Save diagnosis after analysis + if self.agent.last_diagnosis: + analysis_data = { + "diagnosis": self.agent.last_diagnosis["predictions"][0]["class"], + "full_name": self.agent.last_diagnosis["predictions"][0]["full_name"], + "confidence": self.agent.last_diagnosis["predictions"][0]["probability"], + "all_predictions": self.agent.last_diagnosis["predictions"] + } + + # Save MONET features if available + if self.agent.last_monet_result: + analysis_data["monet_features"] = self.agent.last_monet_result.get("features", {}) + + self.store.update_image( + patient_id, lesion_id, image_id, + stage="awaiting_confirmation", + analysis=analysis_data + ) + + def confirm( + self, + patient_id: str, + lesion_id: str, + image_id: str, + confirmed: bool, + feedback: Optional[str] = None + ) -> Generator[str, None, None]: + """Confirm diagnosis and generate management guidance""" + for chunk in self.agent.generate_management_guidance(confirmed, feedback): + yield chunk + + # Update stage to complete + self.store.update_image(patient_id, lesion_id, image_id, stage="complete") + + def chat_followup( + self, + patient_id: str, + lesion_id: str, + message: str + ) -> Generator[str, None, None]: + """Handle follow-up chat messages""" + # Save user message + self.store.add_chat_message(patient_id, lesion_id, "user", message) + + # Generate response + response = "" + for chunk in self.agent.chat_followup(message): + response += chunk + yield chunk + + # Save assistant response + self.store.add_chat_message(patient_id, lesion_id, "assistant", response) + + def get_chat_history(self, patient_id: str, lesion_id: str): + """Get chat history for a lesion""" + messages = self.store.get_chat_history(patient_id, lesion_id) + return [asdict(m) for m in messages] + + def compare_images( + self, + patient_id: str, + lesion_id: str, + previous_image_path: str, + current_image_path: str, + current_image_id: str + ) -> Generator[str, None, None]: + """Compare two images and assess changes""" + self._ensure_loaded() + + # Run comparison + comparison_result = None + for chunk in self.agent.compare_followup_images(previous_image_path, current_image_path): + yield chunk + + # Extract comparison status from agent if available + # Default to STABLE if we can't determine + comparison_data = { + "status": "STABLE", + "summary": "Comparison complete" + } + + # Update the current image with comparison data + self.store.update_image( + patient_id, lesion_id, current_image_id, + comparison=comparison_data + ) + + +def get_analysis_service() -> AnalysisService: + """Get or create AnalysisService singleton""" + if AnalysisService._instance is None: + AnalysisService._instance = AnalysisService() + return AnalysisService._instance diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py new file mode 100644 index 0000000000000000000000000000000000000000..4c0dc8f19755837828aea54616fc592009169ef2 --- /dev/null +++ b/backend/services/chat_service.py @@ -0,0 +1,197 @@ +""" +Chat Service - Patient-level chat with tool dispatch and streaming +""" + +import io +import re +import uuid +from typing import Generator, Optional +from pathlib import Path +from PIL import Image as PILImage + +from data.case_store import get_case_store +from backend.services.analysis_service import get_analysis_service + + +def _extract_response_text(raw: str) -> str: + """Pull clean text out of [RESPONSE]...[/RESPONSE]; strip all other tags.""" + # Grab the RESPONSE block first + match = re.search(r'\[RESPONSE\](.*?)\[/RESPONSE\]', raw, re.DOTALL) + if match: + return match.group(1).strip() + # Fallback: strip every known markup tag + clean = re.sub( + r'\[(STAGE:[^\]]+|THINKING|RESPONSE|/RESPONSE|/THINKING|/STAGE' + r'|ERROR|/ERROR|RESULT|/RESULT|CONFIRM:\d+|/CONFIRM)\]', + '', raw + ) + return clean.strip() + + +class ChatService: + _instance = None + + def __init__(self): + self.store = get_case_store() + + def _get_image_url(self, patient_id: str, lesion_id: str, image_id: str) -> str: + return f"/uploads/{patient_id}/{lesion_id}/{image_id}/image.png" + + def stream_chat( + self, + patient_id: str, + content: str, + image_bytes: Optional[bytes] = None, + ) -> Generator[dict, None, None]: + """Main chat handler — yields SSE event dicts.""" + analysis_service = get_analysis_service() + + if image_bytes: + # ---------------------------------------------------------------- + # Image path: analyze (and optionally compare). + # We do NOT stream the raw verbose analysis text to the chat bubble — + # the tool card IS the display artefact. We accumulate the text + # internally, extract the clean [RESPONSE] block, and put it in + # tool_result.summary so the expanded card can show it. + # ---------------------------------------------------------------- + lesion = self.store.get_or_create_chat_lesion(patient_id) + + img_record = self.store.add_image(patient_id, lesion.id) + pil_image = PILImage.open(io.BytesIO(image_bytes)).convert("RGB") + abs_path = self.store.save_lesion_image( + patient_id, lesion.id, img_record.id, pil_image + ) + self.store.update_image(patient_id, lesion.id, img_record.id, image_path=abs_path) + + user_image_url = self._get_image_url(patient_id, lesion.id, img_record.id) + self.store.add_patient_chat_message( + patient_id, "user", content, image_url=user_image_url + ) + + # ---- tool: analyze_image ---------------------------------------- + call_id = f"tc-{uuid.uuid4().hex[:6]}" + yield {"type": "tool_start", "tool": "analyze_image", "call_id": call_id} + + analysis_text = "" + for chunk in analysis_service.analyze(patient_id, lesion.id, img_record.id): + yield {"type": "text", "content": chunk} + analysis_text += chunk + + updated_img = self.store.get_image(patient_id, lesion.id, img_record.id) + analysis_result: dict = { + "image_url": user_image_url, + "summary": _extract_response_text(analysis_text), + "diagnosis": None, + "full_name": None, + "confidence": None, + "all_predictions": [], + } + if updated_img and updated_img.analysis: + a = updated_img.analysis + analysis_result.update({ + "diagnosis": a.get("diagnosis"), + "full_name": a.get("full_name"), + "confidence": a.get("confidence"), + "all_predictions": a.get("all_predictions", []), + }) + + yield { + "type": "tool_result", + "tool": "analyze_image", + "call_id": call_id, + "result": analysis_result, + } + + # ---- tool: compare_images (if a previous image exists) ---------- + previous_img = self.store.get_previous_image(patient_id, lesion.id, img_record.id) + compare_call_id = None + compare_result = None + compare_text = "" + + if ( + previous_img + and previous_img.image_path + and Path(previous_img.image_path).exists() + ): + compare_call_id = f"tc-{uuid.uuid4().hex[:6]}" + yield { + "type": "tool_start", + "tool": "compare_images", + "call_id": compare_call_id, + } + + for chunk in analysis_service.compare_images( + patient_id, + lesion.id, + previous_img.image_path, + abs_path, + img_record.id, + ): + yield {"type": "text", "content": chunk} + compare_text += chunk + + updated_img2 = self.store.get_image(patient_id, lesion.id, img_record.id) + compare_result = { + "prev_image_url": self._get_image_url(patient_id, lesion.id, previous_img.id), + "curr_image_url": user_image_url, + "status_label": "STABLE", + "feature_changes": {}, + "summary": _extract_response_text(compare_text), + } + if updated_img2 and updated_img2.comparison: + c = updated_img2.comparison + compare_result.update({ + "status_label": c.get("status", "STABLE"), + "feature_changes": c.get("feature_changes", {}), + }) + if c.get("summary"): + compare_result["summary"] = c["summary"] + + yield { + "type": "tool_result", + "tool": "compare_images", + "call_id": compare_call_id, + "result": compare_result, + } + + # Save assistant message + tool_calls_data = [{ + "id": call_id, + "tool": "analyze_image", + "status": "complete", + "result": analysis_result, + }] + if compare_call_id and compare_result: + tool_calls_data.append({ + "id": compare_call_id, + "tool": "compare_images", + "status": "complete", + "result": compare_result, + }) + + self.store.add_patient_chat_message( + patient_id, "assistant", analysis_text + compare_text, + tool_calls=tool_calls_data, + ) + + else: + # ---------------------------------------------------------------- + # Text-only chat — stream chunks; tags are stripped on the frontend + # ---------------------------------------------------------------- + self.store.add_patient_chat_message(patient_id, "user", content) + + analysis_service._ensure_loaded() + response_text = "" + for chunk in analysis_service.agent.chat_followup(content): + yield {"type": "text", "content": chunk} + response_text += chunk + + self.store.add_patient_chat_message( + patient_id, "assistant", _extract_response_text(response_text) + ) + + +def get_chat_service() -> ChatService: + if ChatService._instance is None: + ChatService._instance = ChatService() + return ChatService._instance diff --git a/data/case_store.py b/data/case_store.py new file mode 100644 index 0000000000000000000000000000000000000000..f65c1b4382740e24aa4b700a11617765f321e6b7 --- /dev/null +++ b/data/case_store.py @@ -0,0 +1,507 @@ +""" +Case Store - JSON-based persistence for patients, lesions, and images +""" + +import json +import uuid +import shutil +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Optional, Any +from dataclasses import dataclass, field, asdict +from PIL import Image as PILImage + + +@dataclass +class ChatMessage: + role: str # "user" or "assistant" + content: str + timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat()) + + +@dataclass +class LesionImage: + """A single image capture of a lesion at a point in time""" + id: str + lesion_id: str + timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat()) + image_path: Optional[str] = None + gradcam_path: Optional[str] = None + analysis: Optional[Dict[str, Any]] = None # {diagnosis, confidence, monet_features} + comparison: Optional[Dict[str, Any]] = None # {status, feature_changes, summary} + is_original: bool = False + stage: str = "pending" # pending, analyzing, complete, error + + +@dataclass +class Lesion: + """A tracked lesion that can have multiple images over time""" + id: str + patient_id: str + name: str # User-provided label (e.g., "Left shoulder mole") + location: str = "" # Body location + created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat()) + chat_history: List[Dict] = field(default_factory=list) + + +@dataclass +class Patient: + """A patient who can have multiple lesions""" + id: str + name: str + created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat()) + + +class CaseStore: + """JSON-based persistence for patients, lesions, and images""" + + def __init__(self, data_dir: str = None): + if data_dir is None: + data_dir = Path(__file__).parent + self.data_dir = Path(data_dir) + self.patients_file = self.data_dir / "patients.json" + self.lesions_dir = self.data_dir / "lesions" + self.uploads_dir = self.data_dir / "uploads" + + # Ensure directories exist + self.lesions_dir.mkdir(parents=True, exist_ok=True) + self.uploads_dir.mkdir(parents=True, exist_ok=True) + + # Initialize patients file if needed + if not self.patients_file.exists(): + self._init_patients_file() + + def _init_patients_file(self): + """Initialize patients file""" + data = {"patients": []} + with open(self.patients_file, 'w') as f: + json.dump(data, f, indent=2) + + def _load_patients_data(self) -> Dict: + """Load patients JSON file""" + with open(self.patients_file, 'r') as f: + return json.load(f) + + def _save_patients_data(self, data: Dict): + """Save patients JSON file""" + with open(self.patients_file, 'w') as f: + json.dump(data, f, indent=2) + + # ------------------------------------------------------------------------- + # Patient Methods + # ------------------------------------------------------------------------- + + def list_patients(self) -> List[Patient]: + """List all patients""" + data = self._load_patients_data() + return [Patient(**p) for p in data.get("patients", [])] + + def get_patient(self, patient_id: str) -> Optional[Patient]: + """Get a patient by ID""" + data = self._load_patients_data() + for p in data.get("patients", []): + if p["id"] == patient_id: + return Patient(**p) + return None + + def create_patient(self, name: str) -> Patient: + """Create a new patient""" + patient = Patient( + id=f"patient-{uuid.uuid4().hex[:8]}", + name=name + ) + + data = self._load_patients_data() + data["patients"].append(asdict(patient)) + self._save_patients_data(data) + + # Create directory for this patient's lesions + (self.lesions_dir / patient.id).mkdir(exist_ok=True) + + return patient + + def delete_patient(self, patient_id: str): + """Delete a patient and all their lesions""" + data = self._load_patients_data() + data["patients"] = [p for p in data["patients"] if p["id"] != patient_id] + self._save_patients_data(data) + + # Delete lesion files + patient_lesions_dir = self.lesions_dir / patient_id + if patient_lesions_dir.exists(): + shutil.rmtree(patient_lesions_dir) + + # Delete uploads + patient_uploads_dir = self.uploads_dir / patient_id + if patient_uploads_dir.exists(): + shutil.rmtree(patient_uploads_dir) + + # Delete patient chat history + patient_chat_file = self.data_dir / "patient_chats" / f"{patient_id}.json" + if patient_chat_file.exists(): + patient_chat_file.unlink() + + def get_patient_lesion_count(self, patient_id: str) -> int: + """Get number of lesions for a patient""" + return len(self.list_lesions(patient_id)) + + # ------------------------------------------------------------------------- + # Lesion Methods + # ------------------------------------------------------------------------- + + def _get_lesion_path(self, patient_id: str, lesion_id: str) -> Path: + """Get path to lesion JSON file""" + return self.lesions_dir / patient_id / f"{lesion_id}.json" + + def list_lesions(self, patient_id: str) -> List[Lesion]: + """List all lesions for a patient""" + patient_dir = self.lesions_dir / patient_id + if not patient_dir.exists(): + return [] + + lesions = [] + for f in sorted(patient_dir.glob("*.json")): + with open(f, 'r') as fp: + data = json.load(fp) + # Only load lesion data, not images + lesion_data = {k: v for k, v in data.items() if k != 'images'} + lesions.append(Lesion(**lesion_data)) + + lesions.sort(key=lambda x: x.created_at) + return lesions + + def get_lesion(self, patient_id: str, lesion_id: str) -> Optional[Lesion]: + """Get a lesion by ID""" + path = self._get_lesion_path(patient_id, lesion_id) + if not path.exists(): + return None + + with open(path, 'r') as f: + data = json.load(f) + lesion_data = {k: v for k, v in data.items() if k != 'images'} + return Lesion(**lesion_data) + + def create_lesion(self, patient_id: str, name: str, location: str = "") -> Lesion: + """Create a new lesion for a patient""" + lesion = Lesion( + id=f"lesion-{uuid.uuid4().hex[:8]}", + patient_id=patient_id, + name=name, + location=location + ) + + # Ensure patient directory exists + patient_dir = self.lesions_dir / patient_id + patient_dir.mkdir(exist_ok=True) + + # Save lesion with empty images array + self._save_lesion_data(patient_id, lesion.id, { + **asdict(lesion), + "images": [] + }) + + return lesion + + def _save_lesion_data(self, patient_id: str, lesion_id: str, data: Dict): + """Save lesion data to JSON file""" + path = self._get_lesion_path(patient_id, lesion_id) + with open(path, 'w') as f: + json.dump(data, f, indent=2) + + def _load_lesion_data(self, patient_id: str, lesion_id: str) -> Optional[Dict]: + """Load full lesion data including images""" + path = self._get_lesion_path(patient_id, lesion_id) + if not path.exists(): + return None + + with open(path, 'r') as f: + return json.load(f) + + def delete_lesion(self, patient_id: str, lesion_id: str): + """Delete a lesion and all its images""" + path = self._get_lesion_path(patient_id, lesion_id) + if path.exists(): + path.unlink() + + # Delete uploads for this lesion + lesion_uploads_dir = self.uploads_dir / patient_id / lesion_id + if lesion_uploads_dir.exists(): + shutil.rmtree(lesion_uploads_dir) + + def update_lesion(self, patient_id: str, lesion_id: str, name: str = None, location: str = None): + """Update lesion name or location""" + data = self._load_lesion_data(patient_id, lesion_id) + if data is None: + return + + if name is not None: + data["name"] = name + if location is not None: + data["location"] = location + + self._save_lesion_data(patient_id, lesion_id, data) + + # ------------------------------------------------------------------------- + # LesionImage Methods + # ------------------------------------------------------------------------- + + def list_images(self, patient_id: str, lesion_id: str) -> List[LesionImage]: + """List all images for a lesion""" + data = self._load_lesion_data(patient_id, lesion_id) + if data is None: + return [] + + images = [LesionImage(**img) for img in data.get("images", [])] + images.sort(key=lambda x: x.timestamp) + return images + + def get_image(self, patient_id: str, lesion_id: str, image_id: str) -> Optional[LesionImage]: + """Get an image by ID""" + data = self._load_lesion_data(patient_id, lesion_id) + if data is None: + return None + + for img in data.get("images", []): + if img["id"] == image_id: + return LesionImage(**img) + return None + + def add_image(self, patient_id: str, lesion_id: str) -> LesionImage: + """Add a new image to a lesion's timeline""" + data = self._load_lesion_data(patient_id, lesion_id) + if data is None: + raise ValueError(f"Lesion {lesion_id} not found") + + # Check if this is the first image + is_first = len(data.get("images", [])) == 0 + + image = LesionImage( + id=f"img-{uuid.uuid4().hex[:8]}", + lesion_id=lesion_id, + is_original=is_first + ) + + if "images" not in data: + data["images"] = [] + data["images"].append(asdict(image)) + self._save_lesion_data(patient_id, lesion_id, data) + + return image + + def update_image( + self, + patient_id: str, + lesion_id: str, + image_id: str, + image_path: str = None, + gradcam_path: str = None, + analysis: Dict = None, + comparison: Dict = None, + stage: str = None + ): + """Update an image's data""" + data = self._load_lesion_data(patient_id, lesion_id) + if data is None: + return + + for img in data.get("images", []): + if img["id"] == image_id: + if image_path is not None: + img["image_path"] = image_path + if gradcam_path is not None: + img["gradcam_path"] = gradcam_path + if analysis is not None: + img["analysis"] = analysis + if comparison is not None: + img["comparison"] = comparison + if stage is not None: + img["stage"] = stage + break + + self._save_lesion_data(patient_id, lesion_id, data) + + def save_lesion_image( + self, + patient_id: str, + lesion_id: str, + image_id: str, + image: PILImage.Image, + filename: str = "image.png" + ) -> str: + """Save an uploaded image file, return the path""" + upload_dir = self.uploads_dir / patient_id / lesion_id / image_id + upload_dir.mkdir(parents=True, exist_ok=True) + + image_path = upload_dir / filename + image.save(image_path) + + return str(image_path) + + def get_previous_image( + self, + patient_id: str, + lesion_id: str, + current_image_id: str + ) -> Optional[LesionImage]: + """Get the image before the current one (for comparison)""" + images = self.list_images(patient_id, lesion_id) + + for i, img in enumerate(images): + if img.id == current_image_id and i > 0: + return images[i - 1] + return None + + # ------------------------------------------------------------------------- + # Chat Methods (scoped to lesion) + # ------------------------------------------------------------------------- + + def add_chat_message(self, patient_id: str, lesion_id: str, role: str, content: str): + """Add a chat message to a lesion""" + data = self._load_lesion_data(patient_id, lesion_id) + if data is None: + return + + message = ChatMessage(role=role, content=content) + if "chat_history" not in data: + data["chat_history"] = [] + data["chat_history"].append(asdict(message)) + self._save_lesion_data(patient_id, lesion_id, data) + + def get_chat_history(self, patient_id: str, lesion_id: str) -> List[ChatMessage]: + """Get chat history for a lesion""" + data = self._load_lesion_data(patient_id, lesion_id) + if data is None: + return [] + + return [ChatMessage(**m) for m in data.get("chat_history", [])] + + def clear_chat_history(self, patient_id: str, lesion_id: str): + """Clear chat history for a lesion""" + data = self._load_lesion_data(patient_id, lesion_id) + if data is None: + return + + data["chat_history"] = [] + self._save_lesion_data(patient_id, lesion_id, data) + + # ------------------------------------------------------------------------- + # Patient-level Chat Methods + # ------------------------------------------------------------------------- + + def _get_patient_chat_file(self, patient_id: str) -> Path: + """Get path to patient-level chat JSON file""" + chat_dir = self.data_dir / "patient_chats" + chat_dir.mkdir(exist_ok=True) + return chat_dir / f"{patient_id}.json" + + def get_patient_chat_history(self, patient_id: str) -> List[dict]: + """Get chat history for a patient""" + chat_file = self._get_patient_chat_file(patient_id) + if not chat_file.exists(): + return [] + with open(chat_file, 'r') as f: + data = json.load(f) + return data.get("messages", []) + + def add_patient_chat_message( + self, + patient_id: str, + role: str, + content: str, + image_url: Optional[str] = None, + tool_calls: Optional[list] = None + ): + """Add a message to patient-level chat history""" + chat_file = self._get_patient_chat_file(patient_id) + if chat_file.exists(): + with open(chat_file, 'r') as f: + data = json.load(f) + else: + data = {"messages": []} + + message: Dict[str, Any] = { + "id": f"msg-{uuid.uuid4().hex[:8]}", + "role": role, + "content": content, + "timestamp": datetime.utcnow().isoformat(), + } + if image_url is not None: + message["image_url"] = image_url + if tool_calls is not None: + message["tool_calls"] = tool_calls + + data["messages"].append(message) + with open(chat_file, 'w') as f: + json.dump(data, f, indent=2) + + def clear_patient_chat_history(self, patient_id: str): + """Clear patient-level chat history""" + chat_file = self._get_patient_chat_file(patient_id) + with open(chat_file, 'w') as f: + json.dump({"messages": []}, f) + + def get_or_create_chat_lesion(self, patient_id: str) -> 'Lesion': + """Get or create the internal chat-images lesion for a patient""" + for lesion in self.list_lesions(patient_id): + if lesion.name == "__chat_images__": + return lesion + return self.create_lesion(patient_id, "__chat_images__", "internal") + + def get_latest_chat_image(self, patient_id: str) -> Optional['LesionImage']: + """Get the most recently analyzed chat image for a patient""" + lesion = self.get_or_create_chat_lesion(patient_id) + images = self.list_images(patient_id, lesion.id) + for img in reversed(images): + if img.analysis is not None: + return img + return None + + +# Singleton instance +_store_instance = None + + +def get_case_store() -> CaseStore: + """Get or create CaseStore singleton""" + global _store_instance + if _store_instance is None: + _store_instance = CaseStore() + return _store_instance + + +if __name__ == "__main__": + # Test the store + store = CaseStore() + + print("Patients:") + for patient in store.list_patients(): + print(f" - {patient.id}: {patient.name}") + + # Create a test patient + print("\nCreating test patient...") + patient = store.create_patient("Test Patient") + print(f" Created: {patient.id}") + + # Create a lesion + print("\nCreating lesion...") + lesion = store.create_lesion(patient.id, "Left shoulder mole", "Left shoulder") + print(f" Created: {lesion.id}") + + # Add an image + print("\nAdding image...") + image = store.add_image(patient.id, lesion.id) + print(f" Created: {image.id} (is_original={image.is_original})") + + # Add another image + image2 = store.add_image(patient.id, lesion.id) + print(f" Created: {image2.id} (is_original={image2.is_original})") + + # List images + print(f"\nImages for lesion {lesion.id}:") + for img in store.list_images(patient.id, lesion.id): + print(f" - {img.id}: original={img.is_original}, stage={img.stage}") + + # Cleanup + print("\nCleaning up test patient...") + store.delete_patient(patient.id) + print("Done!") diff --git a/frontend/app.py b/frontend/app.py new file mode 100644 index 0000000000000000000000000000000000000000..88af1c264cb1a20e1e8a9c68c5cac7dae99b0e6b --- /dev/null +++ b/frontend/app.py @@ -0,0 +1,532 @@ +""" +SkinProAI Frontend - Modular Gradio application +""" + +import gradio as gr +from typing import Dict, Generator, Optional +from datetime import datetime +import sys +import os +import re +import base64 + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from data.case_store import get_case_store +from frontend.components.styles import MAIN_CSS +from frontend.components.analysis_view import format_output + + +# ============================================================================= +# CONFIG +# ============================================================================= + +class Config: + APP_TITLE = "SkinProAI" + SERVER_PORT = int(os.environ.get("GRADIO_SERVER_PORT", "7860")) + HF_SPACES = os.environ.get("SPACE_ID") is not None + + +# ============================================================================= +# AGENT +# ============================================================================= + +class AnalysisAgent: + """Wrapper for the MedGemma analysis agent""" + + def __init__(self): + self.model = None + self.loaded = False + + def load(self): + if self.loaded: + return + from models.medgemma_agent import MedGemmaAgent + self.model = MedGemmaAgent(verbose=True) + self.model.load_model() + self.loaded = True + + def analyze(self, image_path: str, question: str = "") -> Generator[str, None, None]: + if not self.loaded: + yield "[STAGE:loading]Loading AI models...[/STAGE]\n" + self.load() + + for chunk in self.model.analyze_image_stream(image_path, question=question): + yield chunk + + def management_guidance(self, confirmed: bool, feedback: str = None) -> Generator[str, None, None]: + for chunk in self.model.generate_management_guidance(confirmed, feedback): + yield chunk + + def followup(self, message: str) -> Generator[str, None, None]: + if not self.loaded or not self.model.last_diagnosis: + yield "[ERROR]No analysis context available.[/ERROR]\n" + return + for chunk in self.model.chat_followup(message): + yield chunk + + def reset(self): + if self.model: + self.model.reset_state() + + +agent = AnalysisAgent() +case_store = get_case_store() + + +# ============================================================================= +# APP +# ============================================================================= + +with gr.Blocks(title=Config.APP_TITLE, css=MAIN_CSS, theme=gr.themes.Soft()) as app: + + # ========================================================================= + # STATE + # ========================================================================= + state = gr.State({ + "page": "patient_select", # patient_select | analysis + "case_id": None, + "instance_id": None, + "output": "", + "gradcam_base64": None + }) + + # ========================================================================= + # PAGE 1: PATIENT SELECTION + # ========================================================================= + with gr.Group(visible=True, elem_classes=["patient-select-container"]) as page_patient: + gr.Markdown("# SkinProAI", elem_classes=["patient-select-title"]) + gr.Markdown("Select a patient to continue or create a new case", elem_classes=["patient-select-subtitle"]) + + with gr.Row(elem_classes=["patient-grid"]): + btn_demo_melanoma = gr.Button("Demo: Melanocytic Lesion", elem_classes=["patient-card"]) + btn_demo_ak = gr.Button("Demo: Actinic Keratosis", elem_classes=["patient-card"]) + btn_new_patient = gr.Button("+ New Patient", variant="primary", elem_classes=["new-patient-btn"]) + + # ========================================================================= + # PAGE 2: ANALYSIS + # ========================================================================= + with gr.Group(visible=False) as page_analysis: + + # Header + with gr.Row(elem_classes=["app-header"]): + gr.Markdown(f"**{Config.APP_TITLE}**", elem_classes=["app-title"]) + btn_back = gr.Button("< Back to Patients", elem_classes=["back-btn"]) + + with gr.Row(elem_classes=["analysis-container"]): + + # Sidebar (previous queries) + with gr.Column(scale=0, min_width=260, visible=False, elem_classes=["query-sidebar"]) as sidebar: + gr.Markdown("### Previous Queries", elem_classes=["sidebar-header"]) + sidebar_list = gr.Column(elem_id="sidebar-queries") + btn_new_query = gr.Button("+ New Query", size="sm", variant="primary") + + # Main content + with gr.Column(scale=4, elem_classes=["main-content"]): + + # Input view (greeting style) + with gr.Group(visible=True, elem_classes=["input-greeting"]) as view_input: + gr.Markdown("What would you like to analyze?", elem_classes=["greeting-title"]) + gr.Markdown("Upload an image and describe what you'd like to know", elem_classes=["greeting-subtitle"]) + + with gr.Column(elem_classes=["input-box-container"]): + input_message = gr.Textbox( + placeholder="Describe the lesion or ask a question...", + show_label=False, + lines=2, + elem_classes=["message-input"] + ) + + input_image = gr.Image( + type="pil", + height=180, + show_label=False, + elem_classes=["image-preview"] + ) + + with gr.Row(elem_classes=["input-actions"]): + gr.Markdown("*Upload a skin lesion image*") + btn_analyze = gr.Button("Analyze", elem_classes=["send-btn"], interactive=False) + + # Results view (shown after analysis) + with gr.Group(visible=False, elem_classes=["chat-view"]) as view_results: + output_html = gr.HTML( + value='
Starting...
', + elem_classes=["results-area"] + ) + + # Confirmation + with gr.Group(visible=False, elem_classes=["confirm-buttons"]) as confirm_box: + gr.Markdown("**Do you agree with this diagnosis?**") + with gr.Row(): + btn_confirm_yes = gr.Button("Yes, continue", variant="primary", size="sm") + btn_confirm_no = gr.Button("No, I disagree", variant="secondary", size="sm") + input_feedback = gr.Textbox(label="Your assessment", placeholder="Enter diagnosis...", visible=False) + btn_submit_feedback = gr.Button("Submit", visible=False, size="sm") + + # Follow-up + with gr.Row(elem_classes=["chat-input-area"]): + input_followup = gr.Textbox(placeholder="Ask a follow-up question...", show_label=False, lines=1, scale=4) + btn_followup = gr.Button("Send", size="sm", scale=1) + + # ========================================================================= + # DYNAMIC SIDEBAR RENDERING + # ========================================================================= + @gr.render(inputs=[state], triggers=[state.change]) + def render_sidebar(s): + case_id = s.get("case_id") + if not case_id or s.get("page") != "analysis": + return + + instances = case_store.list_instances(case_id) + current = s.get("instance_id") + + for i, inst in enumerate(instances, 1): + diagnosis = "Pending" + if inst.analysis and inst.analysis.get("diagnosis"): + d = inst.analysis["diagnosis"] + diagnosis = d.get("class", "?") + + label = f"#{i}: {diagnosis}" + variant = "primary" if inst.id == current else "secondary" + btn = gr.Button(label, size="sm", variant=variant, elem_classes=["query-item"]) + + # Attach click handler to load this instance + def load_instance(inst_id=inst.id, c_id=case_id): + def _load(current_state): + current_state["instance_id"] = inst_id + instance = case_store.get_instance(c_id, inst_id) + + # Load saved output if available + output_html = '
Previous analysis loaded
' + if instance and instance.analysis: + diag = instance.analysis.get("diagnosis", {}) + output_html = f'
Diagnosis: {diag.get("full_name", diag.get("class", "Unknown"))}
' + + return ( + current_state, + gr.update(visible=False), # view_input + gr.update(visible=True), # view_results + output_html, + gr.update(visible=False) # confirm_box + ) + return _load + + btn.click( + load_instance(), + inputs=[state], + outputs=[state, view_input, view_results, output_html, confirm_box] + ) + + # ========================================================================= + # EVENT HANDLERS + # ========================================================================= + + def select_patient(case_id: str, s: Dict): + """Handle patient selection""" + s["case_id"] = case_id + s["page"] = "analysis" + + instances = case_store.list_instances(case_id) + has_queries = len(instances) > 0 + + if has_queries: + # Load most recent + inst = instances[-1] + s["instance_id"] = inst.id + + # Load image if exists + img = None + if inst.image_path and os.path.exists(inst.image_path): + from PIL import Image + img = Image.open(inst.image_path) + + return ( + s, + gr.update(visible=False), # page_patient + gr.update(visible=True), # page_analysis + gr.update(visible=True), # sidebar + gr.update(visible=False), # view_input + gr.update(visible=True), # view_results + '
Previous analysis loaded
', + gr.update(visible=False) # confirm_box + ) + else: + # New instance + inst = case_store.create_instance(case_id) + s["instance_id"] = inst.id + s["output"] = "" + + return ( + s, + gr.update(visible=False), + gr.update(visible=True), + gr.update(visible=False), # sidebar hidden for new patient + gr.update(visible=True), # view_input + gr.update(visible=False), # view_results + "", + gr.update(visible=False) + ) + + def new_patient(s: Dict): + """Create new patient""" + case = case_store.create_case(f"Patient {datetime.now().strftime('%Y-%m-%d %H:%M')}") + return select_patient(case.id, s) + + def go_back(s: Dict): + """Return to patient selection""" + s["page"] = "patient_select" + s["case_id"] = None + s["instance_id"] = None + s["output"] = "" + + return ( + s, + gr.update(visible=True), # page_patient + gr.update(visible=False), # page_analysis + gr.update(visible=False), # sidebar + gr.update(visible=True), # view_input + gr.update(visible=False), # view_results + "", + gr.update(visible=False) # confirm_box + ) + + def new_query(s: Dict): + """Start new query for current patient""" + case_id = s.get("case_id") + if not case_id: + return s, gr.update(), gr.update(), gr.update(), "", gr.update() + + inst = case_store.create_instance(case_id) + s["instance_id"] = inst.id + s["output"] = "" + s["gradcam_base64"] = None + + agent.reset() + + return ( + s, + gr.update(visible=True), # view_input + gr.update(visible=False), # view_results + None, # clear image + "", # clear output + gr.update(visible=False) # confirm_box + ) + + def enable_analyze(img): + """Enable analyze button when image uploaded""" + return gr.update(interactive=img is not None) + + def run_analysis(image, message, s: Dict): + """Run analysis on uploaded image""" + if image is None: + yield s, gr.update(), gr.update(), gr.update(), gr.update() + return + + case_id = s["case_id"] + instance_id = s["instance_id"] + + # Save image + image_path = case_store.save_image(case_id, instance_id, image) + case_store.update_analysis(case_id, instance_id, stage="analyzing", image_path=image_path) + + agent.reset() + s["output"] = "" + gradcam_base64 = None + has_confirm = False + + # Switch to results view + yield ( + s, + gr.update(visible=False), # view_input + gr.update(visible=True), # view_results + '
Starting analysis...
', + gr.update(visible=False) # confirm_box + ) + + partial = "" + for chunk in agent.analyze(image_path, message or ""): + partial += chunk + + # Check for GradCAM + if gradcam_base64 is None: + match = re.search(r'\[GRADCAM_IMAGE:([^\]]+)\]', partial) + if match: + path = match.group(1) + if os.path.exists(path): + try: + with open(path, "rb") as f: + gradcam_base64 = base64.b64encode(f.read()).decode('utf-8') + s["gradcam_base64"] = gradcam_base64 + except: + pass + + if '[CONFIRM:' in partial: + has_confirm = True + + s["output"] = partial + + yield ( + s, + gr.update(visible=False), + gr.update(visible=True), + format_output(partial, gradcam_base64), + gr.update(visible=has_confirm) + ) + + # Save analysis + if agent.model and agent.model.last_diagnosis: + diag = agent.model.last_diagnosis["predictions"][0] + case_store.update_analysis( + case_id, instance_id, + stage="awaiting_confirmation", + analysis={"diagnosis": diag} + ) + + def confirm_yes(s: Dict): + """User confirmed diagnosis""" + partial = s.get("output", "") + gradcam = s.get("gradcam_base64") + + for chunk in agent.management_guidance(confirmed=True): + partial += chunk + s["output"] = partial + yield s, format_output(partial, gradcam), gr.update(visible=False) + + case_store.update_analysis(s["case_id"], s["instance_id"], stage="complete") + + def confirm_no(): + """Show feedback input""" + return gr.update(visible=True), gr.update(visible=True) + + def submit_feedback(feedback: str, s: Dict): + """Submit user feedback""" + partial = s.get("output", "") + gradcam = s.get("gradcam_base64") + + for chunk in agent.management_guidance(confirmed=False, feedback=feedback): + partial += chunk + s["output"] = partial + yield ( + s, + format_output(partial, gradcam), + gr.update(visible=False), + gr.update(visible=False), + gr.update(visible=False), + "" + ) + + case_store.update_analysis(s["case_id"], s["instance_id"], stage="complete") + + def send_followup(message: str, s: Dict): + """Send follow-up question""" + if not message.strip(): + return s, gr.update(), "" + + case_store.add_chat_message(s["case_id"], s["instance_id"], "user", message) + + partial = s.get("output", "") + gradcam = s.get("gradcam_base64") + + partial += f'\n
You: {message}
\n' + + response = "" + for chunk in agent.followup(message): + response += chunk + s["output"] = partial + response + yield s, format_output(partial + response, gradcam), "" + + case_store.add_chat_message(s["case_id"], s["instance_id"], "assistant", response) + + # ========================================================================= + # WIRE EVENTS + # ========================================================================= + + # Patient selection + btn_demo_melanoma.click( + lambda s: select_patient("demo-melanoma", s), + inputs=[state], + outputs=[state, page_patient, page_analysis, sidebar, view_input, view_results, output_html, confirm_box] + ) + + btn_demo_ak.click( + lambda s: select_patient("demo-ak", s), + inputs=[state], + outputs=[state, page_patient, page_analysis, sidebar, view_input, view_results, output_html, confirm_box] + ) + + btn_new_patient.click( + new_patient, + inputs=[state], + outputs=[state, page_patient, page_analysis, sidebar, view_input, view_results, output_html, confirm_box] + ) + + # Navigation + btn_back.click( + go_back, + inputs=[state], + outputs=[state, page_patient, page_analysis, sidebar, view_input, view_results, output_html, confirm_box] + ) + + btn_new_query.click( + new_query, + inputs=[state], + outputs=[state, view_input, view_results, input_image, output_html, confirm_box] + ) + + # Analysis + input_image.change(enable_analyze, inputs=[input_image], outputs=[btn_analyze]) + + btn_analyze.click( + run_analysis, + inputs=[input_image, input_message, state], + outputs=[state, view_input, view_results, output_html, confirm_box] + ) + + # Confirmation + btn_confirm_yes.click( + confirm_yes, + inputs=[state], + outputs=[state, output_html, confirm_box] + ) + + btn_confirm_no.click( + confirm_no, + outputs=[input_feedback, btn_submit_feedback] + ) + + btn_submit_feedback.click( + submit_feedback, + inputs=[input_feedback, state], + outputs=[state, output_html, confirm_box, input_feedback, btn_submit_feedback, input_feedback] + ) + + # Follow-up + btn_followup.click( + send_followup, + inputs=[input_followup, state], + outputs=[state, output_html, input_followup] + ) + + input_followup.submit( + send_followup, + inputs=[input_followup, state], + outputs=[state, output_html, input_followup] + ) + + +# ============================================================================= +# MAIN +# ============================================================================= + +if __name__ == "__main__": + print(f"\n{'='*50}") + print(f" {Config.APP_TITLE}") + print(f"{'='*50}\n") + + app.queue().launch( + server_name="0.0.0.0" if Config.HF_SPACES else "127.0.0.1", + server_port=Config.SERVER_PORT, + share=False, + show_error=True + ) diff --git a/frontend/components/__init__.py b/frontend/components/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/frontend/components/analysis_view.py b/frontend/components/analysis_view.py new file mode 100644 index 0000000000000000000000000000000000000000..ed7e353a52d688219e802221dddb85a8534df073 --- /dev/null +++ b/frontend/components/analysis_view.py @@ -0,0 +1,214 @@ +""" +Analysis View Component - Main analysis interface with input and results +""" + +import gradio as gr +import re +from typing import Optional + + +def parse_markdown(text: str) -> str: + """Convert basic markdown to HTML""" + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'__(.+?)__', r'\1', text) + text = re.sub(r'\*(.+?)\*', r'\1', text) + + # Bullet lists + lines = text.split('\n') + in_list = False + result = [] + for line in lines: + stripped = line.strip() + if re.match(r'^[\*\-] ', stripped): + if not in_list: + result.append('') + in_list = False + result.append(line) + if in_list: + result.append('') + + return '\n'.join(result) + + +# Regex patterns for output parsing +_STAGE_RE = re.compile(r'\[STAGE:(\w+)\](.*?)\[/STAGE\]') +_THINKING_RE = re.compile(r'\[THINKING\](.*?)\[/THINKING\]') +_OBSERVATION_RE = re.compile(r'\[OBSERVATION\](.*?)\[/OBSERVATION\]') +_TOOL_OUTPUT_RE = re.compile(r'\[TOOL_OUTPUT:(.*?)\]\n(.*?)\[/TOOL_OUTPUT\]', re.DOTALL) +_RESULT_RE = re.compile(r'\[RESULT\](.*?)\[/RESULT\]') +_ERROR_RE = re.compile(r'\[ERROR\](.*?)\[/ERROR\]') +_GRADCAM_RE = re.compile(r'\[GRADCAM_IMAGE:[^\]]+\]\n?') +_RESPONSE_RE = re.compile(r'\[RESPONSE\]\n(.*?)\n\[/RESPONSE\]', re.DOTALL) +_COMPLETE_RE = re.compile(r'\[COMPLETE\](.*?)\[/COMPLETE\]') +_CONFIRM_RE = re.compile(r'\[CONFIRM:(\w+)\](.*?)\[/CONFIRM\]') +_REFERENCES_RE = re.compile(r'\[REFERENCES\](.*?)\[/REFERENCES\]', re.DOTALL) +_REF_RE = re.compile(r'\[REF:([^:]+):([^:]+):([^:]+):([^:]+):([^\]]+)\]') + + +def format_output(raw_text: str, gradcam_base64: Optional[str] = None) -> str: + """Convert tagged output to styled HTML""" + html = raw_text + + # Stage headers + html = _STAGE_RE.sub( + r'
\2
', + html + ) + + # Thinking + html = _THINKING_RE.sub(r'
\1
', html) + + # Observations + html = _OBSERVATION_RE.sub(r'
\1
', html) + + # Tool outputs + html = _TOOL_OUTPUT_RE.sub( + r'
\1
\2
', + html + ) + + # Results + html = _RESULT_RE.sub(r'
\1
', html) + + # Errors + html = _ERROR_RE.sub(r'
\1
', html) + + # GradCAM image + if gradcam_base64: + img_html = f'
Attention Map
Grad-CAM
' + html = _GRADCAM_RE.sub(img_html, html) + else: + html = _GRADCAM_RE.sub('', html) + + # Response section + def format_response(match): + content = match.group(1) + parsed = parse_markdown(content) + parsed = re.sub(r'\n\n+', '

', parsed) + parsed = parsed.replace('\n', '
') + return f'

{parsed}

' + + html = _RESPONSE_RE.sub(format_response, html) + + # Complete + html = _COMPLETE_RE.sub(r'
\1
', html) + + # Confirmation + html = _CONFIRM_RE.sub( + r'
\2
', + html + ) + + # References + def format_references(match): + ref_content = match.group(1) + refs_html = ['
References
') + return '\n'.join(refs_html) + + html = _REFERENCES_RE.sub(format_references, html) + + # Convert newlines + html = html.replace('\n', '
') + + return f'
{html}
' + + +def create_analysis_view(): + """ + Create the analysis view component. + + Returns: + Tuple of (container, components dict) + """ + with gr.Group(visible=False, elem_classes=["analysis-container"]) as container: + + with gr.Row(): + # Main content area + with gr.Column(elem_classes=["main-content"]): + + # Input greeting (shown when no analysis yet) + with gr.Group(visible=True, elem_classes=["input-greeting"]) as input_greeting: + gr.Markdown("What would you like to analyze?", elem_classes=["greeting-title"]) + gr.Markdown("Upload an image and describe what you'd like to know", elem_classes=["greeting-subtitle"]) + + with gr.Column(elem_classes=["input-box-container"]): + message_input = gr.Textbox( + placeholder="Describe the lesion or ask a question...", + show_label=False, + lines=3, + elem_classes=["message-input"] + ) + + # Image upload (compact) + image_input = gr.Image( + label="", + type="pil", + height=180, + elem_classes=["image-preview"], + show_label=False + ) + + with gr.Row(elem_classes=["input-actions"]): + upload_hint = gr.Markdown("*Upload a skin lesion image above*", visible=True) + send_btn = gr.Button("Analyze", elem_classes=["send-btn"], interactive=False) + + # Chat/results view (shown after analysis starts) + with gr.Group(visible=False, elem_classes=["chat-view"]) as chat_view: + results_output = gr.HTML( + value='
Starting analysis...
', + elem_classes=["results-area"] + ) + + # Confirmation buttons + with gr.Group(visible=False, elem_classes=["confirm-buttons"]) as confirm_group: + gr.Markdown("**Do you agree with this diagnosis?**") + with gr.Row(): + confirm_yes_btn = gr.Button("Yes, continue", variant="primary", size="sm") + confirm_no_btn = gr.Button("No, I disagree", variant="secondary", size="sm") + feedback_input = gr.Textbox( + label="Your assessment", + placeholder="Enter your diagnosis...", + visible=False + ) + submit_feedback_btn = gr.Button("Submit", visible=False, size="sm") + + # Follow-up input + with gr.Row(elem_classes=["chat-input-area"]): + followup_input = gr.Textbox( + placeholder="Ask a follow-up question...", + show_label=False, + lines=1 + ) + followup_btn = gr.Button("Send", size="sm", elem_classes=["send-btn"]) + + components = { + "input_greeting": input_greeting, + "chat_view": chat_view, + "message_input": message_input, + "image_input": image_input, + "send_btn": send_btn, + "results_output": results_output, + "confirm_group": confirm_group, + "confirm_yes_btn": confirm_yes_btn, + "confirm_no_btn": confirm_no_btn, + "feedback_input": feedback_input, + "submit_feedback_btn": submit_feedback_btn, + "followup_input": followup_input, + "followup_btn": followup_btn, + "upload_hint": upload_hint + } + + return container, components diff --git a/frontend/components/patient_select.py b/frontend/components/patient_select.py new file mode 100644 index 0000000000000000000000000000000000000000..e4d49e0dceb5e83dd6254df5a464e869e1578c0a --- /dev/null +++ b/frontend/components/patient_select.py @@ -0,0 +1,48 @@ +""" +Patient Selection Component - Landing page for selecting/creating patients +""" + +import gradio as gr +from typing import Callable, List +from data.case_store import get_case_store, Case + + +def create_patient_select(on_patient_selected: Callable[[str], None]) -> gr.Group: + """ + Create the patient selection page component. + + Args: + on_patient_selected: Callback when a patient is selected (receives case_id) + + Returns: + gr.Group containing the patient selection UI + """ + case_store = get_case_store() + + with gr.Group(visible=True, elem_classes=["patient-select-container"]) as container: + gr.Markdown("# SkinProAI", elem_classes=["patient-select-title"]) + gr.Markdown("Select a patient to continue or create a new case", elem_classes=["patient-select-subtitle"]) + + with gr.Column(elem_classes=["patient-grid"]): + # Demo cases + demo_melanoma_btn = gr.Button( + "Demo: Melanocytic Lesion", + elem_classes=["patient-card"] + ) + demo_ak_btn = gr.Button( + "Demo: Actinic Keratosis", + elem_classes=["patient-card"] + ) + + # New patient button + new_patient_btn = gr.Button( + "+ New Patient", + elem_classes=["new-patient-btn"] + ) + + return container, demo_melanoma_btn, demo_ak_btn, new_patient_btn + + +def get_patient_cases() -> List[Case]: + """Get list of all patient cases""" + return get_case_store().list_cases() diff --git a/frontend/components/sidebar.py b/frontend/components/sidebar.py new file mode 100644 index 0000000000000000000000000000000000000000..0f62e55b9883ac3fb0b1bc7768699c26d52ebe38 --- /dev/null +++ b/frontend/components/sidebar.py @@ -0,0 +1,55 @@ +""" +Sidebar Component - Shows previous queries for a patient +""" + +import gradio as gr +from datetime import datetime +from typing import List, Optional +from data.case_store import get_case_store, Instance + + +def format_query_item(instance: Instance, index: int) -> str: + """Format an instance as a query item for display""" + diagnosis = "Pending" + if instance.analysis and instance.analysis.get("diagnosis"): + diag = instance.analysis["diagnosis"] + diagnosis = diag.get("full_name", diag.get("class", "Unknown")) + + try: + dt = datetime.fromisoformat(instance.created_at.replace('Z', '+00:00')) + date_str = dt.strftime("%b %d, %H:%M") + except: + date_str = "Unknown" + + return f"Query #{index}: {diagnosis} ({date_str})" + + +def create_sidebar(): + """ + Create the sidebar component for showing previous queries. + + Returns: + Tuple of (container, components dict) + """ + with gr.Column(visible=False, elem_classes=["query-sidebar"]) as container: + gr.Markdown("### Previous Queries", elem_classes=["sidebar-header"]) + + # Dynamic list of query buttons + query_list = gr.Column(elem_id="query-list") + + # New query button + new_query_btn = gr.Button("+ New Query", size="sm", variant="primary") + + components = { + "query_list": query_list, + "new_query_btn": new_query_btn + } + + return container, components + + +def get_queries_for_case(case_id: str) -> List[Instance]: + """Get all instances/queries for a case""" + if not case_id: + return [] + return get_case_store().list_instances(case_id) diff --git a/frontend/components/styles.py b/frontend/components/styles.py new file mode 100644 index 0000000000000000000000000000000000000000..5d4d7fabb26ffd56cd3b89652110cb1ab58aca97 --- /dev/null +++ b/frontend/components/styles.py @@ -0,0 +1,517 @@ +""" +CSS Styles for SkinProAI components +""" + +MAIN_CSS = """ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); + +* { + font-family: 'Inter', sans-serif !important; +} + +.gradio-container { + max-width: 1200px !important; + margin: 0 auto !important; +} + +/* Hide Gradio footer */ +.gradio-container footer { display: none !important; } + +/* ============================================ + PATIENT SELECTION PAGE + ============================================ */ + +.patient-select-container { + min-height: 80vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; +} + +.patient-select-title { + font-size: 32px; + font-weight: 600; + color: #111827; + margin-bottom: 8px; + text-align: center; +} + +.patient-select-subtitle { + font-size: 16px; + color: #6b7280; + margin-bottom: 40px; + text-align: center; +} + +.patient-grid { + display: flex; + gap: 20px; + flex-wrap: wrap; + justify-content: center; + max-width: 800px; +} + +.patient-card { + background: white !important; + border: 2px solid #e5e7eb !important; + border-radius: 16px !important; + padding: 24px 32px !important; + min-width: 200px !important; + cursor: pointer; + transition: all 0.2s ease !important; +} + +.patient-card:hover { + border-color: #6366f1 !important; + box-shadow: 0 8px 25px rgba(99, 102, 241, 0.15) !important; + transform: translateY(-2px); +} + +.new-patient-btn { + background: #6366f1 !important; + color: white !important; + border: none !important; + border-radius: 12px !important; + padding: 16px 32px !important; + font-weight: 500 !important; + margin-top: 24px; +} + +.new-patient-btn:hover { + background: #4f46e5 !important; +} + +/* ============================================ + ANALYSIS PAGE - MAIN LAYOUT + ============================================ */ + +.analysis-container { + display: flex; + height: calc(100vh - 80px); + min-height: 600px; +} + +/* Sidebar */ +.query-sidebar { + width: 280px; + background: #f9fafb; + border-right: 1px solid #e5e7eb; + padding: 20px; + overflow-y: auto; + flex-shrink: 0; +} + +.sidebar-header { + font-size: 14px; + font-weight: 600; + color: #374151; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid #e5e7eb; +} + +.query-item { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px; + margin-bottom: 8px; + cursor: pointer; + transition: all 0.15s; +} + +.query-item:hover { + border-color: #6366f1; + background: #f5f3ff; +} + +.query-item-title { + font-size: 13px; + font-weight: 500; + color: #111827; + margin-bottom: 4px; +} + +.query-item-meta { + font-size: 11px; + color: #6b7280; +} + +/* Main content area */ +.main-content { + flex: 1; + display: flex; + flex-direction: column; + padding: 24px; + overflow: hidden; +} + +/* ============================================ + INPUT AREA (Greeting style) + ============================================ */ + +.input-greeting { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; +} + +.greeting-title { + font-size: 24px; + font-weight: 600; + color: #111827; + margin-bottom: 8px; +} + +.greeting-subtitle { + font-size: 14px; + color: #6b7280; + margin-bottom: 32px; +} + +.input-box-container { + width: 100%; + max-width: 600px; + background: white; + border: 2px solid #e5e7eb; + border-radius: 16px; + padding: 20px; + transition: border-color 0.2s; +} + +.input-box-container:focus-within { + border-color: #6366f1; +} + +.message-input textarea { + border: none !important; + resize: none !important; + font-size: 15px !important; + line-height: 1.5 !important; + padding: 0 !important; +} + +.message-input textarea:focus { + box-shadow: none !important; +} + +.input-actions { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid #f3f4f6; +} + +.upload-btn { + background: #f3f4f6 !important; + color: #374151 !important; + border: 1px solid #e5e7eb !important; + border-radius: 8px !important; + padding: 8px 16px !important; + font-size: 13px !important; +} + +.upload-btn:hover { + background: #e5e7eb !important; +} + +.send-btn { + background: #6366f1 !important; + color: white !important; + border: none !important; + border-radius: 8px !important; + padding: 10px 24px !important; + font-weight: 500 !important; +} + +.send-btn:hover { + background: #4f46e5 !important; +} + +.send-btn:disabled { + background: #d1d5db !important; + cursor: not-allowed; +} + +/* Image preview */ +.image-preview { + margin-top: 16px; + border-radius: 12px; + overflow: hidden; + max-height: 200px; +} + +.image-preview img { + max-height: 200px; + object-fit: contain; +} + +/* ============================================ + CHAT/RESULTS VIEW + ============================================ */ + +.chat-view { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.results-area { + flex: 1; + overflow-y: auto; + padding: 20px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + margin-bottom: 16px; +} + +/* Analysis output styling */ +.analysis-output { + line-height: 1.6; + color: #333; +} + +.stage { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + font-weight: 500; + color: #1a1a1a; + margin-top: 12px; +} + +.stage-indicator { + width: 8px; + height: 8px; + background: #6366f1; + border-radius: 50%; + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.8); } +} + +.thinking { + color: #6b7280; + font-style: italic; + font-size: 13px; + padding: 4px 0 4px 16px; + border-left: 2px solid #e5e7eb; + margin: 4px 0; +} + +.observation { + color: #374151; + font-size: 13px; + padding: 4px 0 4px 16px; +} + +.tool-output { + background: #f8fafc; + border-radius: 8px; + margin: 12px 0; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.tool-header { + background: #f1f5f9; + padding: 8px 12px; + font-weight: 500; + font-size: 13px; + color: #475569; + border-bottom: 1px solid #e2e8f0; +} + +.tool-content { + padding: 12px; + margin: 0; + font-family: 'SF Mono', Monaco, monospace !important; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + color: #334155; +} + +.result { + background: #ecfdf5; + border: 1px solid #a7f3d0; + border-radius: 8px; + padding: 12px 16px; + margin: 12px 0; + font-weight: 500; + color: #065f46; +} + +.error { + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + padding: 12px 16px; + margin: 8px 0; + color: #b91c1c; +} + +.response { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 16px; + margin: 16px 0; + line-height: 1.7; +} + +.response ul, .response ol { + margin: 8px 0; + padding-left: 24px; +} + +.response li { + margin: 4px 0; +} + +.complete { + color: #6b7280; + font-size: 12px; + padding: 8px 0; + text-align: center; +} + +/* Confirmation */ +.confirm-box { + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 8px; + padding: 16px; + margin: 16px 0; + text-align: center; +} + +.confirm-buttons { + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 8px; + padding: 12px; + margin-top: 12px; +} + +/* References */ +.references { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + margin: 16px 0; + overflow: hidden; +} + +.references-header { + background: #f3f4f6; + padding: 8px 12px; + font-weight: 500; + font-size: 13px; + border-bottom: 1px solid #e5e7eb; +} + +.references ul { + list-style: none; + padding: 12px; + margin: 0; +} + +.ref-link { + color: #6366f1; + text-decoration: none; + font-size: 13px; +} + +.ref-link:hover { + text-decoration: underline; +} + +/* GradCAM */ +.gradcam-inline { + margin: 16px 0; + background: #f8fafc; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; +} + +.gradcam-header { + background: #f1f5f9; + padding: 8px 12px; + font-weight: 500; + font-size: 13px; + border-bottom: 1px solid #e2e8f0; +} + +.gradcam-inline img { + max-width: 100%; + max-height: 300px; + display: block; + margin: 12px auto; +} + +/* Chat input at bottom */ +.chat-input-area { + background: white; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 12px 16px; + display: flex; + gap: 12px; + align-items: flex-end; +} + +.chat-input-area textarea { + flex: 1; + border: none !important; + resize: none !important; + font-size: 14px !important; +} + +/* ============================================ + HEADER + ============================================ */ + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid #e5e7eb; + background: white; +} + +.app-title { + font-size: 20px; + font-weight: 600; + color: #111827; +} + +.back-btn { + background: transparent !important; + color: #6b7280 !important; + border: 1px solid #e5e7eb !important; + border-radius: 8px !important; + padding: 8px 16px !important; + font-size: 13px !important; +} + +.back-btn:hover { + background: #f9fafb !important; + color: #111827 !important; +} +""" diff --git a/guidelines/index/chunks.json b/guidelines/index/chunks.json new file mode 100644 index 0000000000000000000000000000000000000000..68669e8dbcc69e9b259768be4e17b2fc69e65c75 --- /dev/null +++ b/guidelines/index/chunks.json @@ -0,0 +1 @@ +[{"text": "JournalofPlastic,Reconstructive&AestheticSurgery(2010)63,1401e1419 Revised UK guidelines for the management *,** of cutaneous melanoma 2010 J.R. Marsdena,*, J.A. Newton-Bishop b, L. Burrows c, M. Cook d, P.G. Corrie e, N.H. Cox a, M.E. Gore f, P. Lorigang, R. MacKieh, P. Nathan i, H. Peachj, B. Powell k, C. Walkera aUniversity HospitalBirmingham, Birmingham B29 6JD,United Kingdom bUniversity of Leeds,Leeds LS9 7TF,United Kingdom cSalisbury District Hospital, Salisbury SP2 8BJ, United Kingdom dRoyal Surrey CountyHospital NHSTrust,Guildford GU27XX, United Kingdom eCambridge University Hospitals NHS FoundationTrust,Cambridge CB2 2QQ,United Kingdom fRoyal Marsden Hospital, LondonSW3 6JJ,United Kingdom gTheChristie NHS Foundation Trust, ManchesterM20 4BX,United Kingdom hUniversity of Glasgow,Glasgow G128QQ, United Kingdom iMount Vernon Hospital, London HA62RN,United Kingdom jStJames\u2019sUniversity Hospital, LeedsLS9 7TF,United Kingdom kStGeorge\u2019sHospital, London SW170QT,United Kingdom KEYWORDS Summary These guidelines for the management of cutaneous melanoma present an Evidence; evidence-basedguidancefortreatment,withidentificationofthestrengthofevidenceavailGuideline; ableatthetimeofpreparationoftheguidelines,andabriefoverviewofepidemiology,diagInvestigation; nosis,investigation,andfollow-up. Melanoma; \u00aa2010 British Association of Plastic, Reconstructive and Aesthetic Surgeons and British Treatment AssociationofDermatologists.Allrightsreserved. *ThisisanupdatedguidelinepreparedfortheBritishAssociationofDermatologists(BAD)ClinicalStandardsUnit,madeupoftheTherapy& GuidelinesSubcommittee(T&G)andtheAudit&ClinicalStandardsSubcommittee(A&CS).MembersoftheClinicalStandardsUnitare:HKBell [ChairmanT&G],LCFuller[ChairmanA&CS],NJLevell,MJTidman,PDYesudian,JLear,JHughes,AJMcDonagh,SPunjabi,NMorar,SWagle [BritishNationalFormulary],SEHulley[BritishDermatologicalNursingGroup],KJLyons[BADScientificAdministrator],andMFMohdMustapa [BADClinicalStandardsManager]. TheseguidelinesarepublishedsimultaneouslyintheBritishJournalofDermatology(doi:10.1111/j.1365-2133.2010.09883.x)andtheJournal ofPlastic,Reconstructive&AestheticSurgery(doi:10.1016/j.bjps.2010.07.006). **Guidelinesproducedin2002bytheBritishAssociationofDermatologists;reviewedandupdated,2009. * Correspondingauthor. E-mailaddress:jerry.marsden@uhb.nhs.uk(J.R.Marsden). 1748-6815/$-seefrontmatter\u00aa2010BritishAssociationofPlastic,ReconstructiveandAestheticSurgeonsandBritishAssociationofDermatologists.Allrightsreserved. doi:10.1016/j.bjps.2010.07.006", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 1}, {"text": "1402 J.R.Marsden etal. Guidelines review process and other publications were identified from the PubMed searches,independentsearchescarriedoutbytheauthors, These guidelines were initially reviewed at a multidisciaswellasmaterialscollectedbytheauthorsaspartoftheir plinarymeetingon8November2007.Thoseattendingwere ongoingprofessionalinterestinthelatestdevelopmentsin theauthors plus: this clinical area. Levels of evidence to support the guideDermatology: V Doherty, D Roberts, F Wojnarowska, lines are quoted according to the criteria stated in HBell,DdeBerker,CHarwood,SBailey,RBarlow,VBataille, Appendix1.TheconsultationprocessforBritishAssociation L Rhodes of Dermatologists guidelines and their compliance with Surgery:A Hayes,J Kenealy,G Perks, M Timmins guideline recommendations have been published elseSpecialist Nursing: H Williams, C McGarr, M Sherman, where.1,2Thereareargumentsinfavourofnewerguideline JDavenport, CWheelhouse gradingmethods,suchasthoseofGRADE,3buttheauthors Histopathology:NKirkham,HRigby,J Theaker believethatthesystemusedhereallowsgreaterpotential Imaging:JSmith, PGuest, A Dancey for consensus in areas of conflicting evidence or where Oncology: N Steven, P Corrie, P Patel, A Goodman, evidence sources are not directly comparable. In some CKelly, PLawton,A Dalgleish instances, this is not due to an absence of high quality Lay representative: TFay (Level Ib) trials but because different entry criteria or Palliative Care:J Speakman,F Calman endpoints preclude direct comparison of results; in other National Institute for Health and Clinical Excellence: cases interpretation of the clinical significance of results NSummerton has been challenged. To assist production of unified Scottish Intercollegiate Guidelines Network:SQureshi guidelines taking account of these issues, the \u2018quality of Primary Care:P Murchie evidence\u2019 grading used in these guidelines differs slightly from that used in other British Association of Dermatologists current guidelines; the strength of Disclaimer recommendationsgradingisthesameasusedinmanyother publications.Wherenolevelisquotedtheevidenceistobe These guidelines reflect the best published data available regarded as representing Level IV (i.e. a consensus at the time the report was prepared. Caution should be statement). exercised in interpreting the data; the results of future The intention of the working party was to agree best studiesmayrequirealterationoftheconclusionsorrecompracticeforthemanagementofmelanomainthebeliefthat mendations in this report. It may be necessary or even this will promote good standards of care across the whole desirable to depart from the guidelines in the interests of country.However,theyareguidelinesonly.Careshouldbe specificpatients andspecial circumstances. Just as adherindividualised wherever appropriate. These guidelines will ence to the guidelines may not constitute defence against berevisedasnecessarytoreflectchangesinpracticeinlight aclaimofnegligence,sodeviationfromthemshouldnotbe ofnewevidence. necessarilydeemednegligent. Integration with national cancer guidance Contributiontotheseguidelineshasbeenmadebyalarge number of clinicians. They have also been endorsed by, or Multidisciplinary care of the patient is held to be the most havehadinputfrom,representativesofthefollowinggroups desirable model as recommended in the Calman/Hine ororganisations:theUKMelanomaStudyGroup,theBritish report.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 2}, {"text": "by, or Multidisciplinary care of the patient is held to be the most havehadinputfrom,representativesofthefollowinggroups desirable model as recommended in the Calman/Hine ororganisations:theUKMelanomaStudyGroup,theBritish report.4 This has been defined by the National Institute for Association of Dermatologists, the British Association of HealthandClinicalExcellenceImprovingOutcomesGuidance Plastic, Reconstructive and Aesthetic Surgeons, the Royal for People with Skin Tumours Including Melanoma (NICE College of Physicians, London, the Association of Cancer IOG).5 Core services will be provided within each Cancer Physicians, the Royal College of Radiologists, London, the Network by Local Skin Cancer Multidisciplinary Teams Royal College of Surgeons, England, the Royal College (LSMDTs). Specialist services will be provided by Specialist ofPathologists(pathologysectiononly),theRoyalCollegeof SkinCancerMultidisciplinaryTeams(SSMDTs).Formelanoma General Practitioners, London, and the Department of thereisacleardemarcationofcaresuchthatmoreadvanced Health. primarymelanoma,raresubtypesofmelanoma,melanomain These consensus guidelines have been drawn up by children,andpatientseligiblefortrialentryorsentinellymph a multidisciplinary working party with membership drawn nodebiopsyshouldbepromptlyreferredforinvestigationand from a variety of groups and coordinated by the United treatmentfromanLSMDTtoanSSMDT(Table1). Kingdom Melanoma Study Group (UKMSG), and the British Association of Dermatologists. The guidelines deal with aspects of the management of melanoma from its prevenPrevention of melanoma tion, through the stages of diagnosis and initial treatment to palliationof advanceddisease. PubMed literature searches for this guidelines revision Individuals,andparticularlychildren,shouldnotgetsunburnt werecarriedouttoidentifypublicationsfrom2000toApril (Level I).6e9 Meta-analysis of case-control studies provides 2010, with search terms including: melanoma genetics, good evidence that melanoma is predominantly caused by epidemiology, early diagnosis, risk factors, clinical intermittent intense sun exposure; fair-skinned individuals features, pathology, surgery, chemotherapy and clinical should therefore limit their recreational exposure through trials. Relevant materials were also isolated from reviews life(LevelI).10Peoplewithfreckles,redorblondhair,skin", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 2}, {"text": "Revised UKguidelines forthe managementof cutaneousmelanoma 2010 1403 Table1 MelanomapatientswhomustbereferredfromaLocalSkinCancerMultidisciplinaryTeamtoaSpecialistSkinCancer MultidisciplinaryTeam(SSMDT)(NICEIOG2006).5 (cid:1) Patients with melanoma managed by other site specialist teams (e.g. gynaecological, mucosal and head and neck (excludingocular)) (cid:1) Patients with stage IB or higher primary melanoma when sentinel lymph node biopsy (SLNB) is available within their Network.IntheabsenceofSLNBthenpatientswithstageIIBorhighershouldbereferredtotheSSMDT(AmericanJoint CommitteeonCancer(AJCC)stagingsystem) (cid:1) Patientswithmelanomaatanystagewhoareeligibleforclinicaltrialsthathavebeenapprovedatcancernetworklevel (cid:1) Patientswithmultipleprimarymelanomas (cid:1) Childrenandyoungadultsunder19yearswithmelanoma (cid:1) Anypatientwithmetastaticmelanomadiagnosedatpresentationoronfollow-up (cid:1) Patientswithgiantcongenitalnaeviwherethereissuspicionofmalignanttransformation (cid:1) Patientswithskinlesionsofuncertainmalignantpotential whichburnsinthesun,increasednumbersofnaevi,andthose Lesionswhicharesuspiciousformelanomashouldnotbe withafamilyhistoryofmelanomaareatincreasedriskand removedinprimarycare.Thisisbecauseclinico-pathological shouldheedthisadvice. correlation is vital for diagnostic accuracy, which in turn Adequate sun exposure to allow vitamin D synthesis, or determines prognosis and defines adjuvant treatment sufficientdietaryintakeofvitaminD isessentialtohuman options, andbecause diagnostic surgery requiresspecialist 3, health; insufficiency of vitamin D is now recognised to be training. Early recognition of melanoma presents the best common.11 It would therefore be inappropriate to greatly opportunityforcure15,19e22(LevelIII,GradeA). limitsunexposureinpeoplewithouttheriskfactorslisted All patients presenting with an atypical melanocytic above.RecentstudieshaveshownthatinUKvitaminDlevels lesion or a large number of moles should have a complete areoftensuboptimalinmelanomapatients,andarelowerin skin examination and assessment of risk factors. The derfair-skinnedpeople.12,13Fair-skinnedpeoplewhoavoidthe moscopeisausefultoolforthetrainedclinicianscreening sunrigorouslytoreducetheriskofmelanomashouldconsider pigmentedlesions,asitcanincreasediagnosticaccuracy.23 supplementingtheirintakeofvitaminD intheabsenceof It is also useful for monitoring multiple pigmented lesions 3 medicalcontraindications. wherephotographyofdermoscopicimagesprovidesarecord Thereisevidencefromarecentmeta-analysisthatsunofchange(LevelIa,GradeA).RecommendationsforLSMDT bedusagedoesincreasetheriskofmelanoma,particularly record-keepingofclinicalfeaturesareproposedinTable2. undertheageof35years,andthereforeitisrecommended thatthis shouldbeavoided(LevelIa).14 Screening and surveillance of high-risk individuals Referral and clinical diagnosis Therearesomeindividualsathigherriskofmelanomawho shouldbeconsideredforreferraltospecialistclinics.These Melanomaremainsrelatively uncommonandthereforethe individuals can be divided broadly into two groups based opportunitytodevelopdiagnosticskillsislimitedinprimary upon the degreeof risk: care.Alllesionssuspiciousofmelanomashouldbereferred urgently under the 2-week rule to local screening services usually run by dermatologists. In England and Wales, this wouldbetoanLSMDT.InScotland,referralshouldbemadeto a local Rapid Access Cancer Clinic according to Scottish CancerReferralGuidelines.Theseven-pointchecklistorthe Table 2 Recommendations for Local Skin Cancer MultiABCDrulemaybehelpfulintheidentificationofmelanomas disciplinaryTeamrecord-keepingofclinicalfeatures. althoughtheyaremoresensitivethanspecific.15e18Urgent Asaminimumthefollowingshouldbeincluded: referraltotheLSMDTisindicatedwherethereis: History(thepresenceorabsenceofthesechanges shouldberecorded) (cid:1) Anewmoleappearingaftertheonsetofpubertywhich (cid:1) Durationofthelesion ischanging inshape,colour orsize (cid:1) Changeinsize (cid:1) Along-standingmolewhichischanginginshape,colour (cid:1) Changeincolour orsize (cid:1) Changeinshape (cid:1) Anymolewhichhasthreeormorecoloursorhaslostits (cid:1) Symptoms(itching,bleeding,etc.) symmetry Examination (cid:1) A molewhich isitchingorbleeding (cid:1) Site (cid:1) Any new persistent skin lesion especially if growing, if (cid:1) Size(maximumdiameter) pigmented or vascular in appearance, and if the diag- (cid:1) Elevation(flat,palpable,nodular) nosis isnotclear (cid:1) Description(irregularmargins,irregularpigmentation (cid:1) Anewpigmentedlineinanailespeciallywherethereis andifulcerationispresent) associateddamage to the nail (LevelIII,GradeB) (cid:1) A lesiongrowing under anail.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 3}, {"text": "1404 J.R.Marsden etal. 1. Individuals at moderate increased risk (approximately 8498-cc94b378312a.pdf.Recommendationsforscreeningand 8e10 times that of the general population) should be surveillanceofhigh-riskindividualsaresummarisedinTable3. counselled about this risk and taught how to selfexamine for changing naevi, but long-term follow-up is Biopsy of suspected melanoma notusual.Suchpatientsarethosewitheitheraprevious primary melanoma, or large numbers of moles some of whichmaybeclinicallyatypical(LevelIa,GradeB).24e28 A lesion suspected to be melanoma, or where melanoma Organ transplant recipients are also at this level of needstobeexcluded,shouldbephotographed,andthen increasedrisk(LevelIII,GradeB).29,30 excised completely. The axis of excision should be 2. Those at greatly increased risk of melanoma (more orientated to facilitate possible subsequent wide local than10timesthatofthegeneralpopulation).Patients excision;generallyonthelimbthiswillbealongthelong withagiantcongenitalpigmentedhairynaevus(definiaxis. If uncertain, direct referral to the multidisciplinary tions include \u201820cm or more in diameter\u2019 and \u20185% of team (MDT) will allow appropriate planning for future bodysurfacearea\u2019)shouldbemonitoredbyanexpertfor surgery. The excision biopsy should include the whole theirlifetimebecauseoftheriskofmalignantchange, tumourwithaclinicalmarginof2mmofnormalskin,and whichissignificantbutpoorlyquantified(LevelIII,Grade acuffoffat.Thisallowsconfirmationofthediagnosisby B).31,32 Excision biopsy of suspicious areas in large examination of the entire lesion, such that subsequent congenitalnaevimaybenecessarybutrequiresexpert definitive treatment can be based on Breslow histopathological review. Patients with a strong family thickness.35e37 historyofmelanomaarealsoatgreatlyincreasedrisk.In Diagnosticshavebiopsiesshouldnotbeperformedsince some families, most clearly in mainland Europe and theymayleadtoincorrectdiagnosisduetosamplingerror, NorthAmerica,familiesatriskofmelanomaarealsoat and make accurate pathological staging of the lesion increasedriskofpancreaticcancer.33Thosewiththree impossible(LevelIII).Forthesamereasonspartialremoval ormorecasesofmelanomaorpancreaticcancerinthe ofnaevifordiagnosismustbeavoidedandpartialremoval family should be referred to appropriate clinics of a melanocytic naevus may result in a clinical and managing inherited predisposition to cancer (involving pathological picture very like melanoma (pseudomeladermatologists and/or clinical geneticists) for counselnoma).Thisgivesrisetoneedlessanxietyandisavoidable. ling. It is the consensus of the Melanoma Genetics Incisional or punch biopsy is occasionally acceptable, for Consortium(www.genomel.org)thatitisprematureto example in the differential diagnosis of lentigo maligna suggest gene testing routinely but this may change as (LM) on the face or of acral melanoma, but there is no moreisknownofthegenespredisposingtomelanoma.34 placeforeitherincisionalorpunchbiopsyoutsidetheskin Therisktofamiliesassociatedwiththepresenceoftwo cancer MDT (Level III). It is acceptable in certain circumfamily members affected with melanoma is lower. In stances to excise the lesion entirely but without repair, these families, if affected individuals also have the and to dress the wound while awaiting definitive atypical mole syndrome, or if there is a history of pathology. multipleprimarymelanomasinanindividualorpancreBiopsies of possible subungual melanomas should be atic cancer, then referral should also be made for carried out by surgeons regularly doing so. The nail should counselling;otherwisefamilymembersshouldprobably beremovedsufficientlyforthenailmatrixtobeadequately beconsideredatmoderatelyincreasedrisk. sampled: clinically obvious tumour should be biopsied if present.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 4}, {"text": "cancer, then referral should also be made for carried out by surgeons regularly doing so. The nail should counselling;otherwisefamilymembersshouldprobably beremovedsufficientlyforthenailmatrixtobeadequately beconsideredatmoderatelyincreasedrisk. sampled: clinically obvious tumour should be biopsied if present. Alloftheaboveindividualsatincreasedriskofmelanoma Prophylactic excision of naevi, or of small (<5cm shouldbeadvisedonthespecificchangesthatsuggestmeladiameter) congenital naevi in the absence of suspicious nomaandencouragedtoundertakemonthlyskinself-examifeatures is notrecommended (LevelIII, GradeD). nation(LevelIII,GradeB).Close-upanddistantphotography Full clinical details should be supplied on the histopamaybeausefuladjuncttodetectingearlymelanomaineither thology form, including history of the lesion, relevant of these high-risk groups (Level III). They should be given previous history, site and differential diagnosis. All melawritteninformationandaccesstoimagesofmolesandmelanocytic lesions excised for whatever reason must be sent nomas. Such images are available at: www.genomel.org or for histopathological review to the pathologist associated www.rcplondon.ac.uk/pubs/contents/f36b1656-cc74-4867with the LSMDTor SSMDT. Table3 Recommendationsforscreeningandsurveillanceofhigh-riskindividuals. (cid:1) Patientswhoareatmoderatelyincreasedriskofmelanomashouldbeadvisedofthisandtaughthowtoself-examine.This includespatientswithatypicalmolephenotype,thosewithapreviousmelanoma,andorgantransplantrecipients(LevelIa, GradeB). (cid:1) Patientswithgiantcongenitalpigmentednaeviareatincreasedriskofmelanomaandrequirelong-termfollow-up(Level IIIa,GradeB). (cid:1) Individuals with a family history of three or more cases of melanoma, or of pancreatic cancer, should be referred to aclinicalgeneticistorspecialiseddermatologyservicesforcounselling.Thosewithtwocasesinthefamilymayalsobenefit, especiallyifoneofthecaseshadmultipleprimarymelanomasortheatypicalmolephenotype(LevelIIa,GradeB).", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 4}, {"text": "Revised UKguidelines forthe managementof cutaneousmelanoma 2010 1405 The diagnosis of melanoma, both in situ and invasive, melanomas (cid:3)1.0mm.40,41 It should be recorded as should be given or supervised by doctors who have number of mitoses per mm(cid:4)2 in the area of greatest received advanced communication skills training, number of mitoses in the vertical growth phase (VGP). following local policies for breaking bad news. A skin It has prognostic value at all thicknesses. cancer trained nurse should be present to provide continuing support. Histologic subtypes. Desmoplastic melanoma with or without neurotropism should be recorded because of its Histopathology different biological behaviour and clinical outcome.42 The subtypes superficial spreading, nodular, lentigo General comments maligna and acral lentiginous melanomas have good clinico-pathological correlation, but their prognostic value has not been established. TheRoyalCollegeofPathologistshasproducedaminimum dataset which should be included in the histopathology report.38 Double reporting is recommended for all melaMargins of excision. This indicates whether excision is completeandtheminimummarginofexcisiontoperipheral nomas and all naevi showing severe dysplasia if resources allowthis tobeachieved within14 days.5 and deep aspects measured in millimetres. If the excision or re-excision is not complete, whether the tumour is in situorinvasiveattheresectionmarginshouldbeindicated. The histopathology report Whenpossibleastatementshouldbemadeofwhetherthe lesion isprimary orsecondarymelanoma. The report shouldincludethe following: Pathologicalstaging.StagingusingTNMandAJCC(Table4), Clinical information andcoding, e.g.SNOMED, shouldbegiven.41 (cid:1) Site of thetumour Growth phase. Invasive melanoma without a vertical (cid:1) Type of surgical procedure: excision or re-excision, growth phase (VGP) is termed microinvasion.43 The incision biopsy,punch biopsy assessment of microstaging criteria should be applied to (cid:1) Anyotherrelevant clinicalinformation the VGPonly. Regression. The presence or absence of tumour regression Macroscopic description has not been shown consistently to affect long-term outcome. Until its relevance is clear it should be reported Contour,colourandsizeofthetumourandtheexcisedskin as segmental replacement of melanoma by fibrosis, since specimenin millimetres. thisis subjectto less observervariation.44 Microscopy Tumour infiltrating lymphocytes (TILs). It remains unclear whether TILs have prognostic value.40 The categories absent, non-brisk and brisk are subject to wide observer Presence or absence of ulceration. Ulceration has progvariation. \u2018Absent\u2019 indicates no lymphocytes infiltrating nostic value, and its presence should be confirmed microamong the tumour cells, but does not exclude scopically as full-thickness loss of epidermis with reactive lymphocytes in the surrounding dermis. \u2018Non-brisk\u2019 is changeswhichincludeafibrinousexudateandattenuation a patchy or discontinuous infiltrate either among the or acanthosis of the adjacent epidermis.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 5}, {"text": "does not exclude scopically as full-thickness loss of epidermis with reactive lymphocytes in the surrounding dermis. \u2018Non-brisk\u2019 is changeswhichincludeafibrinousexudateandattenuation a patchy or discontinuous infiltrate either among the or acanthosis of the adjacent epidermis. These distinguish peripheral cells or in the centre of the tumour, whereas true ulcerationfrom artefact.39 \u2018brisk\u2019 is a continuous infiltrate but may be confined to peripheral cells. These are qualified as mild, moderate or severein intensity. Thickness. The tumour should be measured from the granular layer of the overlying epidermis to the deepest cells in the dermis judged to be malignant, to the nearest Lymphatic or vascular invasion. Vascular or lymphatic 0.1mm. Ulcerated tumours should be measured from the infiltrationhasprognosticvalue,anditspresenceshouldbe base of the ulcer. Tumour forming a sheath around recorded eventhough itis infrequentlyobserved.45 appendages should be excluded when measuring thickness Perineural infiltration. Perineural infiltration occurring except when the melanoma extends out into the adjacent beyond the main bulk of the tumour correlates with reticular dermis when it should be measured in the increasedlocalrecurrence.Itismostcommonlyassociated conventional manner. In the presence of histological with desmoplastic melanoma.46 regression thickness measurements should be of the residual melanoma. Microsatellites should not be included Microsatellites. These are defined as islands of tumour inthickness measurements (LevelIII, GradeB). >0.05mm in the tissue beneath the main invasive mass of melanoma, but separated from it by 0.3mm of normal Mitotic count. The number of mitoses has prognostic collagen (i.e. not tumour stroma or sclerosis of regresvalue and is now included in the American Joint sion).47 Current AJCC staging also requires that satellites Committee on Cancer (AJCC) staging system for must be intralymphatic, which has not previously been", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 5}, {"text": "1406 J.R.Marsden etal. Table4 The2009AmericanJointCommitteeonCancer(AJCC)stagingsystem. Stage Primarytumour(pT) Lymphnodes(LN) Metastases(M) IA <1mm,noulceration,mitosis <1/mm(cid:4)2 IB <1mm,withulcerationor mitoses(cid:5)1/mm(cid:4)2* 1.01e2mm,noulceration IIA 1.01e2mmwithulceration 2.01e4mm,noulceration IIB 2.01e4mm,withulceration >4mm,noulceration IIC >4mm,withulceration IIIA AnyBreslow\u2019sthickness,no Micro-metastases1e3nodes ulceration IIIB AnyBreslow\u2019sthickness,with Micro-metastases1e3nodes ulceration AnyBreslow\u2019sthickness,no 1e3palpablemetastaticnodes ulceration AnyBreslow\u2019sthickness,no Nonodes,butin-transitor ulceration satellitemetastasis/es IIIC AnyBreslow\u2019sthickness,with Upto3palpablelymphnodes ulceration AnyBreslow\u2019sthickness,with 4ormorenodesormatted orwithoutulceration nodesorin-transitdisease \u00felymphnodes AnyBreslow\u2019sthickness,with Nonodes,butin-transitor ulceration satellitemetastasis/es IV,M1a Skin,subcutaneousordistal nodaldisease IV,M1b Lungmetastases IV,M1c Allothersitesoranyothersites ofmetastases withraisedlactate dehydrogenase *Intherarecircumstanceswheremitoticcountcannotbeaccuratelydetermined,aClarklevelofinvasionofeitherIVorVcanbeused todefineT1bmelanoma.EverypatientwithmelanomashouldbeaccuratelystagedusingtheAJCCsystem;thismayincludeperforming asentinellymphnodebiopsywhenthisisrecommendedbytheSpecialistSkinCancerMultidisciplinaryTeam.Stagingshouldbeupdated followingrelapse. required; this may be subject to revision. Microsatellites Equivocal lesions are predictive of regional lymph node metastases; this is reflectedby stage N2c. Itmaynotbepossibletodistinguishpathologicallybetween amelanomaandabenignmelanocyticlesion.Suchpatients Precursornaevus.Thepresenceofcontiguousmelanocytic mustbereferredtotheSSMDTforclinicalandpathological naevusshouldberecorded. review.Adecisiontotreatasamelanomashouldbemade by the SSMDT in discussion with the patient. Thickness Clark level of dermal invasion. This is a less reliable indishould bemeasuredasfor melanoma. cator of prognosis than thickness and is subject to poor observeragreement.ItisnotusedtodefineT1melanomas Sentinel lymph node pathology inthe2009AJCCstagingsystem,exceptthatClarklevelsIV or V may be used for defining T1b melanoma in rare Pathological assessment instances when mitotic count cannot be determined in Thisneedstobedoneinastandardisedwaysothatfindings anon-ulcerated T1 melanoma. betweencentresarecomparable(LevelIII,GradeB). Requirements for microscopy of melanoma Dissection The dissection should be either by bivalving or multiple ThesearegiveninTable5. slicing, although the former is recommended.48e50", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 6}, {"text": "Revised UKguidelines forthe managementof cutaneousmelanoma 2010 1407 sentinellymphnodesand/ordistantmetastasesinpatients Table5 Requirementsformicroscopyofmelanoma. with primarymelanoma 53e58(Level IIa,GradeE). (cid:1) Ulceration (cid:1) Growthphase (cid:1) Thickness (cid:1) Regression Sentinel lymph node biopsy and ultrasound/fine (cid:1) Mitoticcounta (cid:1) Tumour-infiltrating (cid:1) Histologicsubtype lymphocytes needle aspiration cytology (cid:1) Marginsofexcision (cid:1) Lymphaticor (cid:1) Pathologicalstaging vascularinvasion Sentinel lymph node biopsy (SLNB), as discussed later, has (cid:1) Perineuralinvasion high sensitivity and specificity for diagnosing subclinical (cid:1) Microsatellitesb regionallymph node involvement. Ultrasoundandfineneedleaspirationcytology(FNAC)is a Mitoticcountisincludedinthe2009AJCCstagingsystem. the next best method but quoted sensitivities range from b Microsatellitesarenotincludedinthicknessmeasurement. 4.7% to 80%, with the higher sensitivities being achieved only by sentinel node mapping and FNAC of the sentinel node in all cases regardless of morphological appearA minimum of six serial sections should be taken, but ance.59e62 Further staging by CT imaging following a posia higher incidence of metastases is detected by extended tive sentinel lymph node, and prior to completion step sectioning with immunohistochemistry at each level. lymphadenectomy,hasaverylowyield.63e65Consequently The clinical relevance of the smaller metastases detected this should be done only after discussion with an informed by theseextended proceduresisstill unclear. patient andthe SSMDT(LevelIIa, GradeD). Staining Stage III and IV melanoma Use of haematoxylin and eosin and immunohistochemistry is essential. S100 and Melan A are most favoured immunoIn stage III and IV melanoma, imaging strategies will be histochemical stains but a composite method such as Panplannedby the SSMDT. Melis alsoappropriate. CTscanningofthehead,chest,abdomenandpelviswill normally adequately exclude metastases, and is most Assessment oftumour burden relevant in stage III melanoma before planning regional Thisgivesadditionalprognosticinformation.Thefollowing lymph node dissection and regional chemotherapy. If arerecommended: patients are considering entry to an adjuvant study Assessing the depth of the metastasis from the inner followinglymphadenectomy,the timingofscansshouldbe aspectofthesentinellymphnodecapsule;categorisingthe determinedby the SSMDT to avoidduplication. metastasis according to its site, either subcapsular or When stage IV disease is suspected clinically, CT scanparenchymal; measuring the maximum dimension of the ning of the head and whole body should be considered. largest confluent groupof melanoma cells.50e52 Further imaging will be determined by symptoms, clinical trial protocols, and for clarification or reassessment of Completion lymphadenectomy specimens previous imaging findings. Generally, the added yield of The pathological examination of regional nodes dissected PET/CT is unlikely to be clinically relevant in established following positive sentinel lymph node biopsy should stage IV melanoma (Level III, Grade D).", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 7}, {"text": "yield of The pathological examination of regional nodes dissected PET/CT is unlikely to be clinically relevant in established following positive sentinel lymph node biopsy should stage IV melanoma (Level III, Grade D). Where metainclude an attempt to examine all lymph nodes at least at stasectomy is planned, PET/CT may be useful in excluding onelevel,andcountthenumberinvolved.Thepresenceof disease that might make surgery inappropriate. Serum extracapsular spread and involvement of perinodal fat lactatedehydrogenase(LDH)shouldbedoneinallpatients should be recorded, together with the size of the tumourwith suspectedstage IVmelanoma. freemargin.TheuseofimmunohistochemistrysuchasS100 There is no indication for a bone scanin staging except orMelan Afacilitates this. where symptoms point to possible bone disease. Staging investigations aresummarisedin Table6. Investigations and imaging Treatment of the primary lesion Stage I and II melanoma Surgery is the only curative treatment for melanoma. Routine investigations are not required for asymptomatic Following excision for diagnosis and for measurement of patientswithprimarymelanoma.Bloodtestsareunhelpful. microscopic Breslow thickness, a widerand deeper margin Routinecomputedtomography(CT)isnotrecommended istakentoensurecompleteremovaloftheprimarylesion, forpatientswithstageIandIImelanomaasthishasavery and to remove any micro metastases. The depth of the low incidence of true-positive and high incidence of falsetherapeuticexcisionhasconventionallybeentothemuscle positive findings. Patients with particularly high-risk fascia or deeper, and there is no evidence to support primary melanoma may undergo staging investigations if alteringthis approach. deemedappropriatebytheSSMDTand/orasapre-requisite Lateral surgical excision margins for invasive melanoma to trial entry. There is no indication for routine imaging depend on Breslow thickness and are based on five randowith any other modality including plain X-ray, position mised controlled trials (RCTs) including about 3300 emission tomography (PET)/CT and magnetic resonance patients, and a National Institutes of Health Consensus imaging(MRI).PET/CTisnoteffectiveindetectingpositive Panel.66e73 However, only one of these studies is", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 7}, {"text": "1408 J.R.Marsden etal. difficult to detect. Topical treatment with imiquimod is Table6 Staginginvestigationsformelanoma. as yet of unproven value so should only be used in the (cid:1) Patients with stage I, II,and IIIA melanoma should not context of a clinical trial.82 If the patient with LM is routinelybestagedbyimagingorothermethodsasthe treated by non-surgical means then the reason for this truepositivepick-uprateislowandthefalse-positive choice should be discussed and clearly documented by rateishigh(LevelIIa,GradeE). the MDT. (cid:1) PatientswithstageIIIBorCshouldbeimagedbyCTof LocalrecurrenceofLMoccursinabout5%ofpatientsby head,chest,abdomenandpelvispriortosurgeryafter 2 years.77 Excision with micrographic control of surgical SSMDTreview(LevelIIa,GradeA). margins should be considered, although histological clear- (cid:1) Patients with stage IV melanoma should be imaged anceisoftendifficulttodefine.83Insitumelanomaonacral accordingtoclinicalneedandSSMDTreview.Lactate andgenitalskinisalsoassociatedwithahigherriskoflocal dehydrogenaseshouldalsobemeasured(LevelIII, recurrence,butthisislesscommoninothertypesofinsitu GradeA). melanoma. In theory, in situ melanoma should not metaSSMDT,SpecialistSkinCancerMultidisciplinaryTeam. stasise, but occasional cases do recur. This may be due to histological regressionobscuring a more advanced tumour, missed microinvasion, or progression after incomplete removal of insitu disease. adequately powered, and two provide little scope for detecting reduced disease-free or overall survival due to narrowmargins.68,69,71Mostexcludemelanomaonthehead Melanoma up to 1.0mm Breslow thickness andneckand/orextremities.74Arecentsystematicreview estimated overall survival in favour of wide excision There have been three RCTs of patients with melanomas (hazard ratio 1.04; 95% confidence interval 0.95e1.15; inthisthicknessband.66,68,69,73 Therecommendedsurgical PZ0.40), although the difference was not significant. margins are based on the World Health Organisation Thereforeasmall,butpotentiallyimportant,differencein (WHO) Melanoma Co-operative Group Trial 10.66,73 This overallsurvivalbetweenwideandnarrowexcisionmargins randomised trial compared 1cm and 3cm margins for cannot be confidently ruled out. Current randomised trial melanomas up to 2mm thick. No local metastases, and evidenceisinsufficienttoaddressoptimalexcisionmargins similar overall survival, were seen in patients with melaforprimary cutaneous melanoma.75 nomas < 1mm in depth with either excision margin. The recommended surgicalmargins arethosemeasured However, this was based on analysis of data from only 359 clinically at the time of surgery, but adequacy of excision patients. The French and Swedish studies compared 2cm shouldbe subsequentlyconfirmed by review of re-excision with 5cm margins, and the latter only included patients histology, making an adjustment for average shrinkage of with melanomas 0.8mm or more in thickness in this 20%.76Thefinaldecisionaboutthesizeofthemarginshould group.68,69 A 1cm margin is deemed safe for this group bemadebytheMDT,afterdiscussionwiththepatient.The (Level Ib, Grade A). recommendation should be made with consideration of functionalandcosmeticimplicationsofthemarginchosen. Melanoma 1.01e2.0mm Breslow thickness All patients with primary melanoma stage IB and higher shouldbereferredbeforetreatmenttoanSSMDTwhenthis There have been four randomised studies that have providesaSLNBservice.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 8}, {"text": "Grade A). recommendation should be made with consideration of functionalandcosmeticimplicationsofthemarginchosen. Melanoma 1.01e2.0mm Breslow thickness All patients with primary melanoma stage IB and higher shouldbereferredbeforetreatmenttoanSSMDTwhenthis There have been four randomised studies that have providesaSLNBservice.WhentheSSMDTdoesnotprovide included patients in this category. The WHO study showed this, all primary melanomas stage IIB or IIC should be a small excessof localmetastasis asfirst siteof relapse in referred. There are no RCT data for margin size for LM or the 1cm margins group.66,73 There was no difference in otherin situmelanoma. overall survival between 1 and 3cm margins but the study was inadequately powered to detect this. The Intergroup Lentigo maligna and in situ superficial MelanomaTrialcompared2vs.4cmmarginsofexcisionfor spreading melanoma lesionsof1e4mminthickness.67,70Nodifferencewasseen between the two groups in either local recurrence or LM and other in situ melanomas have no potential for survival. Two other studies have included patients with metastatic spread and the aim should be to excise the melanomas up to 2mm, also treated with either 2or 5cm lesioncompletelywithaclearhistologicalmargin,although margins.68,69Therewasnodifferenceinoutcomebetween margin size remains undefined. No further treatment is the groups. The 1 vs. 3cm, 2 vs. 4cm, and 2 vs. 5cm thenrequired. studies cannot be directly compared, but no study using LM is best treated by complete excision because of 2cm margins as one comparator has shown any advantage the risk of subclinical microinvasion. This may be missed of wider margins than this. However, narrower margins on incisional biopsy due to sampling error.73 The risk of trials have either not been performed (e.g. 1vs. 2cm progression to invasive melanoma is poorly quantified, margins) or have been underpowered, and do not permit and in the very elderly may be unlikely within their a definite conclusion that a 1cm margin is adequate. lifespan. Therefore, for some particular clinical situaEvidence to date shows that a minimum margin of 1cm is tions, treatment by other methods such as radiotherapy, required, although 2cm margins are equally appropriate. or observation only, may be appropriate.77e81 There is The final decision will be determined by anatomical site, little evidence to support the use of cryotherapy, and MDTreview, and after discussion with an informed patient this treatment may make subsequent progression (Level Ib,GradeA).", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 8}, {"text": "MDTreview, and after discussion with an informed patient this treatment may make subsequent progression (Level Ib,GradeA).", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 2, "page": 8}, {"text": "Revised UKguidelines forthe managementof cutaneousmelanoma 2010 1409 Melanoma 2.01e4.0mm Breslow thickness surgical treatment planning and investigations can run in parallel. There is no place for elective lymph node dissecThe Intergroup Melanoma Trial showed no difference in tioninthemanagementofprimarymelanomaunlessthisis rates of local metastasis between patients treated with unavoidable because the primary melanoma lies over the 2cm and those treated with 4cm margins.67 However, lymphnodebasin(LevelIb,GradeA).Patientsshouldhave longer follow-up showed reduced overall survival in the access to a skin cancer specialist nurse when relapse is 2cmmarginsgroup,althoughthisfelljustshortofreaching suspected. statistical significance.70 The results of a randomised trial with 3cm margins showed significantly increased rates of Clinically node-negative patients locoregional recurrence in patients treated with 1cm margins, and a reduction in melanoma-specific survival, SLNB was developed as a means of identifying the first again just short of significance, although no difference in lymph node draining the skin in which the melanoma arioverall survival.71 The significance of this is unclear, and ses.84 The procedure is carried out at the same time as the 2 vs. 4cm and 1 vs. 3cm trials cannot be directly definitive wider excision of the primary melanoma.85 SLNB compared. Until the resulting uncertainty is resolved, givesinformation aboutprognosis,andisincreasinglyused which may not happen as the number of patients required in conjunction with adjuvant therapy clinical trials. to detect a difference between 2 and 3cm margins is Patients with melanoma of Breslow thickness 1.2e3.5mm considerable, the default position should be to minimise and a positive SLNB have a 75% 5-year survival compared locoregional and distant metastatic risk. Therefore with 90% if the SLNB is negative.86 SLNB is normally aminimum2cmmarginisrequired inthisgroup,although considered for patients with melanoma (cid:5)1mm, when 3cm margins are equally appropriate. The final decision about20%arepositive;howevertheriskofapositiveSLNB will be determined by anatomical site, need for skin in a melanoma <1.0mm is still 5%.86,87 The procedure is grafting,MDTreview,andafterdiscussionwithaninformed associatedwitha5%morbidity,whichislessthanthatseen patient (LevelIb, GradeA). withcompletenodaldissection.Inpatientswithapositive SLNB, 20% have pathological evidence of metastases in Melanoma greater than 4mm in thickness additional regional nodes.84 Patients with a positive SLNB usually choose to proceed to completion lymphadenecTheriskoflocoregionalanddistantmetastasisis50%ormore tomy.Inabout5%itisnotpossibletoidentifythesentinel inthisgroup.Nonetheless,thesamesurgicalobjectivesapply node either on lymphoscintigraphy, at surgery, or both. tominimiselocoregionalanddistantmetastaticrisk.Thereis Patients should be aware of this limitation. The relevance onlyonerandomisedstudywhichincludesmelanomasthicker ofincreasinglydetailedevaluationofthesentinelnodeand than4mm.71Thistrialcompared1cmwith3cmmargins.The its correlation with prognosis remains to be defined.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 9}, {"text": "at surgery, or both. tominimiselocoregionalanddistantmetastaticrisk.Thereis Patients should be aware of this limitation. The relevance onlyonerandomisedstudywhichincludesmelanomasthicker ofincreasinglydetailedevaluationofthesentinelnodeand than4mm.71Thistrialcompared1cmwith3cmmargins.The its correlation with prognosis remains to be defined.88 resultsshowasignificantincreaseinlocoregionalrecurrence MSLT-1 showed no overall 5-year survival benefit following when1cmmarginsareused,andareductioninmelanomaSLNB and completion lymphadenectomy, and it is unclear specific survival just short of significance, although no whether SLNB improves local control of lymph node difference in overall survival. As there are no data that basins.85,86Afinalreportwithlongerfollow-upisawaited. margins smaller than 3cm are as effective, the evidence Recommendations for the management of clinically suggests3cmmarginsforthisgroup.Thereisnoevidencethat node-negative patients aresummarised inTable8. marginsgreaterthan3cmarerequired.Thefinaldecisionwill bedeterminedbyanatomicalsite,needforskingrafting,MDT Management of patients with clinically or review,andafterdiscussionwithaninformedpatient(Level radiologically suspicious lymph nodes Ib,GradeB). Recommendedsurgicalexcisionmarginsaresummarised FNACofnodesisrecommendedwhenthereisclinicaldoubt inTable 7. about the significance of the nodes. If there is a negative FNACresultbutongoingsuspicion,thentheFNAshouldbe Management of lymph node basins repeated oranimage-guided core biopsy arranged. Open biopsy is recommended when there is clinical Investigation and management of lymph node basins in suspicion even in the presence of negative FNACs in which melanomapatientsshouldbecarriedoutbySSMDTssothat lymphocytes have been successfully aspirated. If open Table7 Recommendedsurgicalexcisionmargins. Breslowthickness Excisionmargins Levelofevidence Gradingofevidence Insitu 5mmmarginstoachieve III B completehistological excision <1mm 1cm Ib A 1.01e2mm 1e2cm Ib A 2.1e4mm 2e3cm Ib A >4mm 3cm Ib B", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 9}, {"text": "1410 J.R.Marsden etal. (cid:1) A single positive superficial inguinal sentinel node Table 8 Recommendations for the management of clini- (Level Ib,GradeA). callynode-negativepatients. (cid:1) There is no role for elective lymph node dissection ApelvicLND shouldbeconsidered inthe presenceof: (LevelI,GradeE) (cid:1) Sentinel node biopsy can be considered in stage IB (cid:1) More than one 1 clinically palpable inguinal and/or melanomaandupwardsinSSMDTs(LevelIa,GradeA) femoral triangle node/s (cid:1) PatientsshouldbeintroducedtotheconceptofSLNBas (cid:1) CT or ultrasound evidence of more than one inguinal astagingprocedurebutshouldalsounderstandthatit and/or femoral triangle node/s, or of pelvic node hasnoproventherapeuticvalue involvement (cid:1) Surgical risks of SLNB, the possibility of failure to find (cid:1) More than onemicroscopicallyinvolved nodeat SLNB aSLN,andofafalse-negativeresult,shouldalsobe (cid:1) A conglomerate of inguinal or femoral triangle lymph explained nodes SNLB,sentinellymphnodebiopsy (cid:1) Microscopic or macroscopic involvement of Cloquet\u2019s node (Level III,GradeB). biopsy is performed, the incision must be such as to allow Cervical nodal recurrence should be treated by either subsequent complete formal block dissection of the surgeons in the SSMDT specialising in head and neck skin regionalnodeswithoutcompromise.Itshouldonlybedone cancerincludingmelanomaorbyaheadandneckMDTwith bySSMDT members.5 aspecialinterestinmelanoma.5Acomprehensive,andnot Exploration or removal of a mass within a nodal basin aselective,neckdissectionshouldbeperformed(LevelIII, which drains a known primary melanoma site, and prior to GradeA). The term\u2018comprehensive\u2019 allows either: definitive surgical treatment, may increase the risk of melanomarecurrenceinthatbasin.89Anymelanomapatient (cid:1) A radicaldissection of levels1e5 who develops a mass in a nodal basin should be referred (cid:1) Modified radical e the above, sparing spinal accessory urgently to the SSMDT, and without prior investigation, for nerve, internal jugular vein and sternocleidomastoid investigationandtreatmentplanning(LevelIII,GradeB). muscle (cid:1) Extendedradicaleradicaldissectionincludingparotid Management of patients with confirmed and/or posterior occipital chain. positive lymph node metastasis The risk of further locoregional recurrence is 16e32% despite comprehensive surgery.101,102 Radical lymph node dissections (LNDs) should only be performedbySSMDTmemberswhodoacombinedminimumof Locoregional recurrent melanoma: skin 15axillaryandgroinblockdissectionsforskincancereach year.5,90 and soft tissues Preoperativestaginginvestigationsshouldbecarriedout asalreadydiscussedforstageIIImelanoma.Ifsuchstaging Surgery is the treatment of choice for single local or is not feasible prior to surgery, and surgery is considered regional metastases. Excision should be clinically and necessary even if distant metastatic disease were to be histologically complete, but a wide margin is not detected,thena chestX-rayandLDH isrecommended. required. Multiple small (<1cm) dermal lesions respond The block dissection specimen should be marked and well to treatment with the CO laser.103 Dermal disease 2 orientated for the pathologist.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 10}, {"text": "dermal lesions respond The block dissection specimen should be marked and well to treatment with the CO laser.103 Dermal disease 2 orientated for the pathologist. Axillary LND for melanoma which is progressing despite surgery or laser, and shouldincludeallnodesinlevelsIeIII,andthismayrequire subcutaneous or deeper limb metastases, should be either resection or division of pectoralis minor. The considered for regional chemotherapy with isolated limb management of inguinal lymph node metastases is controinfusion (ILI) with melphalan and actinomycin D, or with versial. Between 30% and 44% of patients with clinically isolated limb perfusion (ILP) 104,105 (Level IIb, Grade B). involvedsuperficialinguinalnodeswillhaveinvolvedpelvic ILI is less invasive than ILP, and can be more easily nodes, and the risk increases with the number of involved repeated, but may be less effective.105 ILI is suitable for superficialnodes.91e97IfCloquet\u2019snodeispositivetherisk patientswithlowvolume(<5cm)diseaseandthosewith of pelvic node involvement ranges from 44% to 90%.93,96,97 co-morbidities which prevent ILP. Patients with bulky There is no reported increased morbidity associated with disease (>5cm) may be more likely to benefit from ILP combined pelvic and superficial node dissection.94 using melphalan with tumour necrosis factor, but Following ilioinguinal dissection for palpable inguinal arecenttrialcomparingthiscombinationwithmelphalan disease 5-year survival varies with extent of pelvic alone did not confirm additional benefit from adding involvement: 49% with one pelvic node, 28% with two to TNF.106 Radiotherapy may be considered for disease threenodes, and7% with morethan threenodes.97e100 which cannot otherwise be controlled. Selected patients suitable for ILI/ILP should be referred to specialised A superficial inguinal LND should be considered in the centres. The role of electrochemotherapy using intralepresenceof: sional or systemic bleomycin is still being evaluated. (cid:1) A single clinically involved inguinal node or femoral Recommendations for locoregional recurrent melanoma trianglenode are given in Table 9.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 10}, {"text": "Revised UKguidelines forthe managementof cutaneousmelanoma 2010 1411 lymphadenectomy.110Eligiblepatientswere(cid:5)1parotid,(cid:5)2 Table 9 Recommendations for locoregional recurrent cervicaloraxillaryor(cid:5)3groinnodes,orextranodalspread melanoma. of tumour, or node diameter (cid:5)3cm in neck or axilla or (cid:1) Nodes clinically suspicious for melanoma should be (cid:5)4cminthegroin.Interimresultsshowa15%improvement sampledusingfineneedleaspirationcytology(FNAC) in local control following radiotherapy, but there was no priortocarryingoutformalblockdissection.IfFNACis effect on overall survival. There are no data yet on negativealthoughlymphocyteswereseen,acoreor morbidity following this treatment, and so at present the openbiopsyshouldbeperformedifsuspicionremains risk:benefit of adjuvant radiotherapy is unclear. If there is (LevelIII,GradeB) clinicalorhistologicaldoubtabouttheadequacyofsurgery (cid:1) Prior to lymph node dissection, performed by an following recurrence, or about the feasibility of salvage expert,5stagingbyCTscanshouldbecarriedoutother surgery, adjuvant radiotherapy may be considered by the thanwherethiswouldmeanunduedelay(LevelIII, SSMDT (LevelIb,GradeB). GradeB) (cid:1) The treatment of locoregional recurrence in a limb is Occult primary melanoma palliative.Surgicalexcision,CO laser,orisolatedlimb 2 infusionorperfusionmaybeconsidered(LevelIIb, GradeB) Patients with occult primary melanoma may present with a solitary metastasis, lymph node disease, or systemic disease. Such patients should be referred promptly to the SSMDT for investigation and treatment planning. All Adjuvant therapy patients should have a thorough examination of the skin. Occultprimaryuvealtractmelanomanearlyalwayscauses There is no evidence of a survival benefit for adjuvant liver metastases before these are apparent at other sites; chemotherapy inpatients with melanoma.107 Thisincludes searchingforauvealtractprimaryinapatientwithoccult adjuvant regional chemotherapy using ILP, and therefore nodal disease is not appropriate. For patients presenting ILI.108 withinguinallymphadenopathy,examinationofthegenital Interferon has been evaluated in low-, intermediateand urinary tracts, and ano rectum is especially relevant. andhigh-riskpatientsusingvariousdosesandschedules.A AllpatientsshouldbestagedwithCTscansofhead,chest, recent individual patient data meta-analysis concluded abdomenandpelvis.Anumberofreportsfrominstitutionthatinterferonwasassociatedwithasignificantimpacton basedseriessuggestthatpatients presentingwithstage III relapse-free survival and a small effect on overall survival disease from an unknown primary have a better prognosis (5-year survival benefit 3%, P<0.05).109 However, the than patients with a similar stage and a known benefit was seen across all interferon regimens, and was primary.111,112 One published series suggested a survival greatestinthosewithulceratedmelanomas.Therewasno advantage in patients with stage IV disease from an clearindicationastooptimumdoseorduration.Theresults unknown primary compared with those with a declared areawaitedoffurtheranalysisincludingmorerecentdata. primary.113 Interferon is not recommended as standard of care for Patients presenting with lymph node disease from an adjuvant therapy of primary or stage III melanoma (Level occult primary involving a single lymph node basin, should Ia, Grade A).", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 11}, {"text": "primary involving a single lymph node basin, should Ia, Grade A). This is because its effect on disease-free be presumed to have regional rather than distant metassurvival is of uncertain clinical relevance, and although tasis, and treated as for stage III disease with lymph node overall survival is improved in meta-analysis, the effect is blockdissection. small and associated with significant drug toxicity. Prospective studies are required to establish whether Metastatic disease a subset of patients who derive most benefit can be identified. All patients should have access to a skin cancer clinical Clinical trials of adjuvant melanoma vaccines have not nurse specialist and a palliative care team providing so farbeensuccessful. expertise in symptom control and psychosocial support. Patients should be offered entry into adjuvant clinical Links should be made with community cancer support trials approved by the local Cancer Network. They should networks as soon as possible. All patients with metastatic have access to a melanoma specialist who is conversant disease should have access to an oncologist specialising in with currentmelanoma adjuvant trials, and whois ableto melanomafor managementadvice. ensuretheiraccesstosuchstudies.Detailsmaybefoundon Selected patients who relapse with oligometastatic thewebsitesoftheNationalCancerResearchNetwork,and disease may benefit from metastatectomy. Although this the European Organisation for Research and Treatment of has not been evaluated in a prospective randomised trial, Cancer. mediansurvivalof21monthsforselectedsurgicallytreated patients hasbeen reported114e119 (Level IIb,GradeB). Adjuvant radiotherapy No systemic therapy has been shown to extend survival significantly. Dacarbazine is a standard chemotherapy The Tasmanian Radiation Oncology Group (TROG) has outside a clinical trial, although its benefits are limited, completedarandomisedstudyofadjuvantradiotherapyto anditisineffectiveinbrainmetastases(LevelIIa,GradeC). dissectednodalbasins,48Gyin20fractions,in250patients The oral dacarbazine derivative temozolomide has greater with a high (>25%) risk of local recurrence following central nervous system (CNS) penetration but has not", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 11}, {"text": "1412 J.R.Marsden etal. shown significant clinical advantages over dacarbazine in Thereisnomedicalreasontojustifydelayingconception twomulticentreclinicaltrials.120,121Biochemotherapy(the afteradiagnosisofmelanoma(LevelIIa)butthesocialand addition of biologically active agents such as interferonfamily effects of developing recurrent melanoma during a and interleukin-2 to chemotherapy) increases response pregnancy or after birth are great.127,130 It is proper thereratesandtoxicitybutdoesnotsignificantlyincreaseoverall foretocounselawomaninthereproductiveagerangeabout survival.122 The same is true for combination chemoherriskofrecurrenceovertimesothatsheandherpartner therapy, and so this is not recommended other than in can make their decision about conception with adequate highly selected patients in whom palliation is dependent information. These social or familyconsiderations may also upon maximising response in symptomatic deposits. High berelevanttoamalepatientwhosepartnerispregnantorif doseinterleukin-2hasnotbeenevaluatedinarandomised heandhispartnerareconsideringapregnancy. phase III trial although a small minority of patients may There is no evidence that the use of the oral contraexperiencedurable completeresponses.123 ceptive pill plays any role in the natural history of melaPatientswithelevatedLDHhaveareducedlikelihoodof noma (Level Ia).130e133 Decisions about the use of the benefiting from currently available systemic treatment. contraceptive pill should be made on the basis of health Giventhelimitedbenefitswithstandardsystemictherapy, issues otherthan melanoma. all patients with metastatic melanoma should be considThereisnoevidencethathormonereplacementtherapy eredforentry into clinicaltrials of novel therapies. plays any role in the natural history of melanoma,130,132 Patients with CNS metastases have a poor prognosis. neither does it worsen prognosis in stage I and II melaSurgeryorstereotacticradiotherapyshouldbeconsidered noma (Level IIa).133 Decisions about use of hormone for selected patients with limited disease.114,115,124e126 replacementtherapyshouldbemadeonthebasisofhealth The benefits of treating patients with cerebral metasissues otherthan melanoma. tases with whole brain radiotherapy are limited, but may In pregnancy, staging using X-rays should be avoided sometimes have palliative value. Supportive care is wherepossible,especiallyinthefirsttrimester.MRIshould thereforethemostappropriatestrategyformanypatients beused inpreference to CTscan,wherefeasible. (Level IIb, Grade B). Because chemotherapy does not have a survival benefit Spinal cord compression should be treated surgically if in stage IV disease its use in pregnancy requires careful feasible, but multiple sites of disease, poor prognosis and discussion. Use of chemotherapy agents in the first poor performance status may make this inappropriate. trimestershouldbeavoided.Therearecasereportsofthe Radiotherapy may be useful for palliation of rapidly successful birth of normal babies who were exposed to enlarging or painful metastases involving soft tissues and dacarbazine in utero later in pregnancy, but this does not bones(LevelIIb,GradeB). exclude later toxicity. Melanoma can metastasise to the Recommendations for metastatic disease are shown in placenta and to the fetus more frequently than any other Table10. solidtumour.Thishasapoorprognosisforbothmotherand baby.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 12}, {"text": "later in pregnancy, but this does not bones(LevelIIb,GradeB). exclude later toxicity. Melanoma can metastasise to the Recommendations for metastatic disease are shown in placenta and to the fetus more frequently than any other Table10. solidtumour.Thishasapoorprognosisforbothmotherand baby. At delivery in patients with stage IV melanoma the Melanoma, hormone replacement therapy placenta should beexamined formelanoma. Recommendations regarding pregnancy and hormone and pregnancy replacement therapy aresummarisedin Table11. Thereisnoevidencethatmelanomaatornearthetimeof Use of drugs in melanoma patients pregnancy adversely affects prognosis.127 Breslow thickness, site and presence of ulceration are still the key determinants of outcome, and are not different from There are theoretical reasons to suggest that L-DOPA may have an adverse effect on patients with melanoma. There acontrolpopulation.128,132Theoutcomesofpregnancyfor are no data to support this idea however, and such an bothmotherandbaby arenotworsened(LevelIIa).128,129 association seems unlikely.134 The use of immunosuppresSurgical treatment should be determined in the normal santsaftermelanomaisacauseforconcern.Theresultsof way,buttherisksofexposuretoionisingradiationandblue a recent cohort study of patients with rheumatoid arthritis dye during sentinel node biopsy will need special consideration. Table 11 Recommendations regarding pregnancy and Table10 Recommendationsformetastaticdisease. hormonereplacementtherapy. (cid:1) All patients should be managed by Specialist Skin Pregnancywithprimarymelanoma CancerMultidisciplinaryTeams.5 (cid:1) Noworseningofprognosis (cid:1) Surgery should be considered for oligometastatic (cid:1) No increase in adverse outcomes for mother or baby diseaseatsitessuchastheskin,brainorbowel(Level Pregnancyinadvancedmelanoma IIb,GradeB),ortopreventpainorulceration. (cid:1) Placental and fetal metastases possible in stage IV (cid:1) Radiotherapy may have a palliative role in the treatdisease mentofmetastases(LevelII,GradeB). Oralcontraceptivesandmelanoma (cid:1) Standardchemotherapyisdacarbazinealthoughitsrole (cid:1) Noincreasedriskofmelanoma ispalliative(LevelII,GradeC). Hormonereplacementtherapy (cid:1) Patients withstageIV melanomashould beconsidered (cid:1) Noincreasedriskofmelanoma forentrytoclinicaltrials. (cid:1) Noworseningofprognosis", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 12}, {"text": "Revised UKguidelines forthe managementof cutaneousmelanoma 2010 1413 treated with biologic agents showed an increased risk of Care can be shared with primary care, but only if the melanoma (odds ration 2.3, 95% confidence interval secondarycareteamhasdefinedandexplainedtotheprimary 0.9e5.4).135However,thereisusuallylittlethatcanbedone careteamwhatisrequired,andonlyiftheprimarycareteam toavoidthesedrugswithoutanunacceptablelossofquality arepreparedtoacceptresponsibilityforthis.Intheeventof of life. Their use after treatment of primary or secondary suspectedrecurrence,evenafterdischargefromfollow-up,it melanoma should be discussed between the prescribing isrecommendedthatthepatientcontactthesecondarycare doctorsandpatients,andthedecisiontocontinuetheiruse teamdirectlytoavoidpossibledelayindiagnosis. and their dosage should be subject to ongoing review Screening asymptomatic clinically normal patients with followingadiagnosisofmelanoma(LevelIII,GradeC). lymph node ultrasound is sensitive and can detect nodal disease,butthishasnotbeenshowntobeusefulinprimary Organ and blood donation melanoma follow-up.142 The same applies to CT and PET imaging. These investigations should not be used outside Thedecisionaboutwhetherorgansortissuearesuitablefor a clinicaltrial. transplant is made on an individualised basis, taking into account the patient\u2019s medical history.136 A melanoma In situ melanoma patient wouldnotnormally beconsidered asa donor. Patients with a surgically treated single in situ melanoma Follow-up do not require follow-up, as there is no risk of metastasis. They require a return visit after complete excision to Therearethreemainreasonsforfollow-upaftertreatment explain the diagnosis, cheque the whole skin for further of primary cutaneous melanoma. The first is to detect primary melanoma/s, and to teach self-examination for recurrence when further treatment can improve the proga new primary melanoma. Clinical nurse specialist support nosis, the second is to detect further primary melanomas mayberequired despite the absenceof metastaticrisk. and the third is to provide support, information, and education. Theproportionof patientswith melanomawho Stage IA melanoma have impaired health-related quality of life is comparable to other cancers, and their needs for psychosocial support Patients with invasive primary cutaneous melanoma are likely to be similar.137 Provision of this is an important <1.0mmhavea5-yeardisease-freesurvivalofover90%or partofMDTmanagement.138TherearenoRCTswhichhave better. A recent review of 430 patients with melanomas formally evaluated follow-up. Numerous follow-up regi- <0.5mm showed no recurrences at 5e15 years follow-up mens have been reviewed but few are evidencebut 4% of patients developed a second primary melanoma based.139e141 Sixty-two percent of all recurrences were over this period.143 Patients with invasive, non-ulcerated detected by patients themselves in one review, but defiprimarytumours0.5e1.0mmthickhaveonlyslightlyworse nition of patient or doctor detection is unclear and other 5-year disease-free survival, and are in the same stage series emphasise the importance of physician-detected group. Therefore, for stage IA patients a series of two to recurrence.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 13}, {"text": "detection is unclear and other 5-year disease-free survival, and are in the same stage series emphasise the importance of physician-detected group. Therefore, for stage IA patients a series of two to recurrence.134 Patient opinion was equally divided as to fourvisitsoverupto 12monthsissuggested toteachselfwhether follow-up visits were reassuring or provoked examination,andthentheymaybedischargedfromregular further anxiety. There is little evidence of survival advanfollow-up (LevelIII, GradeB). tage following self-detection of metastases.139e141 Most first relapses occur in the 5 years following diagnosis, but Stage IB and IIA melanoma thereisasignificantriskoflaterfirstrelapse;bothpatients andtheirdoctors shouldbeaware of this. This group are at 15e35% risk of recurrence, but most of A primarymelanoma follow-up clinic shouldbeprovided thisriskisinyears2e4.OncetheyhavelearnthowtoselfbyanMDTofdermatologistsandsurgeonswithclinicalnurse examine for locoregional metastasis and new primaries, specialist support, and there should be continuity of care. andunderstandhowtopromptlyaccessthefollow-upteam Patientsshouldbetaughttoself-examinetodetectlocorefor suspected recurrence, they should be seen every 3 gionalrecurrenceandnewprimarymelanoma.Photography months for 3 years, then 6-monthly to 5 years. No routine canbeuseful for follow-upofpatientswho also haveatypinvestigations arerequired(Level III,GradeB). ical moles. Patients should routinely be examined for locoregional and distant metastases, and the whole skin Stage IIB and IIC melanoma should be checked for new primary melanomas. A defined rapid-access pathway must be provided to all patients and GPs for suspected recurrence. Suspected new primary Thisgroupareat40e70%riskofrecurrence.Mostofthisriskis melanomashouldbereferredasnormalthroughthe2-week in years 2e4. They should be taught self-examination and wait system. For Scotland this needs to be compliant with seen 3-monthly for 3 years, and 6-monthly to 5 years. No the62-dayrule.Follow-upofpatientswithAJCCstageIIIand routineinvestigationsarerequired(LevelIII,GradeB). IVdiseaseshouldbeledbymelanomaSSMDTs. Follow-up intervals and duration should be tailored to Sentinel lymph node biopsy thestagegroupoftheprimarymelanomaandthereforeto theriskofrecurrence.Thefollow-upplanshouldbeagreed PatientswhohavehadanegativeSLNBshouldbefollowed betweenthe patient andthe responsible doctors. uponthe basis of Breslow thickness.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 13}, {"text": "1414 J.R.Marsden etal. MostpatientswhohavehadapositiveSLNBwillhavehad Improving Outcomes Guidance for People with Skin a completion lymphadenectomy. As these patients now Tumours including Melanoma; February 2006, available have at least stage IIIA disease, their follow-up should be at: www.nice.org.uk/nicemedia/pdf/CSG_Skin_Manual/ supervised by the SSMDT, and entry into appropriate trials pdf] considered. Risk of recurrence depends on the extent of 2. Comparisonandappropriatenessofstatedclinical,and sentinellymphnodeinvolvement,andmaybelessthanfor measured histological, surgical margins [referenced to some with stage II melanoma. They should be followed up the standards described in theseguidelines] asforstagesIBeIICmelanoma (LevelIII, GradeB). 3. Useofinvestigationsatdiagnosisinprimarymelanoma by stage grouping [referenced to the standards Stage IIIB, IIIC, and resected stage IV melanoma described in theseguidelines] Theriskoffurthermetastasisinthisgroupishigh.Manywill Acknowledgements beeligibleforadjuvanttrials.Thoseoutsidetrialsshouldbe seen 3-monthly for 3 years from the date of staging, 6The authorship team would like to acknowledge the monthlyto5years,thenannuallyto10yearsbyanSSMDT. contribution to these guidelines made by the late Dr Neil Investigationsshouldbecarriedoutonthebasisofclinical Cox. Neil worked tirelessly to improve care for patients, need,andmayincludeCTsurveillanceifconsideredapproand his clear thinking, expert knowledge and generous priate by the SSMDT. This might be used to monitor a site nature wereinvaluable to us. Weshallmiss himgreatly. considered at high risk of relapse. The SSMDTwill need to balance the use of follow-up investigations for this group againsttheneedforearlydetectionoffurtherstagesIIIand Conflicts of interest IVdisease.Earlydetectionfacilitatesbotheffectivetreatmentandtrialentry(LevelIII,GradeB). None declared. Unresectable stage IV melanoma References These patients should be followed up and investigated by theSSMDTaccordingtoclinicalneed.Theymaybeeligible 1. Griffiths CE. The British Association of Dermatologists\u2019 forclinical trials. guidelinesforthemanagementofskindisease.BrJDermatol 1999;141:396e7. 2. Cox NH, Williams HC. The British Association of dermatoloClinical trials gists therapeutic guidelines: can we AGREE? Br J Dermatol 2003;148:621e5. Many patients will be in clinical trials. These will have 3. Guyatt GH, Oxman AD, Vist GE, et al. GRADE: an emerging definedfollow-up intervals which shouldbeadhered to. consensus on rating quality of evidence and strength of Follow-up for melanomais detailedin Table12. recommendations.BrMedJ2008;336:924e6. 4. Calman K, Hine D. Report by the Advisory Group on Cancer ServicestotheChiefMedicalOfficersofEnglandandWales. Table12 Follow-upformelanoma. DepartmentofHealth/WelshOffice;1995. (cid:1) Patientswithinsitumelanomasdonotrequirefollow5. National Institute for Health and Clinical Excellence (NICE). up ImprovingOutcomesGuidanceforPeoplewithSkinTumours (cid:1) Patientswithinvasivemelanomashavedifferingriskof including Melanoma, www.nice.org.uk/nicemedia/pdf/CSG_ Skin_Manual/pdf;February2006(lastaccessed25.05.10). relapseaccordingtotheirstagegroup 6. MarksR,WhitemanD.Sunburnandmelanoma:howstrongis (cid:1) PatientswithstageIAmelanomashouldbeseentwoto theevidence?BrMedJ1994;308:75e6. fourtimesoverupto12months,thendischarged 7. WhitemanD,GreenA.Melanomaandsunburn.CancerCauses (cid:1) PatientswithstageIBeIIIAmelanomashouldbeseen3Control1994;5:564e72. monthlyfor3years,then6-monthlyto5years 8. Armstrong BK. Epidemiology of malignant melanoma: inter- (cid:1) Patients with stage IIIB and IIIC and resected stage IV mittentortotalaccumulatedexposuretothesun?JDermatol melanomashouldbeseen3-monthlyfor3years,then6SurgOncol1988;14:835e49. monthlyto5years,thenannuallyto10years 9. Armstrong B, Kricker A. Sun exposure causes both non- (cid:1) PatientswithunresectablestageIVmelanomaareseen melanocytic skin cancer and malignant melanoma. Proc EnvironUVRadiatHealthEffects;1993:106e13.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 14}, {"text": "melanoma: inter- (cid:1) Patients with stage IIIB and IIIC and resected stage IV mittentortotalaccumulatedexposuretothesun?JDermatol melanomashouldbeseen3-monthlyfor3years,then6SurgOncol1988;14:835e49. monthlyto5years,thenannuallyto10years 9. Armstrong B, Kricker A. Sun exposure causes both non- (cid:1) PatientswithunresectablestageIVmelanomaareseen melanocytic skin cancer and malignant melanoma. Proc EnvironUVRadiatHealthEffects;1993:106e13. accordingtoneed 10. GandiniS, Sera F, Cattaruzza MS, et al. Meta-analysis of risk (LevelIII,GradeB) factors for cutaneous melanoma: III. Family history, actinic damageandphenotypicfactors.EurJCancer2005;41:2040e59. 11. Holick MF. High prevalence of vitamin D inadequacy and Audit points implicationsforhealth.MayoClinProc2006;81:353e73. 12. Newton-BishopJ,BeswickS,Randerson-MoorJ,etal.Serum 25-hydroxyvitamin D3 levels are associated with Breslow Thefollowing aresuggested points foraudit: thicknessatpresentation,andsurvivalfrommelanoma.JClin Oncol2009;27:5439e44. 1. TimelinessandappropriatenessofreferralfromLSMDTto 13. Randerson-Moor JA, Taylor JC, Elliott F, et al. Vitamin D SSMDT[referencedtothestandarddescribedinNational receptor gene polymorphisms, serum 25-hydroxyvitamin D Institute for Health and Clinical Excellence (NICE) levels and melanoma: UK case-control comparisons and", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 14}, {"text": "Revised UKguidelines forthe managementof cutaneousmelanoma 2010 1415 ameta-analysisofpublishedVDRdata.EurJCancer2009;45: 35. LedermanJS,SoberAJ.Doesbiopsytypeinfluencesurvivalin 3271e81. clinical stage I cutaneous melanoma? J Am Acad Dermatol 14. InternationalAgencyforResearchonCancerWorkingGroupon 1985;13:983e7. ArtificialUltravioletLightandSkinCancer.Theassociationof 36. Lees VC, Briggs JC. Effect of initial biopsy procedure on use of sunbeds with cutaneous melanoma and other skin prognosisinstageIinvasivecutaneousmalignantmelanoma: cancers:asystematicreview.IntJCancer2007;120:1116e22. reviewof1086patients.BrJSurg1991;78:1108e10. 15. McGovern TW, Litaker MS. Clinical predictors of malignant 37. AustinJR,ByersRM,BrownWD,etal.Influenceofbiopsyon pigmentedlesions.AcomparisonoftheGlasgowseven-point theprognosisofcutaneousmelanomaoftheheadandneck. checklist and the American Cancer Society\u2019s ABCDs of pigHeadNeck1996;18:107e17. mentedlesions.JDermatolSurgOncol1992;18:22e6. 38. AssociationofDirectorsofAnatomicandSurgicalPathology. 16. AbbasiNR,ShawHM,RigelDS,etal.EarlydiagnosisofcutaRecommendations for the reporting of tissues removed as neousmelanoma:revisitingtheABCDcriteria.JAmMedAssoc partofthesurgicaltreatmentofcutaneousmelanoma.Pathol 2004;292:2771e6. Int1998;48:168e70. 17. MacKie RM. Clinical Dermatology. 5th ed. Oxford University 39. SpatzA,CookM,ElderD,etal.Interobserverreproducibility Press;2003.p.345e346. of ulceration assessment in primary cutaneous melanomas. 18. DuVivierAWP,WilliamsHC,BrettJV,etal.Howdomalignant EurJCancer2003;39:1861e5. melanomas present and does this correlate with the seven40. GimottyPA,GuerryD,MingME,etal.Thinprimarycutaneous pointcheck-list?ClinExpDermatol1991;16:344e7. malignantmelanoma:aprognostictreefor10-yearmetastasis 19. Cox NH, Madan V, Sanders T, et al. skin cancer \u2018two-week ismoreaccuratethanAmericanJointCommitteeonCancer rule\u2019 proforma: assessment of potential modifications to staging.JClinOncol2004;22:3668e76. improvereferralaccuracy.BrJDermatol2008;158:1293e8. 41. EdgeSE,ByrdDR,ComptonCC,etal.,editors.Melanomaof 20. MeliaJ,CooperEJ,FrostT,etal.Cancerresearchcampaign theskin.AJCCCancerStagingManual.7thed.NewYork,NY: healtheducationprogrammetopromotetheearlydetection Springer;2009. ofcutaneousmalignantmelanoma.I.Work-loadandreferral 42. Hawkins WG, Busam KJ, Ben-Porat L, et al. Desmoplastic patterns.BrJDermatol1995;132:405e13. melanoma: a pathologically and clinically distinct form of 21. MeliaJ.Earlydetectionofcutaneousmalignantmelanomain cutaneousmelanoma.AnnSurgOncol2005;12:207e13. Britain.IntJEpidemiol1995;24:S39e44. 43. Elder DE, Murphy GF. Malignant Tumours (Melanoma and 22. MacKie RM, Hole D, HunterJAA,et al. Cutaneousmalignant RelatedLesions).ArmedForcesInstituteofPathology;1990. melanoma in Scotland: incidence, survival, and mortality, p.103e205. 1979e94.TheScottishMelanomaGroup.BrMedJ1997;315: 44. Kaur C, Thomas RJ, Desai N, et al. The correlation of 1117e21. regression in primary melanoma with sentinel lymph node 23. Braun RP, OlivieroM, KolmI. Dermoscopy: what\u2019s new?Clin status.JClinPathol2008;61:297e300. Dermatol2009;27:26e34. 45. Straume O, Akslan LA. Independent prognostic importance of 24. Melia J. Changing incidence and mortality from cutaneous vascularinvasioninnodularmelanoma.Cancer1996;78:1211e9. malignantmelanoma.BrMedJ1997;315:1106e7. 46. Quinn MJ, Crotty KA, Thomson JP, et al. Desmoplastic and 25. Parkin DM, Muir CS. Cancer incidence in five continents: desmoplastic neurotropic melanoma experience with 280 comparability and quality of data. IARC Sci Publ 1992;120: patients.Cancer1998;83:1128e35. 45e173. 47. HarristTJ,RigelDS,DayJrCL,etal.Microscopicsatellitesare 26. RhodesAR,WeinstockMA,FitzpatrickTB,etal.Riskfactors morehighlyassociatedwithregionallymphnodemetastases for cutaneous melanoma. A practical method of recognizing thanisprimarymelanomathickness.Cancer1984;53:2183e7. pre-disposedindividuals.JAmMedAssoc1987;258:3146e54. 48. CookMG,DiPalmaS.Pathologyofsentinellymphnodesfor 27. NewtonJA,BatailleV,GriffithsK,etal.Howcommonisthe melanoma.JClinPathol2008;61:897e902. atypical mole syndrome phenotype in apparently sporadic 49. CochranAJ.Surgicalpathologyremainspivotalintheevaluation melanoma?JAmAcadDermatol1993;29:989e96. of\u2018sentinel\u2019lymphnodes.AmJSurgPathol1999;23:1169e72. 28. Bataille V, Newton Bishop JA, Sasieni P, et al. Risk of cuta50. Starz H, Balda BR, Kramer KU, et al. A micromorphometryneousmelanomainrelationtothenumbers,typesandsitesof based concept for routine classification of sentinel lymph naevi:acase-controlstudy.BrJCancer1996;73:1605e11. node metastases and its clinical relevance for patients with 29. Le Mire L, Hollowood K, Gray D, et al. Melanomas in renal melanoma.Cancer2001;91:2110e21. transplantrecipients.BrJDermatol2006;154:472e7.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 15}, {"text": "et al. A micromorphometryneousmelanomainrelationtothenumbers,typesandsitesof based concept for routine classification of sentinel lymph naevi:acase-controlstudy.BrJCancer1996;73:1605e11. node metastases and its clinical relevance for patients with 29. Le Mire L, Hollowood K, Gray D, et al. Melanomas in renal melanoma.Cancer2001;91:2110e21. transplantrecipients.BrJDermatol2006;154:472e7. 51. Dewar DJ, Newell B, Green MA, et al. The microanatomic 30. Brown VL, Matin RN, Cerio R, et al. Melanomas in renal location of metastatic melanoma in sentinel lymph nodes transplant recipients: the London experience and invitation predicts nonsentinel lymph node involvement. J Clin Oncol toparticipateinaEuropeanstudy.BrJDermatol2007;156: 2004;22:3345e9. 165e7. 52. vanAkkooiAC,deWiltJH,VerhoefC,etal.TheRotterdam 31. MarghoobAA,SchoenbachSP,KopfAW,etal.Largecongencriteriaforsentinelnodetumorload:thesimplestprognostic ital melanocytic nevi and the risk for the development of factor?JClinOncol2008;26:2011. malignant melanoma. A prospective study. Arch Dermatol 53. BasseresN,GrobJJ,RichardMA,etal.Cost-effectivenessof 1996;132:170e5. surveillance of stage I melanoma. A retrospective appraisal 32. Illig L, Weidner F, Hundeiker M, et al. Congenital nevi less basedona10-yearexperienceinadermatologydepartment thanorequalto10cmasprecursorstomelanoma:52cases, inFrance.Dermatology1995;191:199e203. a review, and a new conception. Arch Dermatol 1985;121: 54. KhansurT,SandersJ,DasSK.Evaluationofstagingworkupin 1274e81. malignantmelanoma.ArchSurg1989;124:847e9. 33. GoldsteinAM,ChanM,HarlandM,etal.High-riskmelanoma 55. YancovitzM,FineltN, WarychaMA,etal.Roleofradiologic susceptibility genes and pancreatic cancer, neural system imaging at the time of initial diagnosis of stage T1b-T3b tumors, and uveal melanoma across GenoMEL. Cancer Res melanoma.Cancer2007;110:1107e14. 2006;66:9818e28. 56. Maubec E, Lumbroso J, Masson F, et al. F-18 fluorodeoxy-D34. LeachmanSA,CarucciJ,KohlmannW,etal.Selectioncriteria glucose positron emission tomography scan in the initial forgeneticassessmentofpatientswithfamilialmelanoma.J evaluationofpatientswithaprimarymelanomathickerthan AmAcadDermatol2009;61:677.e1e677.e14. 4mm.MelanomaRes2007;17:147e54.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 15}, {"text": "1416 J.R.Marsden etal. 57. ClarkPB,SooV,KrassJ,etal.FutilityoffluorodeoxyglucoseF 76. Silverman MK, Golomb FM, Kopf AW, et al. Verification of 18 positron emission tomography in initial evaluation of aformulafordeterminationofpre-excisionsurgicalmargins patientswithT2toT4melanoma.ArchSurg2006;141:284e8. fromfixed-tissuemelanomaspecimens.JAmAcadDermatol 58. Wagner JD, Schauwecker D, Davidson D, et al. Prospective 1992;27:214e9. study of fluorodeoxyglucose positron emission tomography 77. PrestonP,MateyP,MarsdenJR,etal.Surgicaltreatmentof imagingoflymphnodebasinsinmelanomapatientsundergoing lentigo maligna using 2mm excision margins. Br J Dermatol sentinellymphnodebiopsy.JClinOncol1999;17:1508e15. 2003;149:109e10. 59. vanRijkMC,TeertstraHJ,PeterseJL,etal.Ultrasonography 78. Mahendran RM, Newton-Bishop JA. Survey of U.K. current and fine-needle aspiration cytology in the preoperative practicein thetreatmentof lentigomaligna.Br J Dermatol assessment of melanoma patients eligible for sentinel node 2001;144:71e6. biopsy.AnnSurgOncol2006;13:1511e6. 79. Schmid-WendtnerMH,BrunnerB,KonzB,etal.Fractionated 60. SibonC,ChagnonS,Tchake\u00b4rianA,etal.Thecontributionof radiotherapy of lentigo maligna and lentigo maligna melahigh-resolution ultrasonography in preoperatively detecting nomain64patients.JAmAcadDermatol2000;43:477e82. sentinel-node metastases in melanoma patients. Melanoma 80. TsangRW,LiuFF,WellsW,etal.Lentigomalignaofthehead Res2007;17:233e7. and neck. Results of treatment by radiotherapy. Arch Der61. StarrittEC,UrenRF,ScolyerRA,etal.Ultrasoundexamination matol1994;130:1008e12. of sentinel nodes in the initial assessment of patients with 81. Pitman GH, Kopf AW, Bart RS, et al. Treatment of lentigo primarycutaneousmelanoma.AnnSurgOncol2005;12:18e23. maligna and lentigo maligna melanoma. J Dermatol Surg 62. Voit C, Kron M, Scha\u00a8fer G, et al. Ultrasound-guided fine Oncol1979;5:727e37. needle aspiration cytology prior to sentinel node biopsy in 82. RajparS,MarsdenJR.Imiquimodinthetreatmentoflentigo melanomapatients.AnnSurgOncol2006;13:1682e9. maligna.BrJDermatol2006;155:653e6. 63. Aloia TA, Gershenwald JE, Andtbacka RH, et al. Utility of 83. Walling HW, Scupham RK, Bean AK, et al. Staged excision computedtomographyandmagneticresonanceimagingstaging versusMohsmicrographicsurgeryforlentigomalignaandlenbeforecompletionlymphadenectomyinpatientswithsentinel tigomalignamelanoma.JAmAcadDermatol2007;57:659e64. lymphnode-positivemelanoma.JClinOncol2006;24:2858e65. 84. Morton DL, Wen DR, Wong JH, et al. Technical details of 64. Horn J, Lock-Anderson J, Sj\u00f8strand H, et al. Routine use of intraoperativelymphaticmappingforearlystagemelanoma. FDG-PET scans in melanoma patients with positive sentinel ArchSurg1992;127:392e9. nodebiopsy.EurJNuclMolImaging2006;33:887e92. 85. Morton D, Thompson J, Cochrane A, et al. Sentinel-node 65. Constantinidou A, Hofman M, O\u2019Doherty M, et al. Routine biopsyornodalobservationinmelanoma.NEnglJMed2006; positron emission tomography and emission tomography/- 355:1307e17. computed tomography in melanoma staging with positive 86. MortonDL,CochranAJ,ThompsonJF,etal.Sentinelnodebiopsy sentinel node biopsy is of limited benefit. Melanoma Res forearlystagemelanomaeAccuracyandmorbidityinMSLT-1, 2008;18:56e60. aninternationalmulticentretrial.AnnSurg2005;242:302e13. 66. VeronesiU,CascinelliN,AdamusJ,etal.ThinstageIprimary 87. Wright BE, Scheri RP, Ye X, et al. Importance of sentinel cutaneousmalignantmelanoma.Comparisonofexcisionwith lymphnodebiopsyinpatientswiththinmelanoma.ArchSurg marginsof1or3cm.NEnglJMed1988;318:1159e62. 2008;143:892e9. 67. Balch CM, Urist MM, Karakousis CP, et al. Efficacy of 2 cm 88. LiW,StallA,ShiversSC,etal.Clinicalrelevanceofmolecular surgical margins for intermediate-thickness melanomas staging for melanoma: comparison of RT-PCR and immuno- (1e4mm):resultsofamulti-institutionalrandomizedsurgical histochemistry staining in sentinel lymph nodes of patients trial.AnnSurg1993;218:262e7. withmelanoma.AnnSurg2000;231:795e803. 68. Cohn-CedermarkG,RutqvistLE,AnderssonR,etal.Longterm 89. NathansohnN,SchachterJ,GutmanH.Patternsofrecurrence resultsofarandomizedstudybytheSwedishMelanomaStudy in patients with melanoma after radical lymph node dissecGroupon2cmversus5cmresectionmarginsforpatientswith tion.ArchSurg2005;140:1172e7. cutaneousmelanomawithatumorthicknessof0.8e2.0mm. 90. NationalCancerPeerReviewProgramme.ManualforCancer Cancer2000;89:1495e501. Services 2008: Skin Measures. 08e2J-212.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 16}, {"text": "and immuno- (1e4mm):resultsofamulti-institutionalrandomizedsurgical histochemistry staining in sentinel lymph nodes of patients trial.AnnSurg1993;218:262e7. withmelanoma.AnnSurg2000;231:795e803. 68. Cohn-CedermarkG,RutqvistLE,AnderssonR,etal.Longterm 89. NathansohnN,SchachterJ,GutmanH.Patternsofrecurrence resultsofarandomizedstudybytheSwedishMelanomaStudy in patients with melanoma after radical lymph node dissecGroupon2cmversus5cmresectionmarginsforpatientswith tion.ArchSurg2005;140:1172e7. cutaneousmelanomawithatumorthicknessof0.8e2.0mm. 90. NationalCancerPeerReviewProgramme.ManualforCancer Cancer2000;89:1495e501. Services 2008: Skin Measures. 08e2J-212. Available at: 69. Khayat D, Rixe O, Martin G, et al. Surgical margins in cutahttp://www.library.nhs.uk/integratedSearch/viewResource. neousmelanoma(2cmversus5cmforlesionsmeasuringless aspx?resID=299673(lastaccessed25.05.10) than 2.1-mm thick). Long-term results of a large European 91. Karakousis CP, Emrich LJ, Rao U, et al. Groin dissection in MulticentricPhaseIIIstudy.Cancer2003;97:1941e6. malignantmelanoma.AmJSurg1986;152:491e5. 70. Balch C, Soong SJ, Smith T, et al. Long term results of 92. BadgwellB,XingY,GershenwaldJE,etal.Pelviclymphnode aprospectivesurgicaltrialcomparing2cmvs.4cmexcision dissectionisbeneficialinsubsetsofpatientswithnode-posimarginsfor740patientswith1e4mmmelanomas.AnnSurg tivemelanoma.AnnSurgOncol2007;14:2867e75. Oncol2001;8:101e8. 93. Finck SJ, Giuliano, Mann BD, et al. Result of ilioinguinal 71. ThomasJ,Newton-BishopJ,A\u2019HernR,etal.Excisionmargins dissectionforstageImelanoma.AnnSurg1982;196:180e6. in high-risk malignant melanoma. N Engl J Med 2004;350: 94. Sterne GD, Murray DS, Grimley RP. Ilioinguinal block dissec757e66. tionformalignantmelanoma.BrJSurg1995;82:1057e9. 72. NIHConsensusConference.Diagnosisandtreatmentofearly 95. EssnerR,ScheriR,KavanaghM,etal.Surgicalmanagementof melanoma.JAmMedAssoc1992;268:1314e9. the groin lymph nodes in melanoma in the era of sentinel 73. Veronesi U, Cascinelli N. Narrow excision (1 cm margin) e lymphnodedissection.ArchSurg2006;141:877e82. a safe procedure for thin cutaneous melanoma. Arch Surg 96. ShenP,ConfortiA,EssnerR,etal.Isthenodeofcloquetthe 1991;126:438e41. sentinelnodefortheiliac/obturatornodegroup?TheCancer 74. Lens M, Nathan P, Bataille V. Excision margins for primary J2000;6:93e7. cutaneousmelanoma:updatedpooledanalysisofrandomized 97. HughesTMD,A\u2019HernR,ThomasJ,etal.Prognosisandsurgical controlledtrials.ArchSurg2007;142:885e91. management of patients with palpable inguinal lymph node 75. SladdenMJ,BalchC,BarzilaiDA,etal.Surgicalexcisionmargins metastasesfrommelanoma.BrJSurg2000;87:892e901. forprimarycutaneousmelanoma.CochraneDatabaseSystRev; 98. KarakousisCP,DriscollDL.Positivedeepnodesinthegroinand 2009;.doi:10.1002/14651858.CD004835.pub2.CD004835. survivalinmalignantmelanoma.AmJSurg1996;171:421e2.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 16}, {"text": "Revised UKguidelines forthe managementof cutaneousmelanoma 2010 1417 99. Balch CM, Ross MI. Melanoma patients with iliac nodal 120. MiddletonMR,GrobJJ,AaronsonN,etal.Randomizedphase metastasescanbecured.AnnSurgOncol1999;6:230e1. IIIstudyoftemozolomideversusdacarbazineinthetreatment 100. StrobbeLJA,JonkA,etal.Positiveiliacandobturatornodes of patients with advanced metastatic malignant melanoma. inmelanoma:survivalandprognosticfactors.AnnSurgOncol JClinOncol2000;18:158e66. 1999;6:255e62. 121. PatelP,SuciuS,MortierL,etal.Extendedscheduleescalated 101. O\u2019BrienCJ,CoatesAS,Petersen-SchaeferK,etal.Experience dosetemozolomideversusdacarbazineinstageIVmalignant with998cutaneousmelanomaoftheheadandneckover30 melanoma; final results of the randomised phase 3 study years.AmJSurg1991;182:86e91. (EORTC18032).AnnOncol2009;19:viii3. 102. TurkulaLD,WoodsJ.Limitedorselectivenodaldissectionfor 122. IvesNJ,StoweRL,etal.Chemotherapycomparedwithbiomalignantmelanomaoftheheadandneck.AmJSurg1984; chemotherapy for the treatment of metastatic melanoma: 148:446e8. a meta-analysis of 18 trials involving 2,621 patients. J Clin 103. HillS,ThomasJM.Useofthecarbondioxidelasertomanage Oncol2007;25:5426e34. cutaneous metastases from malignant melanoma. Br J Surg 123. Tarhini AA, Kirkwood JM, et al. Durable completeresponses 1996;83:509e12. withhigh-dosebolusinterleukin-2inpatientswithmetastatic 104. ThompsonJF,KamPC.Currentstatusofisolatedlimbinfusion melanoma who have experienced progression after biowith mild hyperthermia for melanoma. Int J Hyperthermia chemotherapy.JClinOncol2007;25:3802e7. 2008;24:219e25. 124. ZacestAC,BesserM,StevensG,etal.Surgicalmanagement 105. BeasleyGM,PetersenRP,YooJ,etal.Isolatedlimbinfusionof of cerebral metastases from melanoma: outcome in 147 in-transit malignant melanoma of the extremity: a wellpatients treated at a single institution over two decades. tolerated butless effective alternativeto hyperthermic isoJNeurosurg2002;96:552e8. latedlimbperfusion.AnnSurgOncol2008;15:2195e205. 125. Mori Y,KondziolkaD, FlickingerJC, etal. Stereotacticradi106. Cornett WR, McCall LM, Petersen RP, et al. Randomized osurgeryforcerebralmetastaticmelanoma:factorsaffecting multicentertrialofhyperthermicisolatedlimbperfusionwith localdiseasecontrolandsurvival.IntJRadiatOncolBiolPhys melphalan alone compared with melphalan plus tumor 1998;42:581e9. necrosisfactor:AmericanCollegeofSurgeonsOncologyGroup 126. SelekU,ChangE,Hassenbusch3rdSJ,etal.StereotacticradiTrialZ0020.JClinOncol2006;24:4196e201. osurgicaltreatmentin103patientsfor153cerebralmelanoma 107. VeronesiU,AdamusJ,AubertC,etal.Arandomisedtrialof metastases.IntJRadiatOncolBiolPhys2004;59:1097e106. adjuvant chemotherapy and immunotherapy in cutaneous 127. LensMB,RosdahlI,AhlbomA,etal.Effectofpregnancyon melanoma.NEnglJMed1982;307:913e6. survival in women with cutaneous malignant melanoma. 108. KoopsHS,VagliniM,SuciuS,etal.Prophylacticisolatedlimb JClinOncol2004;22:4369e75. perfusion for localized, high-risk limb melanoma: results of 128. O\u2019Meara AT, Cress R, Xing G, et al. Malignant melanoma in a multicenter randomized phase III trial. European Organipregnancy.Apopulation-basedevaluation.Cancer2005;103: zation for Research and Treatment of Cancer Malignant 1217e26. Melanoma Cooperative Group Protocol 18832, the World 129. Daryanani D, Plukker JT, De Hullu JA, et al. Pregnancy and Health Organization Melanoma Program Trial 15, and the early-stagemelanoma.Cancer2003;97:2248e53. NorthAmericanPerfusionGroupSouthwestOncologyGroup130. NaldiL,AltieriA,ImbertiGL,etal.Oncologystudygroupofthe 8593.JClinOncol1998;16:2906e12. Italian Group for Epidemiologic Research in Dermatology 109. WheatleyK,IvesN,EggermontA,etal.Adjuvanttherapyfor (GISED).Cutaneousmalignantmelanomainwomen.Phenotypic melanoma:anindividualpatientmeta-analysisofrandomised characteristics, sun exposure, and hormonal factors: a casetrials.JClinOncol2007;25:8526. controlstudyfromItaly.AnnEpidemiol2005;15:545e50. 110. Henderson MA, Burmeister B, Thompson JF, et al. Adjuvant 131. KaragasMR,StukelTA,DykesJ,etal.Apooledanalysisof10 radiotherapyandregionallymphnodefieldcontrolinmelanoma case-controlstudiesofmelanomaandoralcontraceptiveuse. patients after lymphadenectomy: Results of an intergroup BrJCancer2002;86:1085e92. randomizedtrial.JClinOncol2009;27:18[Suppl;abstrLBA9084]. 132. LeaCS,HollyEA,HartgeP,etal.Reproductiveriskfactorsfor 111. Chang P, Knapper WH. Metastatic melanoma of unknown cutaneous melanoma in women: a case-control study. Am J primary.Cancer1982;49:1106e11. Epidemiol2007;165:505e13. 112. LeeCC,FariesMB,WanekLA,etal.Improvedsurvivalafter 133. MacKie RM, Bray CA. Hormone replacement therapy after lymphadenectomy for nodal metastasis from an unknown surgery for stage I or II cutaneous melanoma. Br J Cancer primarymelanoma.JClinOncol2008;26:535e41. 2004;90:770e2. 113. Lee CC, Faries MB, Wanek LA, et al.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 17}, {"text": "CA. Hormone replacement therapy after lymphadenectomy for nodal metastasis from an unknown surgery for stage I or II cutaneous melanoma. Br J Cancer primarymelanoma.JClinOncol2008;26:535e41. 2004;90:770e2. 113. Lee CC, Faries MB, Wanek LA, et al. Improved survival for 134. SipleJ,SchneiderD,WanlassW,etal.Levodopatherapyand stage IV melanoma from an unknown primary site. J Clin riskofmalignantmelanoma.AnnPharm2000;34:382e5. Oncol2009;27:3489e95. 135. Wolfe F, Michaud K. Biologic treatment of rheumatoid 114. PatchellRA,TibbsPA,WalshJW,etal.Arandomisedtrialof arthritisandtheriskofmalignancy:analysesfromalargeUS surgery in the treatment of single metastases of the brain. observationalstudy.ArthritisRheum2007;56:2886e95. NEnglJMed1990;322:494e500. 136. NHS Blood and Transplant. Organ Donation: How to Become 115. Miller JD. Surgical excision for single cerebral metastasis? a Donor. www.organdonation.nhs.uk/ukt/how_to_become_ Lancet1993;341:1566. a_donor/how_to_become_a_donor.jsp (last accessed 116. SondakV,LiuP,WarnekeJ,etal.Surgicalresectionforstage 25.05.10). IV melanoma: a Southwest Oncology Group Trial (S9430). 137. Cornish D, Holterhues C, van der Poll-Franse L, et al. A JClinOncol2006;24(Suppl.):8019(Abstract). systematic review of health-related quality of life in cuta117. Overett TK, Shiu MH. Surgical treatment of distant metaneousmelanoma.AnnOncol2009;20(Suppl6):51e8. static melanoma. Indications and results. Cancer 1985;56: 138. Sollner W, Zschocke I, Augustin M. Melanoma patients: 1222e30. psychosocialstress,copingwithillnessandsocialsupport.A 118. MeyerT,MerkelS,GoehlJ,etal.Surgicaltherapyfordistant systematicreview.PsychotherPsychosomMedPsychol1998; metastases of malignant melanoma. Cancer 2000;89: 48:338e48. 1983e91. 139. FranckenAB,BastiannetE,HoekstraHJ.Followupinpatients 119. EssnerR,LeeGH,etal.Contemporarysurgicaltreatmentof with localized primary melanoma. Lancet Oncol 2005;6: advanced-stagemelanoma.ArchSurg2004;139:961e7. 608e21.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 17}, {"text": "1418 J.R.Marsden etal. 140. Hofmann U, Szedlak M, Ritgen W, et al. Primary staging and (cid:1) Patients with giant congenital pigmented naevi are at follow up in melanoma patients-monocenter evaluation of increasedriskofmelanomaandrequirelong-termfollowmethodscostsandpatientsurvival.BrJCancer2002;87:151e7. up 141. GarbeC,HauschildA,VolkenandtM,etal.Evidenceandinter- (cid:1) The prophylactic excision of small congenital naevi is disciplinaryconsensus-basedGermanguidelines:diagnosisand not recommended surveillanceofmelanoma.MelanomaRes2007;17:393e9. (cid:1) Individualswithafamilyhistoryofthreeormorecases 142. BalfountaML,BeauchetA,ChagnonS,etal.Ultrasonography ofmelanomashouldbereferredtoaClinicalGeneticist or palpation for detection of melanoma nodal invasion: ameta-analysis.LancetOncol2004;5:673e80. or specialised dermatology services for counselling. 143. Einwachter-Thompson J, MacKie RM. An evidence base for Those with two cases in the family may also benefit, reconsidering current follow-up guidelines for patients with especially if one of the cases had multiple primary cutaneousmelanomalessthan0.5mmthickatdiagnosis.BrJ melanomas or theatypical mole syndrome Dermatol2008;159:337e41. Requirements for microscopy of melanoma Summary of 2010 guidelines for management of melanoma Essential Ulceration Thickness Mitoticcount Histological Marginsof Pathological (See full manuscript for details of evidence and recomsubtype excision staging mendation gradings) DesirableLevelofdermal Growthphase Regression Melanoma patients who must be referred from the invasion Local Skin Cancer Multidisciplinary Team to the TumourinfiltratingLymphaticor Specialist Skin Cancer Multidisciplinary Team lymphocytes vascularinvasion Perineural Microsatellites (cid:1) PatientswithstageIBorhigherprimarymelanomawhen invasion sentinel lymph node biopsy (SLNB) is available within theirNetwork.IntheabsenceofSLNBthenpatientswith Surgical wider excision margins for primary stageIIBorhighershouldbereferredtotheSSMDT melanoma (cid:1) Patients with melanoma stage I or above who are eligible for clinical trials that have been approved at CancerNetwork level Breslowthickness Lateralexcisionmargins (cid:1) Patients with melanoma managed by other site tomuscleormusclefascia specialist teams, e.g. gynaecological, mucosal and headandneck(excluding ocular) Insitu 5mmmarginstoachievecomplete (cid:1) Patientswith multipleprimary melanomas histologicalexcision (cid:1) Childrenyounger than19 yearswith melanoma <1mm 1cm (cid:1) Any patient with metastatic melanoma diagnosed at 1.01e2mm 1e2cm presentationoron follow-up 2.1e4mm 2e3cm (cid:1) Patients with giant congenital naevi where there is >4mm 3cm suspicionof malignanttransformation (cid:1) Patients with skin lesions of uncertain malignant Staging investigations for melanoma potential Recommendations for Local Skin Cancer (cid:1) StagesI,II,andIIIAmelanomapatientsshouldnotroutinely Multidisciplinary Team record keeping of clinical bestagedbyimagingorothermethodsasthetrue-positive features pick-uprateislowandthefalse-positiverateishigh (cid:1) StagesIIIBorCpatientsshouldbeimagedbyCTpriorto surgery andwith SSMDTreview See:National Institute for Health and Clinical Excellence (cid:1) Stage IV melanoma patients should be imaged accord- (NICE) Improving Outcomes Guidance for People with ing to clinical need and SSMDT review; lactate dehySkinTumoursincludingMelanoma.February2006.www. drogenase shouldalso bemeasured nice.org.", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 18}, {"text": "dehySkinTumoursincludingMelanoma.February2006.www. drogenase shouldalso bemeasured nice.org.uk/nicemedia/pdf/CSG_Skin_Manual/pdf Recommendationsforthemanagementofclinically node-negative patients Recommendationsforscreeningandsurveillanceof high-risk individuals (cid:1) There is no role for elective lymph node dissection (Level I,GradeE) (cid:1) Patients who are at moderately increased risk of (cid:1) Sentinellymphnodebiopsy(SLNB)canbeconsideredin melanomashouldbeadvisedofthisandtaughthowto stage IB melanoma and upwards in SSMDTs (Level IIa, self-examine.Thisincludespatientswithatypicalmole GradeC) phenotype,thosewithapreviousmelanoma,andorgan (cid:1) SLNBisastagingprocedurewithnoproventherapeutic transplantrecipients value", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 1, "page": 18}, {"text": "Revised UKguidelines forthe managementof cutaneousmelanoma 2010 1419 (cid:1) Surgical risks of SLNB, and of a false-negative result, Follow-up of melanoma patients shouldalso beexplained (cid:1) Patientswithinsitumelanomasdonotrequirefollow-up Recommendations for locoregional recurrent (cid:1) Patients with stage IA melanomas should be seen melanoma two to four times over up to 12 months then discharged (cid:1) All patients shouldbemanagedby SSMDTs. (cid:1) Patients with stage IBeIIIA melanomas should be (cid:1) Nodes clinically suspicious for melanoma should be seen 3-monthly for 3 years, then 6-monthly to 5 sampled using fine needle aspiration cytology (FNAC) years priortocarryingoutformalblockdissection.IfFNACis (cid:1) Patients with stage IIIB and IIIC and resected stage negative although lymphocytes were seen, a core or IV melanoma should be seen 3-monthly for 3 open biopsy shouldbeperformed ifsuspicion remains years, 6-monthly to 5 years, then annually to 10 (cid:1) Prior to formal dissection, performed by an expert, years staging by CT scan should be carried out other than (cid:1) Patients with unresectable stage IV melanomas are wherethiswouldmeanunduedelay(LevelIII,GradeB) seenaccordingto need (cid:1) The treatment of locoregional limb recurrence is palliative and, depending on extent and response, includes Appendix 1. Definition of the levels of excisionorCO laser,isolatedlimbinfusionorperfusion 2 evidence used in preparation of the guidelines Recommendations for metastatic disease (cid:1) All patients shouldbemanagedby SSMDTs (cid:1) Surgery should be considered for oligometastatic Level Typeofevidence disease at sites such as the skin, brain or gut, or to Ia Evidenceobtainedfrommeta-analysisof prevent painorulceration (cid:1) Radiotherapy may have a palliative role in the treatrandomisedcontrolledtrials,ormeta-analysisof epidemiologicalstudies ment ofmetastases (cid:1) Standardchemotherapyisdacarbazinealthoughitsrole Ib Evidenceobtainedfromatleastonerandomised controlledtrial ispalliative (cid:1) Patients with stage IV melanoma should be considered IIa Evidenceobtainedfromatleastonewell-designed controlledstudywithoutrandomisation for entry toclinical trials IIb Evidenceobtainedfromatleastoneothertypeof well-designedquasi-experimentalstudy III Evidenceobtainedfromwell-designednonPregnancy, oral contraceptives and hormone experimentaldescriptivestudies,suchas replacement therapy comparativestudies,correlationstudiesandcase studies IV Evidenceobtainedfromexpertcommitteereports oropinionsand/orclinicalexperienceof Pregnancyin Oral Hormone respectedauthorities melanoma contraceptives replacement therapy Gradeofrecommendation Noworseningof Noincreased Noincreased A Thereisgoodevidencetosupporttheuseofthe prognosis riskofmelanoma riskofmelanoma procedure Noincreasein Noworseningof B Thereisfairevidencetosupporttheuseofthe adverse prognosis procedure outcomesfor C Thereispoorevidencetosupporttheuseofthe motherorbaby procedure Placental D Thereisfairevidencetosupporttherejectionof metastases theuseoftheprocedure possiblein E Thereisgoodevidencetosupporttherejectionof stageIVdisease theuseoftheprocedure", "source": "revised UK guideliens for the management of cutaneous melanoma 2010.pdf", "chunk_id": 0, "page": 19}, {"text": "BJD GUIDELINES British Journal of Dermatology British Association of Dermatologists\u2019 guidelines for the care of patients with actinic keratosis 2017 D. de Berker,1 J.M. McGregor,2 M.F. Mohd Mustapa,3 L.S. Exton3 and B.R. Hughes4 1Bristol Dermatology Centre, University HospitalsBristol, Bristol BS2 8HW,U.K. 2Departmentof Dermatology, BartsHealth NHS Trust, LondonE11BB, U.K. 3British Associationof Dermatologists, WillanHouse, 4 Fitzroy Square,London W1T 5HQ, U.K. 4PortsmouthDermatology Centre, PortsmouthHospitals NHSTrust, PortsmouthPO3 6AD, U.K. Correspondence DaviddeBerker. 1.0 Purpose and scope E-mail:guidelines@bad.org.uk The overall objective of the guideline is to provide up-to-date, Acceptedfor publication evidence-based recommendations for the management of acti7September2016 nickeratosis (AK). The document aims (i) to offer an appraisal of all relevant Fundingsources literature up to February 2016, focusing on any key developNone. ments; (ii) to address important, practical clinical questions relating to the primary guideline objective, including accurate Conflictsof interest diagnosis and suitable treatment; (iii) to provide guideline Nonedeclared. recommendations and, where appropriate, some health ecoD.deB.,J.M.M.andB.R.H.aremembersoftheGuidelineDevelopmentGroup,with nomicimplications; and(iv)todiscuss potentialdevelopments technicalsupportfromL.S.E.andM.F.M.M. andfuture directions. The guideline is presented as a detailed review with highThisisanupdatedguidelinepreparedfortheBritishAssociationofDermatologists lighted recommendations for practical use in the clinic (see (BAD)ClinicalStandardsUnit,whichincludestheTherapy&Guidelines(T&G)SubSection 13.0), in addition to an updated patient information committee.MembersoftheClinicalStandardsUnitwhohavebeeninvolvedareP.M. leaflet (PIL), available at the British Association of DermatoloMcHenry(ChairmanT&G),K.Gibbon,D.A.Buckley,I.Nasr,C.E.DuarteWilgists (BAD) website (http://www.bad.org.uk/for-the-public/ liamson,V.J.Swale,T.A.Leslie,E.C.Mallon,S.Wakelin,S.Ungureanu,R.Y.P. patient-information-leaflets). Hunasehally,M.Cork,G.A.Johnston,J.Natkunarajah,F.S.Worsnop,N.Chiang,J. Donnelly(BritishNationalFormulary),C.Saunders(BritishDermatologicalNursing Group),A.G.Brian(BADScientificAdministrator),L.S.Exton(BADInformation 1.1 Exclusions Scientist)andM.F.MohdMustapa(BADClinicalStandardsManager). This guideline does not cover bowenoid AK or actinic Guidelinesproducedin2007bytheBritishAssociationofDermatologists;reviewedand cheilitis. updated,2016. 2.0 Stakeholder involvement and peer review DOI10.1111/bjd.15107 NICEhasrenewedaccreditationoftheprocessusedbytheBritishAssociaTheGuidelineDevelopmentGroup(GDG)consistedofconsultionofDermatologiststoproduceclinicalguidelines.Therenewedaccreditationisvaliduntil31May2021andappliestoguidanceproducedusing tant dermatologists. The draft document was circulated to the theprocessesdescribedinUpdatedguidanceforwritingaBritishAssociationofDermatologistsclinicalguideline\u2013theadoptionoftheGRADE BAD membership, the British Dermatological Nursing Group, methodology2016.Theoriginalaccreditationtermbeganon12May 2010.Moreinformationonaccreditationcanbeviewedatwww.nice. the Primary Care Dermatological Society, the British Society org.uk/accreditation. for Skin Care in Immunosuppressed Individuals, and Age U.K. for comments. These comments were actively considered by the GDG, and peer reviewed by the Clinical Standards Unit of the BAD (made up of the Therapy & Guidelines Subcommittee) priortopublication. 3.0 Methodology This set of guidelines has been developed using the BAD recommended methodology,1 with reference to the Appraisal of Guidelines Research and Evaluation (AGREE II) instrument 20 BritishJournalofDermatology(2017)176,pp20\u201343 \u00a92017BritishAssociationofDermatologists Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 1}, {"text": "Guidelinesforactinickeratosis2017, D.deBerkeretal. 21 (www.agreetrust.org).2 Recommendations were developed for feature of an AK is epithelial dysplasia. This may be restricted implementation in the National Health Service (NHS) using a to the basal layer or may extend to full-thickness atypia, at process of considered judgement based on the evidence. The which point the lesion is known as SCC in situ (Bowen disPubMed, MEDLINE and EMBASE databases were searched for ease). There is disorderly arrangement and maturation of meta-analyses, randomized and nonrandomized controlled epithelial cells. Multiple buds of epithelial cells may occur at clinical trials, case series, case reports and open studies involvthemembranezone, but noinvasion is seen.Histological variing AK published in the English language from January 2004 ants of AK have been described, including hypertrophic, to February 2016; the search terms and strategies are detailed bowenoid, lichenoid, acantholytic andpigmented. in Appendix S1 (see Supporting Information). Additional releActinic keratoses are generally considered to be premaligvant references were also isolated from citations in the nant lesions with low individual potential for invasive maligreviewed literature, as well as specific targeted searches for nancy andpotential for spontaneous regression. AKs present as systemic treatments and AK developing into squamous cell discrete, sometimes confluent, patchesoferythema andscaling carcinoma (SCC) asaresult ofspecific newtreatments. on predominantly sun-exposed skin, usually in middle-aged All identified titles were screened, and those relevant for and elderly individuals. Clinically, they are graded on a threefirst-round inclusion were selected for further scrutiny. The point scale according to magnitude. Field change might abstracts for the shortlisted references were then reviewed by include any or all of these lesions (Table 1).3 At grade 1 the the GDG, with a third round of review and selection for pholesion is just visible and palpable (gritty to feel and difficult to todynamic therapy (PDT) publications given their number and see), grade 2 lesions are usually red and scaly (easily felt and complexity.Disagreementsinthefinalselectionswereresolved seen), and grade 3 corresponds to thicker, hyperkeratotic by discussion with the entire GDG. The full papers of relevant lesions. Grade 3 AKs can be difficult to differentiate from material were obtained. The structure of the 2007 guidelines small, early SCCs, which, if excised, may be reported to have was then discussed and re-evaluated, with headings and subearly or equivocalinvasion.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 2}, {"text": "material were obtained. The structure of the 2007 guidelines small, early SCCs, which, if excised, may be reported to have was then discussed and re-evaluated, with headings and subearly or equivocalinvasion. headings decided; different coauthors were allocated separate They are often asymptomatic but may occasionally be sore subsections. Each coauthor then performed a detailed appraisal or itch; lesions may be single or multiple. When multiple, the of the selected literature with discussions within the GDG to concept of \u2018field change\u2019 is used to describe an area of skin resolve any issues. All subsections were subsequently collated that is involved extensively with actinic damage.4 The epiandedited toproducethefinal guideline. demiology, risk factors, disease associations and demographics of the \u2018at-risk\u2019 population are all pertinent to patient management. They are discussed together with the available treatment 4.0 Limitations of the guideline options. This document has been prepared on behalf of the BAD and is based on the best data available when the document was pre6.1 Aetiology pared. It is recognized that under certain conditions it may be necessary to deviate from the guidelines, and that the results Actinic keratosesaretheresultofchronicexposuretoultravioof future studies may require some of the recommendations let (UV) radiation, predominantly on skin of the head and herein to be changed. Failure to adhere to these guidelines dorsa of the hands, in fair-skinned individuals.5 In addition, should not necessarily be considered negligent, nor should UVB-specific p53 mutations have been demonstrated in AKs, adherence to these recommendations constitute a defence providing molecular evidence in support of a role for sunagainst a claim of negligence. Limiting the review to Englishlight.6 There is a high prevalence of keratinocyte cancer, language references was a pragmatic decision, but the authors including AKs,in thosereceiving chronicimmunosuppression, recognize this may exclude some important information pubparticularly organ transplant recipients,7 but also patients on lished in otherlanguages. long-term treatment for inflammatory bowel and rheumatological disease, although this is not as well documented. Other possible risk factors include exposure to arsenic8,9 and chronic 5.0 Plans for guideline revision sunbed use.10\u201312 The proposed revision of this set of recommendations is scheduled for 2021; where necessary, important interim Table1 Gradesofactinickeratosis(AK)3 changes willbeupdated ontheBAD website. Grade1 Mild;pinkorgreymarkswithslightscaleorgritty 6.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 2}, {"text": "of this set of recommendations is scheduled for 2021; where necessary, important interim Table1 Gradesofactinickeratosis(AK)3 changes willbeupdated ontheBAD website. Grade1 Mild;pinkorgreymarkswithslightscaleorgritty 6.0 Background totouch Grade2 Moderate;thickerhyperkeratosisandeasilydetected Actinic keratoses (synonymous with solar keratoses) are keraGrade3 Severe;hypertrophic,thickkeratin totic lesions occurring on chronically light-exposed adult skin. Fieldchange Confluentareasofseveralcentimetresormorewith They represent focal areas of abnormal keratinocyte proliferaarangeoffeaturesmatchinganyorallofthe gradesofAK tion and differentiation that carry a low risk of progression to invasive SCC. A spectrum of histology is seen, but the cardinal \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 2}, {"text": "22 Guidelinesforactinickeratosis2017, D.deBerkeretal. prospective photographic monitoring study over 5 years, it 6.2 Incidence andprevalence appeared that 65% of SCCs arose at a site of previously docuIt is likely that the incidence of AKs is underestimated. It is mented AK.20 A recent systematic review of 24 eligible studies difficult to measure the burden of AKs reliably in individuals examining the natural history of AKs concluded that there andin populations.13 were no reliable estimates concerning the frequency of AKs In prevalence studies in Galway, South Wales and developing into invasive carcinoma.26 Merseyside, 19\u201324% of individuals aged > 60 had at least one In summary, combined data suggest the possibility of AK.14\u201316 AKs were also present in 3(cid:1)6% of men aged regression and a low risk of malignant progression for any 40\u201349 years.16 A linear increase in prevalence was found with given AK. The presence of AK (particularly in high-risk age (from 60 to 80 years) in men but not in women in patients\u2013seeSection 7.6\u2013withmultipleAKsorfieldchange) another U.K. study, and the rate of new AKs was estimated to predicts an excess risk for subsequently developing an NMSC be 149 per 1000 person-years.15 Over 30% of those attending or melanoma compared withamatchedpopulation. a dermatology clinic (mean age of attendance 61 years) in Austria had AK. By the age of 70 years, > 70% of those 6.4 Investigation anddiagnosis attending had AK, with the majority (70%) on the head and neck.17 A Rotterdam prevalence study of > 2000 Dutch men Diagnosis of AK may be made in primary or secondary care and women, mean age 72 years, found AK in 49% of men and as part of a general skin examination associated with and28%of women.18 assessment of sun damage, focal keratotic lesions or skin cancer. Teledermatology has been cited as an effective means of diagnosis.27 Dermoscopy can be employed with a range of 6.3 Natural history: spontaneousregression and defined dermoscopic features.28 People with chronic fluctuatmalignant transformation ing disease may learn self-diagnosis but are advised to corrobPatientswithAKhaveachronicdisease.Thepresenceofasinorate their assessment with a healthcare professional. Such gle lesion is a marker of excessive sun exposure and is associmodels of patient-led monitoring and action are advocated in ated with the development of further lesions.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 3}, {"text": "lesion is a marker of excessive sun exposure and is associmodels of patient-led monitoring and action are advocated in ated with the development of further lesions. Point-prevalence otherchronicdiseasessuchasdiabetesmellitus,29wheremotistudies demonstrate that lesions regress and relapse over time vated self-management is a significant element in improved (thisisprobablyrelevantinthecaseofgrade1and2lesions). outcome.30 Diagnosis is typically on clinical grounds. UncerFigures range between 25% and 70% for apparent resolution tainty may arise in distinguishing AKs from superficial BCC, of AKs over a period of 1\u20134 years.15,19,20 Prospective evaluaSCC in situ, invasive SCC and even amelanotic melanoma, tion demonstrates a low rate of malignant transformation, where a skin biopsy or excision for histological examination with less than one in 1000 AKs developing into SCC per may be indicated. Where invasive malignancy is in the differannum.21 In a US study, 0(cid:1)6% of patients developed an SCC ential, the patient care should be shared with a member of a in the AK field within the first year \u2013 rising to 2(cid:1)57% at skin cancer multidisciplinary team. At the diagnosis of AK, the 4 years.20 The higher rate of apparent transformation in the location and thickness (e.g. grade 1, 2 or 3)3 should be docuUS study probably reflects the higher risk status of this premented; locationis best recorded on adiagram. dominantly maleveteran population. Nonetheless, there is evidence that AKs are a marker of 6.5 Shouldactinic keratoses be treated? excess risk for nonmelanoma skin cancer (NMSC). Mathematical models derived from the study undertaken by Marks et al. The natural history of individual lesions suggests that treatpredict that for an individual with an average of 7(cid:1)7 AKs, the ment is not universally required on the basis of preventing probability of developing an SCC within a 10-year period is progression into SCC.15 An indirect benefit of treatment is the approximately 10%.22 demonstration of lesions not responding to normal therapy, When 918 adults (mean age 61 years) with AKs but no which may represent a subgroup with higher malignant previous history of skin cancer were followed prospectively potential.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 3}, {"text": "opinion that believes for 5 years, the incidence rates for basal cell carcinoma (BCC) AKs are part of a spectrum that includes SCC in situ, and that and SCC were estimated at 4106 and 3198 per 100 000 perprevention of SCC is therefore the reason for therapy.31 A son-years, respectively, representing an excess risk compared Cochrane review of treatment of AK did not find any evidence with the general population.23 A sixfold excess risk for NMSC that treatment of AK resulted in reduction in presentation of or melanoma was found in a representative sample of the US invasive SCC.32 There is inadequate evidence to justify treatMedicare population with AKs compared with those without ment ofallAKs totry toprevent malignant change. (P < 0(cid:1)001).24 The most appropriate management plan should be deterDespite the proximity of AKs and SCC when they occur on mined by the patient\u2019s preferences and clinical circumstances, chronically sun-damaged skin, and the histological and molecwhich should take into account the extent, duration and presular similarities between them,25 debate continues concerning ence of symptoms, severity of lesions and other associated risk whether they are separate but similar pathologies developing factors for skin cancer, in addition to the patient\u2019s general in tandem or whether one leads directly to the other. In one healthandwell-being.Aquality-of-life questionnaire(AKQoL) BritishJournalofDermatology(2017)176,pp20\u201343 \u00a92017BritishAssociationofDermatologists Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 3}, {"text": "Guidelinesforactinickeratosis2017, D.deBerkeretal. 23 has been established and validated.33 In particular, patients 7.1 Notreatment (strength ofrecommendation A, quality express concern with respect to (i) the disease itself, (ii) sideofevidence 2++) effects and difficulties with treatment, (iii) association with the term cancer where AK is a risk factor for SCC, and (iv) Summaries of the levels of evidence and strengths of recomthe need to adjust sunshine behaviour on a background of mendation are given inAppendices 1and2. long-acquired habits andpreferences.34 Any perspective on nontreatment should be based on a whole-patient assessment,risks,comorbidities andpreferences. The fact that many AKs remit does not diminish the counter7.0 Management balancing point that they are associated with UV exposure and Many options are available for the treatment of AKs. The the development of melanoma, SCC and BCC. All patients main patient-centred considerations are the symptoms and need clear information on this risk and their own risk of SCC cosmetic burden of the AK, the efficacy and burden of treatin general so that, irrespective of the diagnosis of AK, they ment, and the threat of evolution of the AK into a more know to present early for assessment if a lesion bleeds, is bulky lesion or invasive SCC. Healthcare professional considpainful, grows significantly or becomes protuberant. All erations overlap with these, but include others such as effipatients should beadvised regarding sunprotection. cacy, a flexible regimen, availability in primary and secondary care and cost. An additional aspect of management 7.2 Primary care is consideration of the patient\u2019s overall risk of skin cancer and the wider skin examination. In the Rotterdam study on Patients with AK will ask their general practitioner (GP) for the prevalence of AK in the general population, participants diagnosis and treatment advice. Most AKs can be diagnosed with \u2265 10 AKs (13(cid:1)6%) had a threefold higher risk for havand treated in primary care.41 In healthcare systems with a ing a history of SCC compared with participants with four to primary-care physician as the first contact, skin monitoring of nine (4(cid:1)0%).18 Full-body skin examination revealed a skin sun-exposed surfaces of the head and neck and dorsa of hands cancer (BCC, SCC or melanoma) in 4%.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 4}, {"text": "sun-exposed surfaces of the head and neck and dorsa of hands cancer (BCC, SCC or melanoma) in 4%. The correlation is possible on an opportunistic basis and can be coupled with between skin cancer and number of AKs led the Dutch relevant prevention and self-care advice.42 Specialist dermatolgroup to advocate shorter follow-up intervals and more ogy nurses can play a similar role and are able to prescribe active treatment in those with \u2265 10 AKs, while not defining treatment in some healthcare systems.43 The Primary Care an interval and acknowledging the resource implications for Dermatology Society has developed guidance on the managethis 5% of the population. ment of AK in primary care in the U.K.44,45 Teledermatology At the outset of management, the location and grade has been used to support the diagnosis and management of (Table 1) of the AKs should be defined to enable monitoring, AK in primary care with specialist guidance.27 AKs are part of response to treatment or evolution. This can be done using the spectrum of actinic damage, which, once present, is mandrawings, body maps and photography, often with lesions aged rather thancured. numbered. Consider referral forspecialist care when: Management can be directed at individual lesions or over a (cid:129) AK failstorespondtostandard treatments; wider area (Fig. 1). This distinction represents lesion vs. field (cid:129) multiple or relapsing AKs represent a management chaltreatment. Field-based treatment can act to manage a range of lenge; actinic changes in a zone such as theforehead, scalp or central (cid:129) AK occurs inthelong-term immunosuppressed; face, and may provide some benefit in reduction of onset of (cid:129) new lesions.35 Topical therapies, skin peels36\u201339 and PDT are lesions are likely to be AK, but there is concern that they might be SCC (use the 2-week-wait route for possuitable. Usually, focal destructive therapies such as curettage sible skin cancer), for example when they are (i) bleedand cautery or cryotherapy are limited to lesion treatment ing, (ii) painful or (iii) thickened with substance when (Table 2). held between fingerandthumb. AEuropeanAK guideline achieved consensus through avoting and weighting method with advice grouped according to the isolated, field-associated or skin-cancer-associated distribu7.3 Secondary care tion of the AKs. There remained a preference for cryosurgery for isolated lesions and curettage for larger ones.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 4}, {"text": "avoting and weighting method with advice grouped according to the isolated, field-associated or skin-cancer-associated distribu7.3 Secondary care tion of the AKs. There remained a preference for cryosurgery for isolated lesions and curettage for larger ones. Otherwise, Secondary-care referral is required for diagnosis and/or manpreferences revolved largely around different strengths of the agement if the lesion might represent an invasive SCC.41 All common main agents, namely 5-fluorouracil (5-FU), imiquiregions of the U.K. have dedicated 2-week-wait or \u2018urgent mod, ingenol mebutate and variants of PDT. Diclofenac in cancer\u2019 pathways. In addition, if treatment in primary care for hyaluronic acid and imiquimod at 2(cid:1)5% were not favoured. In AK is unsuccessful, then referral is warranted for management immunosuppressed patients there was a preference for the alone. This includes patients with extensive disease or who are stronger formulations of all products. Laser was not considimmunosuppressed (see bulletpoints above). ered a good choice for any circumstance other than treatment Following diagnosisinsecondary care,treatmentcanbeiniof fielddisease.40 tiated with a future management plan for continued patient \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 4}, {"text": "24 Guidelinesforactinickeratosis2017, D.deBerkeretal. Fig1. Venndiagramillustratingtheoverlappingnatureoflesion-andfield-basedtreatmentsforactinickeratosis(AK). self-care (see Section 7.4) in collaboration with the GP. Histoof further lesions and relapse. They may also develop related logical diagnosis can be provided through a range of surgical pathology such as solar lentigo, lentigo simplex, SCC in situ procedures (see Sections 8.3 and 8.4) determined by lesion andNMSC. and circumstance. Follow-up in secondary care can be warPatient education is important in enabling self-care, with ranted to assess outcomes of treatment, extensive disease, early self-diagnosis, ongoing intermittent treatment and patients with associated skin cancers, or those with other conawareness of the risk of skin cancer and how to minimize this siderations such as immunosuppression. Nurse clinicians may risk (see the AK PIL available at http://www.bad.org.uk/forcontribute tothecarepathway. the-public/patient-information-leaflets). Most patients with mild AK will be seen in primary care and can manage their disease with topical therapy. Corroboration of diagnosis with 7.4 Self-care the GP is advisable before treatment of new lesions. ShortActinic keratosis is a chronic disease. Once patients have the term therapies (e.g. 4 weeks) may need renewal of prescripdiagnosis of AK, it is usually the start of a continued process tion when expired to ensure maintained efficacy. An BritishJournalofDermatology(2017)176,pp20\u201343 \u00a92017BritishAssociationofDermatologists Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 5}, {"text": "ytidibromdnaesufoesae,ycacfifefonoitaulave\u2019srohtuaehtnodesabsignirocsehT.sevitanretlaniameninmorfyparehtevitcafoeciohcehtgninimretedsrotcaF 2elbaT maercUF-5 htiw%5(cid:1)0 lonegnI domiuqimI domiuqimI legcanefolciD dicacilycilas UF-5 stnemmoC TDP-LAM egatteruC yregrusoyrC aetatubem %5(cid:1)3 %5 %3 %01 %5maerc eciohC sKAfocitsiretcarahcniaM (cid:129) (cid:129) (cid:129)(cid:129)(cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) sKAelgniS )rebmunwol( (cid:129)(cid:129)(cid:129) (cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) egnahcdleiF )rebmunhgih( tonyamsnoiselnihT (cid:129)(cid:129) (cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) 2ro1edarG eriuqersyawla )muidemroniht( tnemtaert ebyamygolotsiH (cid:129) (cid:129)(cid:129)(cid:129)(cid:129) (cid:129)(cid:129) (cid:129) (cid:129) (cid:129) (cid:129) (cid:129)(cid:129) (cid:129) 3edarG egatteruC.deriuqer )cihportrepyh( noisicxelamrofro derreferpebyam ebyamygolotsiH (cid:129) (cid:129)(cid:129)(cid:129)(cid:129) (cid:129)(cid:129) (cid:129) (cid:129) (cid:129) (cid:129) (cid:129)(cid:129) (cid:129) snoiseldetalosI egatteruC.deriuqer gniliaf noisicxelamrofro rehtootdnopserot derreferpebyam seipareht snoiselniatreC (cid:129)(cid:129)(cid:129)(cid:129) (cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129) (cid:129)(cid:129) (cid:129)(cid:129)(cid:129) tneuflnoC tnatsiseranihtiw tnarticlacer eriuqeryamdlefi rehtogniliaf,sKA lacigolotsih stnemtaert tnemssessa noitacoL (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) ,eson,srae,placS ,daeherof,skeehc laroirep nacseiparehtlacipoT (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129) (cid:129) (cid:129) (cid:129) (cid:129)(cid:129) (cid:129) latibroireP esuottlucfifideb dnahtuomraen eeS.seye s\u2019rerutcafunam snoitadnemmocer htiwtnemtaerterP (cid:129)(cid:129)(cid:129)(cid:129) (cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) placstneuflnoC %5dicacilycilas yamtnemtnio emoctuoevorpmi )deunitnoc( Guidelinesforactinickeratosis2017, D.deBerkeretal. 25 \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 6}, {"text": ")deunitnoc( 2elbaT maercUF-5 htiw%5(cid:1)0 lonegnI domiuqimI domiuqimI legcanefolciD dicacilycilas UF-5 stnemmoC TDP-LAM egatteruC yregrusoyrC aetatubem %5(cid:1)3 %5 %3 %01 %5maerc eciohC asignilaehrooP (cid:129)(cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129) (cid:129) (cid:129) (cid:129) (cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129) eenkehtwoleB nrecnocralucitrap llA.etissihtta daelnacseitiladom .noitarecluot ebyamtnemtaerT htiwdenibmoc noitavelenoecivda noisserpmocdna emosnignigadnab secnatsni lacipotfosesruoC (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) (cid:129)(cid:129)(cid:129)(cid:129) sdnahfokcaB deenyamypareht ,dednetxeebot tnemtaerterpdna dicacilycilashtiw yamtnemtnio%5 emoctuoevorpmi tnemtaertdooG rofdooG deensecudeR \u2019syad3ro2 thgilselbissoP elbairaV ytidibrom-woL esabnoidolloC htiwelbixelF stnemmoC fosaerarof fonoisivorp tneitaprof repnoitacilppa fonoitcuder desabesnopser tnemtaert elbaneyam elpitlum esaesidtneuflnoc dnaygolotsih ,tnemevlovni tnemtaert stceffe-edis tneitapno rofelbatius esicerperom stnemtaert roopdna ycacfifehgih sesaercnitub tisekamesruoc rewolhtiw htiw,ygoloib arevosKAniht fotnemecalp enomorf rehtootesnopser detalosirof noycnedneped otysae noitartnecnoc secnatsniemos aeraediw .tnemtaert .noitpircserp .stnemtaert .snoiselrekciht lacidem tub,rebmemer laitnatsbusfo dicacilycilaS -flesrofdooG seriuqeR salacitcarptoN fonoisivorp secuder stceffe-edis ylbaborp eracdetcerid detacided enituor ;tnemtaert roytilibixefl sesaercni dnatnempiuqe ,tnemtaert -flessevomer rofytiliba rofytilibatius erac-yradnoces sracssevael nacdnaerac ottneitap denekciht ninoisivorp seriuqerdna gnirracsesuac yfidom snoisel secnatsnitsom lacol otgnidrocca citehtseana stceffe-edis nidesuylerar(cid:129);secnatsmucricnognidnepeddesuebnac(cid:129)(cid:129);tnemtaertriaf(cid:129)(cid:129)(cid:129);tnemtaertdooG(cid:129)(cid:129)(cid:129)(cid:129).yparehtcimanydotohpetaniluvealonimalyhtem,TDP-LAM;licaruoroufl,UF;sisotarekcinitca,KA .knurtdnasbmilehtno1(cid:3)ggl005dnaecafehtno1(cid:3)ggl051tadesoDa.secnatsmucriceseht 26 Guidelinesforactinickeratosis2017, D.deBerkeretal. BritishJournalofDermatology(2017)176,pp20\u201343 \u00a92017BritishAssociationofDermatologists Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 7}, {"text": "Guidelinesforactinickeratosis2017, D.deBerkeretal. 27 important part of patient education is awareness of the side8.0 Treatment effects of treatment before they start therapy. Many cause short-term redness, soreness and sometimes crusting or oozTreatment needs to address a wide range of variables including. If this is not anticipated it can cause distress and abaning thenature oftheAK,thebody site, patientpreference, the donment of treatment, where a pause would have been more premorbid state of the patient and previous treatments tried. appropriate. Added to this is the large number of therapeutic agents, their modes of application andtheflexibility with which eachagent can be used. Given this backdrop, it is to be expected that 7.5 Prevention there is variation in clinical practice. Table 2 attempts to bring There have been three randomized controlled trials (RCTs) a broad perspective to the options. In some instances it might (twoin Australia andoneintheU.S.A.)in patients> 50 years be viewed in combination with Table 3, which is an approxiold, many of whom had a history of AKs, to show that sunmationof asimplified cost\u2013benefit analysis. screen, used to prevent unintentional sun exposure, is associated with a small decrease in the incidence of SCCs and AKs (but notBCCs) over ashort follow-up period.46\u201348 8.1 General More active treatment with a 4-week course of 5-FU, 5% Lifestyle, dietary fat,56 workplace and genetics are likely to twice daily to a field of involved skin can reduce the rate of make a difference to the risk of getting AK, mainly relating to onset of new AKs in that area over the subsequent 18 months thelevels of UVradiation exposure. when compared with placebo.35 There are no studies to show that sun avoidance reduces 8.1.1 Emollient (strength ofrecommendation B, levelof the risk of development of AKs and skin cancer in a popuevidence 2+) lation without AKs. Nonetheless, it is reasonable to hope that the effect of public health campaigns over the last few Elderly, sun-damaged skin is often dry, and emollient can be decades will have an impact on this risk. Such practices part of a management regimen. The direct effect of emollient may have a bearing on vitamin D levels, which warrant on AKs is not clear.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 8}, {"text": "effect of emollient may have a bearing on vitamin D levels, which warrant on AKs is not clear. Additives such as urea or salicylic acid review in those patients subject to high levels of sun avoidmay provide benefit. There are no trials dedicated to the study ance or who have other risk factors for diseases related to of palliative therapy of AKs, but emollient or gel vehicle has low vitamin D.49 been employed in the placebo arm of many trials. The vehicle limb of an RCT of diclofenac gel in hyaluronan vehicle described clearance of the target lesion in 44% of patients 7.6 High-risk cases using the vehicle after 60 days.57 Less dramatic results were Are there high-risk groupsandis their managementdifferent? seen with the cream vehicle used in trials of imiquimod Patients with multiple and confluent AKs are likely to be at applied two or three times a week for 4 weeks, reporting higher risk of NMSC than those with single lesions. Patients complete clearance of treated AKs in 0%,58 2(cid:1)4%59 and on chronic immunosuppressive therapy are at increased risk 14(cid:1)1%,60 with the latter treated three times a week for for skin cancer. Organ transplant recipients are reported as 8 weeks. having 50\u2013100 times the skin cancer risk of an ageand sexmatched control population.50,51 It is recommended that 8.1.2 Sunprotection andsunscreen (strength of high-risk patients have closer follow-up52 and more rigorous recommendation A,levelof evidence1++) treatment40 for premalignant lesions, including SCC in situ and AK, together with a lower index of suspicion for skin biopsy There is no specific information or evidence on sun protection to exclude malignancy. Nonetheless, there is no evidence that other than through studies on sunscreen. Sunscreen has a such measures reduce the risk of new skin cancers, although combined emollient andphotoprotective effect.Arandomized, they may reduce morbidity from surgery and minimize the placebo-controlled trial of sunscreen with sun protection facrisk ofrecurrence. tor (SPF) 17 applied twice daily to the face for 7 months Treatment for AKs in transplant patients may be less effecshowed sunscreen to be more effective than emollient in tive than in the general population.53 Outcomes may be conterms of the total number of AKs and new lesions.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 8}, {"text": "be more effective than emollient in tive than in the general population.53 Outcomes may be conterms of the total number of AKs and new lesions.46 A single, founded by the large number of proliferative and daily application of sunscreen with SPF 16 in Queensland, hyperkeratotic lesions in this group. Studies have not found a Australia, also showed it to be more effective than discrereduction in subsequent skin cancers in areas previously treationary use of the same sunscreen over a 2-year period in the ted for AKs with PDT.51 Oral retinoids have been used to reduction of AKs.61 A similar approach in the same setting reduce the risk of SCC in transplant patients at high risk of also reduced the incidence of cutaneous SCC.48 A 24-month getting skincancer,but theeffecton AKsis not reported. study of daily use of sunscreen (SPF > 50, high UVA absorpA single study reported that capecitabine, used in 15 tion) in 120 case-controlled organ transplant recipients patients, reduced the risk of SCC,54 BCC and AKs in organ showedsignificantreductioninAKsandNMSCsarisingduring transplant recipients, buttoxicityis likely tolimititsuse.55 thestudy.49 \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 8}, {"text": "ecnedivefoslevelehT.serusaememoctuodnagnilpmasralimisnodesabsyawlatoneraspuorgtnemtaertneewtebataD.KAnisnoitpotnemtaertniamehtfognittesdnaytilibixefl,ycacfife,tsoC 3elbaT 4ot++1morfegnarelbatsihtotgnitubirtnoc ninoitcudeR nodesabsKA ffatsfotsocno-ddA lanoitavresboa .tnempiuqeroemit rosTCRb,seiduts roeracyramirP sisylana-atemc otelbanemA .]eracyradnoces[ tonodseulav( noitaulavefodoireP elbanemA eracdetcerid-tneitap elbixelF seriuqeryllacipyTd ssecxetcefler nahtregnol( eracyramirpot gnirotinomdna snemiger stisiv2 metidesnepsiD metirepegnartsoC )lortnocrevo )tnemtaertevitca (cid:129)(cid:129)(cid:129) (cid:129) (cid:129) 0 \u2666 51,a%12otpU etinfiedni,cidoireP tnemtaertoN (cid:129)(cid:129)(cid:129) (cid:129) (cid:129) 0 \u2666 06,75,b%44\u20130 etinfiedni,cidoireP elcihevrotneillomE (cid:129)(cid:129)(cid:129) (cid:129) (cid:129) ]\u2666\u2666[\u2666 Lm005 \u2666 74,64,b%63\u201371 etinfiedni,cidoireP /3AVU(neercsnuS )05\u201371FPS (cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129)(cid:129) d]\u2666\u2666\u2666\u2666[\u2666\u2666 g04 \u2666 66,63,b%87\u201307 shtnom4\u20132 %5licaruoroulF-5 (cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129)(cid:129) d]\u2666\u2666\u2666\u2666[\u2666\u2666 stehcas42\u201321 \u2666\u2666/\u2666 57,47,b%48otc%05 shtnom4\u20132 %5domiuqimI (cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129)(cid:129) d]\u2666\u2666\u2666\u2666[\u2666\u2666 stehcas65\u201382 \u2666\u2666\u2666\u2666/\u2666\u2666\u2666 58,b%63\u201343 shtnom4\u20133 %57(cid:1)3domiuqimI (cid:129)(cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129) d]\u2666\u2666\u2666\u2666[\u2666\u2666 g001\u201305 \u2666\u2666/\u2666 09,75,b%07\u201391 shtnom4\u20132 %3legcanefolciD (cid:129)(cid:129) \u274d (cid:129) d]\u2666\u2666\u2666\u2666[\u2666\u2666 noitacilppa-elgnis3\u20132 \u2666\u2666 39,29,b%24\u201343 shtnom2\u20131 etatubemlonegnI sebutg-74(cid:1)0 ,ecaf1(cid:3)ggl051( sbmil1(cid:3)ggl005 )knurtdna \u274d \u274d (cid:129)(cid:129) 771829\u00a3\u2013724\u00a3 ebuttnemtaertelgniS \u2666\u2666\u2666 671,721,911,701,b%39\u201396 shtnom2\u20131 TDP-LAM (cid:129) \u274d (cid:129)(cid:129) d]\u2666\u2666\u2666\u2666[\u2666\u2666 elbacilppatoN nwonktoN 601,301,a%88\u201393 shtnom2\u20131 yregrusoyrC seiparehtnoitanibmoC (cid:129)(cid:129)(cid:129) (cid:129)(cid:129) (cid:129)(cid:129) ]\u2666\u2666[\u2666 Lm52 \u2666 27,17,b%77ota%55 shtnom4\u20132 %5(cid:1)0 licaruoroulF-5 dicacilycilasdna %01 (cid:129) (cid:129) (cid:129)(cid:129) ]\u2666\u2666[\u2666 g001\u201305 \u2666\u2666/\u2666 751,651,a%001\u201364 shtnom4\u20132 dnalegcanefolciD yregrusoyrc (cid:129) (cid:129) (cid:129)(cid:129) d]\u2666\u2666\u2666\u2666[\u2666\u2666 stehcas42\u201321 \u2666\u2666/\u2666 68,a%5(cid:1)95 shtnom4\u20132 dnadomiuqimI yregrusoyrc \u274d (cid:129) (cid:129)(cid:129) sulpd]\u2666\u2666\u2666\u2666[\u2666\u2666 sulpstehcas42\u201321 \u2666\u2666/\u2666 061,a%98 shtnom4\u20132 dnadomiuqimI 829\u00a3\u2013724\u00a3 ebuttnemtaertelgnis TDP-LAM morfnwardstsoC egakcapdradnatS stnemmoC snoitacilbupSHN esruocroezis :tnemtaertfo 66noitide,4102FNB noitcetorpnus,FPS;lairtdellortnocdezimodnar,TCR;yparehtcimanydotohpetaniluvealonimalyhtem,TDP-LAM;ecivreShtlaeHlanoitaN,SHN;yralumroFlanoitaNhsitirB,FNB;sisotarekcinitca,KA ;ytilibanema/ytilibixefletaredom(cid:129)(cid:129);ytilibanema/ytilibixeflemos(cid:129);elbixeflroelbanematon\u274d:yekeraC.002\u2013051\u00a3\u2666\u2666\u2666\u2666;051\u2013101\u00a3\u2666\u2666\u2666;001\u201315\u00a3\u2666\u2666;05\u20130\u00a3\u2666:yektsoC.Ateloivartlu,AVU;rotcaf 971.44\u00a3:tisivPG.86\u00a3:ecnadnettapu-wolloflatipsoH871.401\u00a3:51\u20134102ffiratSHNecnadnettacinilclatipsohtsriF.elbanema/elbixeflyrev(cid:129)(cid:129)(cid:129) 28 Guidelinesforactinickeratosis2017, D.deBerkeretal. BritishJournalofDermatology(2017)176,pp20\u201343 \u00a92017BritishAssociationofDermatologists Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 9}, {"text": "Guidelinesforactinickeratosis2017, D.deBerkeretal. 29 A Cochrane review with subsequent meta-analysis of complete 8.2 Active treatments clearance results ranked the efficacy of all of the main treatAll topical therapies for AK may result in side-effects of irritaments andput5-FU atthetop.65 tion. Some AKs proceed to ooze, crusting and soreness with Nine of the trials were controlled; a right\u2013left comparison local swelling. Details are cited in this guideline for the indi- (n = 6)wasthemostcommondesign,butonlyfivewereranvidual treatments and are included in the relevant PILs. It is domized. Numbers in the studies were generally small, with a important that the patient understands the extent of the area mean of 26 patients per trial and fewer than 15 patients in to be treated and anticipates the side-effects. The size of area 50% of the trials. The minimum follow-up was \u2265 12 months will depend on a range of factors including the therapy, focal in only two studies. Many open studies appeared to demonor scattered pathology and the conceptual model (fieldor strate the efficacy of 5-FU in a range of potencies and differlesion-basedtreatment).Wheremorbidityisanascendantconent vehicles in the treatment of AKs when used on the face cern, treatment should be initiated over a small area such as twice daily for 3 weeks. Only two trials studied the use of 54\u201310 cm2 with flexible frequency to establish tolerance and FU in the currently available formulation of a 5% cream in a confidence. Some treatments define a ceiling of surface area well-constructed, controlled manner.36,66 Kurwa et al. exambased on the aliquot of prescribed item, for example one tube ined the lesional area of AKs on the back of the hands before of ingenol mebutate is a single dose for 25 cm2. Imiquimod and after treatment with 5-FU 5% cream twice daily for 5% is issued in 250-mg sachets where directions include \u2018one 3 weeks in a randomized right\u2013left comparison with a single sachet only\u2019 and to \u2018cover the area\u2019 typically with a centimetre treatment with PDT.66 Of the 14 patients evaluable at margin around any pathology. Others recommend a maxi6 months, there was a mean reduction in lesional area of 70% mum basedon toxicity, such as500 cm2for 5-FU 5%. (5-FU) and 73% (PDT), with no statistically significant differPatients should be provided with advice on how to manage encebetweenthem.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 10}, {"text": "there was a mean reduction in lesional area of 70% mum basedon toxicity, such as500 cm2for 5-FU 5%. (5-FU) and 73% (PDT), with no statistically significant differPatients should be provided with advice on how to manage encebetweenthem.Openstudieshavesuggestedthatthisregside-effects, with strategies including a break in treatment, imen is not sufficiently long for effective treatment of AKs on altering the frequency of application, use of emollient and in thehands,67but is adequatefor thoseon theface.36 some instances application oftopicalsteroid. Witheiler et al. used 5-FU 5% cream on the face as the control in a right\u2013left comparison with a single application of Jessner\u2019s solution (14% lactic acid, 14% salicylic acid, 14% 8.2.1 5-Fluorouracil (strength ofrecommendation A, level resorcinol in ethanol) followed by a trichloroacetic acid (TCA) of evidence1++) 35% peel.36 There was a mean reduction in AKs on both sides The majority of the data on topical therapies relate to the 5% of the face from 18 to four (78% reduction with 5-FU and concentration of 5-FU cream. 5-FU works by the inhibition of 79% reduction with TCA). This benefit was sustained for thymidylate synthetase, which is needed for DNA synthesis. It 12 months. The third follow-up at 32 months demonstrated may also interfere with the formation and function of RNA.62 that the number of AKs had risen again to 10 (5-FU) and 15 It is a widely used, flexible and low-cost treatment.63 It can (TCA) in the eight evaluable patients; these differences were be used either as lesional treatment or as part of field treatnot statistically significant. ment. The side-effects with the latter can be substantial, and it The results of using the same formulation of 5-FU less freis important that the patient is counselled about them, includquently, but for prolonged periods, are conflicting. An open ing soreness, redness and possible crusting. All of these can be trial of 10 patients reported clearance of 96% of AKs after a minimized through reduction in the frequency of application mean of 6(cid:1)7 weeks applying treatment twice daily, once or or short breaks in a course of therapy. It is permitted to wash twice per week.68 Six patients were followed for 9 months the area and apply thin emollient. If the reaction is excessive, and showed an 86% clearance rate that was maintained.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 10}, {"text": "of therapy. It is permitted to wash twice per week.68 Six patients were followed for 9 months the area and apply thin emollient. If the reaction is excessive, and showed an 86% clearance rate that was maintained. weak steroid can be applied. It is important that the patient is Epstein followed this study with a similar protocol and sample enabled to learn how to use the treatment, as it is one they size, except that evaluation was done by dermatologists given mayrequireintermittentlyinthefutureandabadinitialexpea series of photographs and blinded to the sequence.69 Eight rience canlimit further use. of 13 patients failed to show any improvement, with the conMany regimens cite twice-daily application over 4 weeks, clusion that pulsing 5-FU over a period < 10 weeks is not but less frequent initial use may enable titration of the freeffective. A comparison of use twice daily for 3 weeks against quency of application against reaction, tolerance and efficacy. twice daily for 1 day a week for 12 weeks showed the infreUse at poor healing sites such as the lower leg should always quent regimen to be 80% as effective when evaluated at the be undertaken with caution and may need supervision. More final assessment at 52 weeks.70 recently, 5-FU 0(cid:1)5% in 10% salicylic acid has been evaluated The mixed quality and size of these studies mean it is diffiand can be prescribed.64 A wide range of open trials, dosecult to provide a firm interpretation, but the indication is that ranging studies and manipulations of the vehicle have been pulsed therapy may work for some patients and enable more reported over the last 45 years, as well as two RCTs, confirmprotracted therapy with reduced morbidity. Such an approach ing efficacy. A large,placebo-controlled RCT showed 5-FU 5% can be useful for sensitive areas or people reluctant to use to be more effective than placebo in AK clearance and the treatments that provoke redness and crusting. However, for reduction of follow-up cryosurgery treatments at 6 months.35 some people it may fail to show benefit, and increased \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 10}, {"text": "increased \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 3, "page": 10}, {"text": "30 Guidelinesforactinickeratosis2017, D.deBerkeretal. frequency of use or an alternative treatment may need to be periods. After a further 24 weeks, these figures changed to employed. 45%, 0% and 14(cid:1)3%, respectively, illustrating that improve5-FU 0(cid:1)5% in 10% salicylic acid has been assessed, where mentcontinuestoprogressaftertheconclusionofimiquimod, salicylic acid may be acting as a keratolytic to enhance the in contrast to other treatments.58 Similar results were reported efficacy of5-FU. Intheinitial opentrial, statistics wereunderfor imiquimod 5% used three times a week for 4 weeks in taken per AK rather than per patient.71 In this framework, itis 126 patients, andrepeated for afurther 4 weeksa monthlater not possible to report the complete cure of any single patient. in 79 of them and compared with cream vehicle. The global However, the clinical clearance rate for individual AKs was complete response in the combined groups was 55%, com77%, with no control or blinding. Subsequent evaluation paredwith2(cid:1)3%forvehicle,illustratingthatpersonalvariation against salicylic acid vehicle and diclofenac 3% in hyaluronic makes it possible to tailor the duration of the regimen to the gel has been undertaken.64 Daily application with a brush for individual.77 An RCT comparing imiquimod 5% cream on the 6\u201312 weeks or the point of clearance was compared with face [three times a week for 4 (40%) or 8 (60%) weeks twice-daily diclofenac gel over the same period. Eight weeks depending on response] with liquid nitrogen spray (10-s post-treatment, complete clearance was determined to be freeze to commencement of thawing) favoured liquid nitroachieved in 55(cid:1)4%, 32% and 15(cid:1)1% of the patients using the gen, with complete clearance in 88% vs. 66(cid:1)9%. However, the study product, diclofenac and vehicle, respectively; the AKs cryosurgery resulted in a higher number of pigmentary were grades 1 and 2. A follow-up phase of the same study changes.78 measured the rate of relapse of individual lesions rather than The side-effects of imiquimod are similar to those of 5-FU, relapse from complete clearance.72 With this measure, the rate with severe erythema (30(cid:1)6%), scabbing andcrusting (29(cid:1)9%) of relapse measured per lesion was less at 12 months with 5and erosions or ulceration (10(cid:1)2%). Flu-like symptoms can FU 0(cid:1)5% in 10% salicylic acid (14(cid:1)2%) thandiclofenac 3% gel also arise and are more likely if multiple sachets are used at (19%) (P = 0(cid:1)02%).", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 11}, {"text": "erosions or ulceration (10(cid:1)2%). Flu-like symptoms can FU 0(cid:1)5% in 10% salicylic acid (14(cid:1)2%) thandiclofenac 3% gel also arise and are more likely if multiple sachets are used at (19%) (P = 0(cid:1)02%).72 The nature of the application makes it each treatment or if it is being used for superficial BCC with suitable for lesion-directed therapy rather than field therapy. more frequent applications than is typically the case for AK.59 The license highlights the benefit of the salicylic acid vehicle An instance of post-treatment eruptive keratoacanthomas has as a means of addressing more keratotic AKs. About 50% of been reported.79 The extent of side-effects is not wholly prepatients discontinue treatment at 6 weeks due to disappeardictable, with some patients manifesting an extreme reaction anceoftheAK.73 and others very little. The clinical response is largely in proportion to the side-effects, and those terminating treatment early due to extreme soreness may still get a good response. 8.2.2 Imiquimod5% cream(strength of recommendation Side-effects are generally well tolerated, but it is important to A,levelof evidence1++) counsel the patient carefully in order to anticipate those who Imiquimod is a topical immune-response modifier. It is availhave moreextremeclinicalreactions.80 able as a 5% and a 3(cid:1)75% cream. Most of the data on treatThere are limited long-term data on relapse after treatment, ment response pertain to the 5% cream and are considered but in a three-armed RCT between cryosurgery, 5-FU 5% and first.IntheU.K.itislicensedforuseinclinicallytypical,nonimiquimod, the proportions of the intention-to-treat populahyperkeratotic, nonhypertrophic AKs on the face or scalp in tion maintaining clearance at 12 months were 1%, 33% and immunocompetent adults, when the size or number of lesions 76%, respectively.81 The cryosurgery treatments were 20\u201340 s limits the efficacy and/or acceptability of cryotherapy, and in duration, which is a dose at which scarring might be other topical treatment options are contraindicated or less expected. This observation poses questions about this limb of appropriate. It isapplied atnight andwashed offin themornthe study. In an observational, 16-month follow-up study, ing 8 h later. Courses are three times a week for 4 weeks, 75(cid:1)3% of those receiving treatment three times a week over whichcan berepeated forafurther 4 weeksifneeded.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 11}, {"text": "of those receiving treatment three times a week over whichcan berepeated forafurther 4 weeksifneeded. 8 weeks were clear at 16 months, in comparison with 57(cid:1)4% A meta-analysis of the use of imiquimod 5% cream from receiving thesametreatment twice aweek.82 five RCTs using it two or three times a week for 12\u201316 weeks There is a small number of studies on imiquimod 3(cid:1)75% demonstrated a 50% complete clearance rate. This is similar to cream (strength of recommendation B, level of evidence 1+), more brief and flexible regimens as per the license.74 A small which is licensed for treatment of AKs of the head and scalp RCT against vehicle placebo showed clearance rates of 84% with application once daily for two, 2-week periods separated when used up to three times per week for 12 weeks.75 Two by 2 weeks. Early studies had longer courses, applied once RCTs with regimens of three times per week for 16 weeks daily for two periods of 3 weeks separated by 3 weeks, over a and follow-up 8 weeks later gave 47% of subjects with com9-week course. Comparison with placebo vehicle 8 weeks plete clearance (vs. 7(cid:1)2% with placebo)76 and 57(cid:1)1% (vs. after conclusion of treatment showed complete clearance rates 2(cid:1)2%for placebo).59 of 5(cid:1)5% (placebo) and 34%.83 Where AKs responded they A head-to-head open trial between imiquimod 5% (twice recurred in 60% of patients within 14 months.84 Where treatper week for 16 weeks), its cream vehicle and diclofenac 3% ment was given as per the licence at weeks 1 and 2, then 5 gel (twice daily for 90 days) showed complete clearance of and 6, complete clearance was seen in 6(cid:1)3% and 35(cid:1)6% for 19(cid:1)1%, 0% and 20% at the end of the respective treatment emollient and imiquimod, respectively.85 Where the BritishJournalofDermatology(2017)176,pp20\u201343 \u00a92017BritishAssociationofDermatologists Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 11}, {"text": "Guidelinesforactinickeratosis2017, D.deBerkeretal. 31 comparator wascryosurgery andemollient vehicle, imiquimod the disruption of mitochondrial membranes resulting in dam3(cid:1)75%following cryosurgery resulted in complete clearance in age anddeath of host cells and promotion ofcell-specific anti59(cid:1)5%ofpatients.86 bodies with consequent antibody-dependent, cell-medicated Imiquimod3(cid:1)75%hasalsobeenusedfollowingcryosurgery cellular cytotoxicity. It is licensed for the treatment of nonkerasasupplementarytreatment,improvingtheresultoftreatment atotic, nonhypertrophic AK in adults (grade 1 and 2). It is ofhypertrophicAKsontheforearmanddorsaofthehandsover sold in two strengths (150 and 500 lg g (cid:3)1), with the weaker cryosurgery alone.87 It is possible that the adverse side-effects one applied 3 days in succession to the chosen area on the associated with imiquimod 5% cream are less with the 3(cid:1)75% face and scalp and the stronger one applied 2 days in succesformulation, and this might enable more field-based treatment sion to other sites. Each application is dispensed as a single overlesiontherapyandimprovepatienttolerance. tube of cream (three tubes for the face and scalp or two tubes for other sites) with scope to cover a field 5 9 5 cm. Use of the 150-lg g (cid:3)1 preparation on the face and scalp over 3 days 8.2.3 Diclofenac gel(strength of recommendation A,level resulted in a complete cure rate of 40% vs. 11(cid:1)7% for vehicle of evidence1+) 60 days after starting the treatment in one RCT,92 and 42(cid:1)2% Diclofenac3%ina2(cid:1)5%hyaluronicgelislicensedforapplicavs. 3(cid:1)7%forvehicle in another ofsimilar size.93 tion twice daily for 60\u201390 days and can be applied as a Pooled data from two trials of this treatment regimen follesionor field-based treatment. Its mechanism of action for lowed successfully treated patients for 12 months. There was AK is not known, but may be related to inhibition of the relapse on the head and neck in just over half of the patients cyclooxygenase pathway leading to reduced prostaglandin E2 in the following year.94 If residual lesions are re-treated at synthesis. Diclofenac gel usually causes less intense local skin 8 weeks, clearance at 12 months increases from 27% to reaction than 5-FU or imiquimod 5% cream.88 The reduction 50%.95 Clearance on other body sites is less, at 34(cid:1)1% vs. ofside-effectsismatchedbyreduced efficacywherediclofenac 4(cid:1)7%for vehicle after application of the500-lg g (cid:3)1 cream on gel and 5-FU 5% are compared, but both achieve high levels two consecutive days, with relapse in just over half of the (73% vs. 77%) ofpatientsatisfaction.89 patients at 12 months.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 12}, {"text": "the500-lg g (cid:3)1 cream on gel and 5-FU 5% are compared, but both achieve high levels two consecutive days, with relapse in just over half of the (73% vs. 77%) ofpatientsatisfaction.89 patients at 12 months. Specific sites such as the back of the There are three vehicle-controlled studies in the treatment hand are less likely to clear completely, with 18(cid:1)5% vs. 0% of mild AKs. In the first, patients were treated for a mean of for placebo.96 60 days, with aclearance of 70%of target lesionsin the treatSide-effects peak at 4 days, which is after completion of the ment group compared with 44% in those using the vehicle.57 application of treatment. Common effects are redness, scabIn the second study, treatment was for 90 days; 50% achieved bing, pain and pustules, with most side-effects settling within complete clearance vs. 20% of those treated with vehicle alone 28 days.93,94 (P < 0(cid:1)001).90 In a three-armed RCT comparing diclofenac A 2015 update from the US Food and Drug Administration 3%, imiquimod 5% cream and base cream, the rates of comhighlights the risks of severe adverse reactions, with local and plete clearance at the end of treatment were 19(cid:1)1%, 20% and systemic allergic features and herpes zoster infection. Within 0%. the update they emphasize the need \u2018to avoid applying the gel In summary, these three different studies with diclofenac in, near, and around the mouth, lips and eye area\u2019.97 Extendgel show 26%, 30% and 19(cid:1)1% benefit over vehicle gel or ing the area of treatment of ingenol mebutate to 100 cm2 base cream, respectively.58 Extending treatment from 90 to resulted in no increase in treatment-related adverse events 180 days gave an additional 5% complete clearance without a compared with 25 cm2.98 Clobetasol propionate, used twice significant change in adverse effects.91 Follow-up assessment daily for 4 days post-treatment, did not alleviate the sympwas limited to 30 days post-treatment in the first two studies. toms or efficacy of ingenol mebutate.99 This may be relevant In the third, clearance dropped to 14(cid:1)3% at 24 weeks\u2019 followtoothertreatments causingsoreness. up. Diclofenac 3% gel used as part of a three-armed study with 5-FU 0(cid:1)5% in 10% salicylic acid and vehicle resulted in a 8.2.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 12}, {"text": "causingsoreness. up. Diclofenac 3% gel used as part of a three-armed study with 5-FU 0(cid:1)5% in 10% salicylic acid and vehicle resulted in a 8.2.5 Topicalretinoids (strength ofrecommendation B, 32% rate (17% over vehicle)64 of complete clearance, and a level ofevidence1+) relapse rate of 19% at 12 months.72 These data indicate moderate efficacy with low morbidity in mild AKs. Treatment was A range of older trials demonstrate a modest benefit with the well tolerated and reported side-effects were mainly pruritus use of topical retinoids in AK.100 They may lend some addi- (41% estimated after 30 days\u2019 treatment) and rash (40% estitional benefit with respect to improvement in lentigines and mated after60 days).57 reduced wrinkles. Their use is usually sustained rather than based on a limited course of treatment. Products include ada8.2.4 Ingenol mebutate cream(150 lgg(cid:3)1faceandscalp, palene 0(cid:1)3%, tretinoin 0(cid:1)1% and 0(cid:1)05% and topical isotreti500 lgg(cid:3)1limbsand trunk)(strength ofrecommendation noin 0(cid:1)1%. Where adapalene 0(cid:1)1% was compared with 0(cid:1)3%, the latter was significantly more efficacious in achieving AK A, levelofevidence 1+) count reduction after 9 months.101 Currently, tretinoin and Ingenol mebutate is a diterpene ester extracted from the plant isotretinoin are prescribable in the U.K. only in 0(cid:1)025% and Euphorbia peplus. At a cellular level it appears to work through 0(cid:1)05% concentrations, respectively, as topical antibiotic \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 12}, {"text": "32 Guidelinesforactinickeratosis2017, D.deBerkeretal. combinations licensed for use in acne. A Veterans Association rate of 88% as judged at 24 weeks.106 In a right\u2013left, splitRCT exploring topical retinoid for chemoprevention of skin face study comparing MAL-PDT with a cryosurgery double cancerwasstoppedearlybecauseofexcessiveall-causemortalfreeze\u2013thaw cycle retreated at 12 weeks as needed, MAL-PDT ityin thetreatment groupat interim analysis.102 showed 89(cid:1)1% cured lesions vs. 86(cid:1)1% for cryosurgery, with Keyrecommendations: topical therapies no significant difference between them. Patient and clinician (cid:129) assessment of cosmetic outcome were in favour of MALEmollient and sunscreen with advice on sun protection PDT.107 might be a satisfactory treatment for people with fluctuExtensive cryosurgery over large areas has been referred to ating grade 1AKs. (cid:129) as cryopeeling, and can be used to treat fields of AKs and Education at the outset of using active topical therapies background damage.108 Cryosurgery has been described in is important to ensure a full understanding of how to combination with topical 5-FU, where the duration of treatapplytreatmentandthenatureoftheside-effects,which ment and consequent side-effects of both modalities could be canbemarked. (cid:129) reduced while maintaining efficacy.109 The pretreatment of AK Active topical therapy is suited to use in primary and with 5-FU 0(cid:1)5%for 1 week, prior totreatment of theremainsecondary care. Where possible, a management plan ing lesions with cryosurgery at the 1-month follow-up, may should be formulated that enables the patient to be decrease the AK count at 6 months when compared with managed in primary care. (cid:129) cryosurgery and vehicle pretreatment (reduced to 33% vs. Topical therapy is suited to use as lesionand field55%, P = 0(cid:1)01).110 basedtreatment.Whereusedforfieldtreatment,thesize Cryosurgery is a flexible therapy that requires skill in of the field needs to be defined with the patient to administration. With larger doses it is likely to result in loss ensure anticipation andtolerance ofside-effects. (cid:129) of pigment and scarring. Patient counselling is important conFailure of an individual lesion to respond to topical cerning the shortand long-term side-effects. In particular, therapyindicates aneedfor furtherevaluation. Thismay patients should be aware of blistering, oedema, crusting and include referral from primary care to secondary care or soreness. Doses appropriate for AK are usually < 10 s, but still surgerytoobtainhistology andextendtreatment. carry some risk of damage to underlying structures such as tendons and nerves if applied on the back of the hands.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 13}, {"text": "or soreness. Doses appropriate for AK are usually < 10 s, but still surgerytoobtainhistology andextendtreatment. carry some risk of damage to underlying structures such as tendons and nerves if applied on the back of the hands. Below the knee, slow healing can be a problem, particularly in the 8.3 Cryosurgery(strength ofrecommendation A,level of older patientgrouppresenting withAK. evidence 1++) Cryosurgery is a long-established treatment for AKs requiring 8.4 Surgery a cryospray (or cotton wool and orange sticks) and a supply of liquid nitrogen. Complete clearance rates vary according to There are no trials of surgery for AKs. The nature of the the duration of freeze and the number of treatments, usually pathology makes it likely that a surgical procedure able to separated by 6\u201312 weeks. The relationship between duration remove an area of diseased skin represents an effective therof freeze and clearance was examined in a three-dose study apy. Histological information can be useful in management of with 12-month follow-up after cryosurgery. A duration < 5 s AK. It is unlikely that this would be a first-line treatment showed 39% cure, 5\u201320 s, 69% cure and > 20 s, 83% cure unless there was diagnostic uncertainty. Surgery addresses a on the scalp and face.103 Many more recent studies are based focal lesion, and where AK presents in a field of actinic damon head-to-head trials with PDT. A randomized study comparage, it does not address this. A curettage specimen may make ing cryosurgery with PDT in 193 patients indicated an overall it difficult to determine whether a lesion has an element of 75% complete response rate for cryosurgery in contrast to dermal invasion. In some instances a deep shave or formal 69% in those treated with PDT at 3 months.104 The differenexcision with histological examination might be preferred. If tial success of the two therapies was more marked for thick curettage is used for a hyperkeratotic AK where SCC is a diflesions, with 69% showing complete response to cryosurgery ferential diagnosis, it may be warranted to employ two or vs. 52% to PDT. A double freeze\u2013thaw cycle was used in this three cycles oftherapy.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 13}, {"text": "52% to PDT. A double freeze\u2013thaw cycle was used in this three cycles oftherapy. This will ensure that if the histology is study in contrast to a single cycle, which, when used in a difthat of invasive SCC, or if it is equivocal, the curettage is still ferent study, yielded a68%response.105 likely to represent adequate treatment. Exceptions would be A more recent head-to-head study with methyl aminolaewhere the size, histological type or location of an SCC would vulinate (MAL)-PDT employed cryosurgery (1 9 10-s freeze) make curettageanunacceptable treatment.111 as needed every 3 months for up to four visits with assessment at 12 months. It reported a complete response rate of 8.5 Systemic therapy (strength ofrecommendation C, 85%, with 77% needing only one treatment. Side-effects of level ofevidence 2+) cryosurgery of soreness, blistering, pigmentary change and scarring contributed to an overall patient preference for Systemic retinoids have been assessed for their potential role PDT.78 Inanother study, adoublefreeze\u2013thaw cycle given just in suppression or treatment of multiple AKs. Early studies once with no defined duration provided a complete clearance employing etretinate showed the efficacy of this drug in BritishJournalofDermatology(2017)176,pp20\u201343 \u00a92017BritishAssociationofDermatologists Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 13}, {"text": "Guidelinesforactinickeratosis2017, D.deBerkeretal. 33 double-blind, crossover trials.112 Anecdotal evidence over the photosensitizing cream, then the agent is applied under occlulast 20 years suggests that there can be some considerable sion for 3 h prior to irradiation. Surface fluorescence with a morbidity in employing this treatment. In addition, there may Wood\u2019s lamp can help delineate lesions and identify persistent be a rebound effect once the systemic therapy is stopped. disease. Treatment can be painful but can be managed with However, these effects were not observed at the 4-month folcold-air analgesia or nerve blocks.121,122 Erythema and crustlow-up in theone availablereport on thissubject.113 ing often occur but can be reduced by the use of plaster or Use of systemic retinoids may be justified in very high-risk physical sunscreens.123 patients, such as organ transplant recipients, where there is a Four studies report PDT to be more effective than plapresumed increased risk of progression from AK to SCC.54 cebo.105,107,124,125 Three were randomized and one double Renal transplant patients given oral acitretin 0(cid:1)4 mg kg (cid:3)1 blinded, with 80\u2013211 subjects. Efficacy rates appear better for showed some immunohistochemical normalization of keratin the face and scalp than the forearm and hands,126 but there expression patterns, butaresidue ofhistological dysplasia sugare no studies comparing the two sites. Response rates in the gests potential for relapse when the drug is stopped.114 Lowface and scalp range from 69% to 93%. Follow-up reported dose acitretin is currently given as a treatment option in the up to24%recurrence at12 months in anopen-label study.127 \u2018European best practice guidelines\u2019 for renal transplant patients There may be an increased response of PDT with fractionwith multipledysplastic skin lesions.115 ated light128 and pretreatment with laser.129 Both claimed betAn international survey of 28 dermatologists managing skin ter responses, but patient numbers were small and the sidedisease in organ transplant recipients looked at the use of syseffects greater with laser pretreatment. In a side-to-side comtemic retinoid.116 In the setting of AK alone, only where the parative study of PDT vs. CO laser treatment they were of 2 AK was extensive did the majority (56%) advocate systemic equalefficacy but patientpreferencewas forPDT.130 retinoid. Once patients start getting multiple (more than five) Three open-label RCTs compared MAL-PDT with cryosurSCCs in addition to AK, the rate of prescribing increased to gery.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 14}, {"text": "did the majority (56%) advocate systemic equalefficacy but patientpreferencewas forPDT.130 retinoid. Once patients start getting multiple (more than five) Three open-label RCTs compared MAL-PDT with cryosurSCCs in addition to AK, the rate of prescribing increased to gery. Two freeze\u2013thaw cycles were comparable in one between 74% and 81% depending on whether the SCCs were study104 and inferior in terms of relapse in another.107 A sinhigh risk. Acitretin was the most common drug, at a starting gle cycle was inferior in the third study.105 The efficacy of dose of 10 mg (42%) or 25 mg (58%); 12% of respondents PDT was confirmed in a meta-analysis in 2014.131 PDT has used isotretinoin. also been compared side to side in trials with 5-FU.66 A There is little literature on the use of systemic cytotoxic right\u2013left comparison of AK treatment on the backs of the agents in the immunosuppressed, mainly for the treatment of hands showed a similar response for PDT and 5-FU, clearing SCC but in the setting of extensive AK. Capecitabine given to lesions in 73% and 70% of cases, respectively. Three studies 15 organ transplant recipients with frequent SCC, BCC and AK showed nodifference between PDTandimiquimod.132\u2013134 showed reductions in monthly incidence to 22%, 33% and In a randomized study of 30 patients given up to two treat45% of pretreatment levels, respectively.55 Side-effects resulted ments with ALA-PDT or one to two courses of imiquimod in 33% ofpatients stopping after1 year. (three times a week for 4 weeks), equivalent responses were The cyclooxygenase-2 inhibitor, celecoxib, taken for seen 6 months after completion of treatment (65% vs. 9 months can reduce the number of BCCs and SCCs over an 55%).134 PDTwasmoreeffectivefor grade 2lesions. 11-month period, but does not appear to alter the number of A thin, self-adhesive patch has been developed for selfAKs.117 Overall, it may have a role in high-risk patients with application.135,136 In a multicentre RCT involving 449 multiple NMSCs, but it does not have evidence of a role for patients, efficacy with active treatment was 82% after AK.118 12 weeks compared with 19% for placebo and 77% for cryosurgery. It is advocated as allowing self-application and avoiding theneed forpretreatment curettage. 8.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 14}, {"text": "weeks compared with 19% for placebo and 77% for cryosurgery. It is advocated as allowing self-application and avoiding theneed forpretreatment curettage. 8.6 Photodynamic therapy (strength ofrecommendation Daylight PDT involves the application of MAL to the skin A, level ofevidence 1+) without occlusion and subsequent exposure to ambient dayPhotodynamic therapy combines a dedicated light source of light. A high-SPF sunscreen without mineral filters is applied appropriate wavelengths with the application of a photosensi15 min before the photosensitizing cream. Thirty minutes tizing cream to produce apoptosis and necrosis of the target later thepatient spends2 houtdoors. tissue. Photosensitizing agents include 5-aminolaevulinic acid Five RCTs in Europe and Australia have confirmed the effi- (5-ALA) and the methyl ester of 5-ALA, 5-MAL. BF-200ALA cacy of daylight PDT compared with conventional PDT.137\u2013141 was recently used, showing increased stability and penetraThis was in mild (grade 1) to moderate (grade 2) lesions on tion.119,120 the face and scalp. Clearance rates of 70\u201389% were reported. A range of light sources can be used.121 Red narrow-specIn European studies daylight PDT can be performed in all trum light sources permit shorter illumination times and weather conditions,141 but temperatures > 10 \u00b0C are advised appear to give higher response rates.119,120 Current knowledge for patient comfort. Consensus guidelines have also been proof photosensitizers and light sources are detailed in the 2013 duced.142\u2013144 European Guidelines for PDT.121 In most situations superficial One comparative study was a right\u2013left comparison of PDT crust or keratin is first removed with light curettage and with ingenol mebutate in grade 1 and 2 lesions (27 patients). \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 14}, {"text": "34 Guidelinesforactinickeratosis2017, D.deBerkeretal. In both there was a 40% complete response rate, but PDT was product with two active ingredients where, for instance, salibetter tolerated.145 cylic acid 10% may break down surface keratin and improve penetration and hence the efficacy of 5-FU 0(cid:1)5% (see Section 8.2.1). 8.7 Laser therapy (strength ofrecommendation B, level Salicylic acid ointment is sometimes used as a preliminary ofevidence 1+) to topical 5-FU to remove overlying keratin. Salicylic acid In principal, dermabrasion, chemical peels and laser treatment 50% in croton oil has been described as a treatment for AKs should treat AKs, as skin is destroyed to a controlled depth. when used in combination with TCA 20% and pretreatment The majority of studies treat field change as well as individual with topical tretinoin as a serial regimen for facial peel.39 The lesions. These physical therapies come with significant risk of ointment base may be acting as an emollient, with some level long-term side-effects including hypopigmentation and persisofsuccess for grade 1AK3,57 (seeSection 8.1.1). tent erythema and scarring. The risk of such problems is In a case series, patients were pretreated with diclofenac 3% greater with ablative rather than nonablative laser techniques, gel for 12 weeks followed by cryosurgery for the 29% with whichalso requireanti-infective prophylaxis.146 residual lesions. This demonstrated an overall effective There are no studies comparing laser treatment with no response to this combined management approach, with comtreatment or placebo. plete clearance maintained for6\u201320 months (mean10).156 An A good-quality, prospective, randomized study of 5-FU open-label multicentre trial of cryosurgery (freeze time of 4\u2013 (twice daily for 4 weeks) vs. erbium-doped yttrium alu10 s) followed by diclofenac 3%gel 15 days later for the next minium garnet (Er:YAG) laser in 55 patients demonstrated 3 months vs. cryosurgery alone found that complete clearance significantly fewer recurrences in the laser group at 6 and increased from 21% to 46% with the addition of diclofenac. 12 months, but more erythema and hypopigmentation in the The clearance rates for a target lesion were greater at 32% vs. long term. No data were reported on clearance at completion 64%.157 A small, right\u2013left-hand study with 4 weeks\u2019 pretreatoftreatment.147 ment using diclofenac 3% gel and PDT compared with plaA second prospective randomized trial of CO laser vs.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 15}, {"text": "data were reported on clearance at completion 64%.157 A small, right\u2013left-hand study with 4 weeks\u2019 pretreatoftreatment.147 ment using diclofenac 3% gel and PDT compared with plaA second prospective randomized trial of CO laser vs. TCA cebo gel and PDT demonstrated a greater decrease in the 2 30% vs. 5-FU (twice daily for 3 weeks) with small patient number and thickness of AKs on the side with diclofenac prenumbers showed a significant clearance of lesions with all treatment,at 12 months.158 interventions at 3 months. The authors also claimed a delayed Diclofenac 3% as pretreatment for 5-FU 0(cid:1)5% in 10% salitime to recurrence of NMSC compared with controls, but the cylic acid is also reported as an effective sequential regicontrolswere thedropouts fromthestudy.148 men.156 There are reports of 5\u20137 days of 5-FU 5% Three retrospective case studies of CO with or without Er: pretreatment also being safe and effective in combination with 2 YAG lasers show clearance and reduced AKs at follow-up.149 cryosurgery110 or PDT159 or, alternatively, in combination Two of these studies demonstrate 80% and 87% clearance at with glycolic peels.38 PDT followed by imiquimod twice a 12 months.150,151 A split-face study of ablative, fractionated week for 16 weeks was beneficial over PDT and subsequent (i.e. multiple pinpoint treatments) CO laser treatment vehicle cream alone when undertaken in a split-face study.160 2 demonstrated only a short-term reduction in the number of Thecompleteclearanceratewasnotreported,butthepercentAKs, not sustained over 3 months.152 One pilot study of nonage reduction in count was significant at 89(cid:1)9% in the comablative fractional laser in 10 patients reported 46% clearance bined treatment side vs. 74(cid:1)5% on the side treated with PDT at 6 months and claimed minimal acute and long-term sidealone. With PDT as the variable rather than imiquimod, PDT effects.153 did not appear to improve the efficacy of imiquimod alone, Dermabrasion in open studies clears AKs on the face and although the study was small. Similarly, adding imiquimod scalp. Coleman et al. treated 23 patients, 96% of whom were 5% two times a week for 8 weeks to cryosurgery does not clear at 6 months and 54% at 5 years.154 Winton and Salasche appeartoimprove on cryosurgery alone. treated fivepatients successfully, withcompleteclearance.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 15}, {"text": "does not clear at 6 months and 54% at 5 years.154 Winton and Salasche appeartoimprove on cryosurgery alone. treated fivepatients successfully, withcompleteclearance.155 An alternative regimen has been studied for hypertrophic A simple, prospective case series describing treatment of AKs on the forearms and the dorsa of the hands. Imiquimod individual AKs with phenol 100% applied once a month for 3(cid:1)75% was used following a double, 5-s freeze\u2013thaw cycle, up to a maximum of 8 months in 32 patients reported no randomized to the right or left arm and applied in up to two recurrence at12 months.37 sachets per night for 2 weeks, followed by a 2-week break and a further 2 weeks. The comparison was cryosurgery alone on the other arm. Complete cure was not an end point, but 8.8 Combination treatment there was a greater level of clearance in the combination arm There are many trials of combination therapy in the treatment (76%) than the control arm (38%). This difference did not of AK; they are of two kinds. The first is a serial approach emerge until 10 weeks after cryosurgery (4 weeks after comwhere one treatment is advocated to follow the other dependpletion of imiquimod), and maximized at the end of assessing on outcome, or as a specific regimen of pretreatment with ment at 14 weeks.87 Randomizing to 5-FU 0(cid:1)5%, 1 week the possibility that the effects of one treatment will maximize, after cryosurgery resulted in nonsignificant improvement over or consolidate, the response. The second approach is to use a cryosurgery alone.161 BritishJournalofDermatology(2017)176,pp20\u201343 \u00a92017BritishAssociationofDermatologists Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 15}, {"text": "Guidelinesforactinickeratosis2017, D.deBerkeretal. 35 Keyrecommendations: physicalandsystemic therapies Studies undertaken in ophthalmology suggest that treatment (cid:129) is possible with close supervision. Treatment can be combined Education at the outset of using physical therapies is with care of the eye. In a case series of 14 patients treating important to ensure a full understanding of the sideperiocular skin with 5-FU 5% twice daily for 2 weeks, antibieffects, which can be marked and include scarring and otic ointment was coprescribed and used until the area had alteredpigmentation. (cid:129) healed.163 Six of the 14 AKs were on the upper eyelid and Cryosurgery is a flexible and effective form of lesionnine abutted a lid margin. Five patients required a second based physical therapy that removes the patient involvecourse of treatment, but overall clearance was complete in all ment in their own care and requires administration in a cases and remained so for a mean follow-up period of servicewith cryosurgery. (cid:129) 38 months. Two patients had transient inflammatory sideCurettage can be warranted for thicker (grade 3) AKs, effects affecting the eye. The potential precision of application wheretheyareresistanttotopicaltherapyandwherethere of 5-FU 0(cid:1)5% in 10% salicylic acid in a collodion base might is suspicion that they may representearly SCC. Histology make it a useful alternative to cream formulation, but there mustalwaysbeobtained.Diagnosticbiopsymaybewarare no dataon this. rantedonthesamebasis,butissubjecttosamplingerror. (cid:129) A retrospective study of the use of imiquimod 5% cream PDT is an effective treatment for confluent AKs, such as for a range of periocular actinic lesions identified 47 patients on the scalp, which are difficult to manage or resistant mainly with AK, mainly on the lower lid.164 Conjunctivitis totreatment in theabsence ofinvasive disease. (cid:129) occurred in 15 and six had ocular stinging, with conjunctivitis PDT has low scarring potential and less risk of poor in three for over 2 weeks. Antibiotics were needed in three, healing in comparison with other physical therapies at for preseptal cellulitis in two of them. Nine patients discontinvulnerable sitessuch asthelower leg. (cid:129) ued imiquimod due to ocular irritation and conjunctivitis, of Pretreatment with topical therapy can increase the effiwhom four patients recommenced and finished the treatment cacy ofphysical therapies. (cid:129) after a rest period.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 16}, {"text": "increase the effiwhom four patients recommenced and finished the treatment cacy ofphysical therapies. (cid:129) after a rest period. At a mean follow-up of 16 weeks, 34 Failure of an individual lesion to respond to physical (72%) patients had clinical clearance of the periocular lesions therapy indicates a need for further evaluation. This and no patient had any residual ophthalmic side-effects from couldinclude formalexcision. (cid:129) imiquimod. Systemic therapy is usually given in the context of mulThere is no good literature on diclofenac gel or ingenol tiple grade 3 AKs, a history of serial SCCs and immunomebutate, but both the PIL and product licence emphasize the suppression. Therapy might be preventive with a importance of avoidingcontact oftheproductwiththeeyes. retinoid and should be undertaken as part of a multidisciplinarydecision,whichmightincludealternatives such asthereduction ofimmunosuppression. 9.1.2 Ears The ear is a common site for the presentation of AK and SCC. The risk of metastasis is higher when SCC arises on 9.0 Special considerations the ear. This means that the context of treatment is slightly different at this site than at others. The wish to treat AK 9.1 Bodysites (strengthof recommendation C, levelof with a view to avoiding evolution to SCC may be a greater evidence 2+) priority. Histological diagnosis of any thicker AKs to Thedatafromavailabletreatmentsindicatethatsometreatments differentiate them from invasive SCC by shave biopsy or are more adaptable than others and that morbidity varies with excision is recommended. These interventions may represent location. The balance ofissues determined by location,charactreatment of such AKs or a preliminary to treatment of teristicsoftheAKsandnatureofthepatientaresummarizedin SCC. Table 3.Thescoringisbasedontheauthors\u2019evaluationofefficacy,easeofuse,morbidityandcost-benefit. 9.1.3 Forearm andhands Actinickeratosesonthedorsumofthehandareoftenmultiple 9.1.1 Periocular and hyperkeratotic. Early management with topical therapy or The main consideration for treatment of periocular AK is the PDT may reduce the need for surgical interventions later. The risk of adverse events involving the eye. All products licensed skin is more tolerant of the side-effects of inflammatory treatfor treatment of AK have cautions in the PIL and/or summary ments and hence permits prolongation of the course of treatof product characteristics concerning getting treatment in the ment in some instances. This may be required due to the eye.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 16}, {"text": "instances. This may be required due to the eye. Creams used near the eye can smear into it, and inflamthicker skin or hindrance to treatment penetration by keratin mation caused by local destructive or inflammatory treatments in thicker AKs. Combinations of salicylic acid and 5-FU or such as cryosurgery may impinge upon the eye either directly curettage can be useful elements of treatment for the grade 3 or indirectly. Typically, liquid nitrogen is used with a contact AKs found on the forearm and back of hands (see Secprobe, ensuring thatcold vapour doesnotdamage theeye.162 tion 8.8) \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 16}, {"text": "36 Guidelinesforactinickeratosis2017, D.deBerkeretal. Anecdotal and limited trial data suggest that treatments for 9.1.4 Below theknee AKs in transplant patients are less effective than in the general Actinic keratosis below the knee are often mixed with SCC population,53 perhaps because AKs are more proliferative and in situ, a disposition to NMSC, including atypical BCCs and hyperkeratotic in this group or because new lesions appear other actinic changes. They are a feature of elderly sunrapidly in the treated site. One study in transplant recipients exposed legs and often coincide with decreasing ability to failed to demonstrate a reduction in the development of subheal. Treatments need to find the right balance between sequent skin cancers in those areas of skin previously treated managing the disease and causing complications, which for AKs with PDT.51 Where safety studies are undertaken in include nonhealing and soft-tissue infection. Treatment is transplantpatientstheyappeartodemonstratereactionssimilar likely to be intermittent, low intensity and chronic; most tothosein nontransplant patients.167 reports are case series with small numbers. Infrequent or pulsed application of 5-FU has been employed,68 as has more 9.3 Follow-up intensive treatment using 5-FU chemowraps.165 This entails the application of 5-FU 5% once a week under an occlusive There are no data concerning the benefit of follow-up in bandage for 7 days over a period of 4\u20138 weeks. Gaps in treatpatients with AKs. Patients and their carers should be educated ment canbeneeded whereskin breakagedevelops. regarding changes that suggest malignancy. Those at high risk Diclofenac 3% gel might be employed with the expectation of NMSC, such as organ transplant recipients, may warrant of fewer side-effects, but possibly less benefit. PDT is used on follow-up; the presence of at least 10 AKs is an indicator of thelower legs,particularly wherethere may beproblems with this risk.18 healing. Where treatments are likely to require evaluation and Keyrecommendations: special sites (Table 2) adjustment, follow-up in primary, intermediate or secondary (cid:129) care is needed. This could be by any member of the healthPoor healing sites such as below the knee in the elderly careteamwithvalidatedcompetenciesinthesafeandeffective require flexible regimens, heightened supervision and management of AK. Current NHS guidance suggests that consideration ofless destructive treatments such asPDT. (cid:129) patients with AK should be managed in primary care.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 17}, {"text": "supervision and management of AK. Current NHS guidance suggests that consideration ofless destructive treatments such asPDT. (cid:129) patients with AK should be managed in primary care. Where The ears are commonly affected by AK and require this is a sole pathology and not complicated by NMSC or attention early in respect to all modalities of treatment, other factors, the patient should be provided with information including preventive action with a broad-brimmed hat toenableongoing diagnosis and care. andsunscreen. (cid:129) Grade 3 AKs on the ear may warrant curettage early to obtain histology andavoid missedearly SCC. 9.4 Treatment failure (cid:129) The skin of the dorsum of the hands can be more resisAll treatments have some risk of failing to achieve clearance of tant to treatment than the head and neck, and warrants anindividuallesion.Wherethisisthecase,thereasonforfailextendedperiods oftopical therapy. (cid:129) ure needs assessment, where one of the possible explanations All licensed treatments include warnings about use near might be that the diagnosis is incorrect. Lesions within the the eye. Periocular AK needs careful assessment in secdifferential diagnosis of AK include SCC in situ, invasive SCC, ondary care. Topical treatments may be possible, but seborrhoeic keratosis, actinic porokeratosis, viral wart and clearguidance andsupervision areneeded. others. Depending on the outcome of this clinical assessment, treatment might be escalated in intensity, duration or type, or thelesion might bebiopsied or treated surgically. 9.2 Immunosuppressed patients An alternative interpretation of failure is that the patient Data on the epidemiology and natural progression of AK in continues to get new AKs. This is not true failure, but more the immunosuppressed are less detailed than in the normal an illustration of the nature of the disease. Once someone is population. The risk of progression to SCC is likely to be diagnosed with AK, they are likely to need intermittent, lifehigher, and therefore there is a greater need for treatment of long treatment. AK. Wallingford et al. highlighted the need to monitor transplant patients with field-change AK disease.166 Within a large 10.0 Economic considerations cohort of renal transplant patients, nearly one-third were found to have AKs. Of these, half were isolated AKs and half Table 3summarizes thecost-effectiveness of therapy. were AKs within a field of AK and actinic change.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 17}, {"text": "found to have AKs. Of these, half were isolated AKs and half Table 3summarizes thecost-effectiveness of therapy. were AKs within a field of AK and actinic change. In the subIt is likely that the number of treatment episodes for AK sequent year, the rate of development of SCC associated with will increase. Australian Medicare data demonstrate an increase the isolated AK was 7%, in comparison with 21% of those of 160% between 1994 and 2012 in claims for use of cryowith field change, most of which (11 of 15) arose within the surgery to treat 10 or more AKs. Many studies have tried to field. Other differences between the two groups were that the assess the cost-effectiveness of treatment for AK.168\u2013171 Howpatients with isolated AKs tended to be transplanted later in ever, the methods of calculation and the different healthcare lifeandhadashorter duration ofimmunosuppression.166 systems andtheirwaysof fundingprevent comparison. BritishJournalofDermatology(2017)176,pp20\u201343 \u00a92017BritishAssociationofDermatologists Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 17}, {"text": "Guidelinesforactinickeratosis2017, D.deBerkeretal. 37 Two nonindustry studies have been published from the quantitative assessment. Examples of all this are found in the U.K. Wilsontried tocomparethecostofPDTandimiquimod, studies referencedin this guideline. taking into account quality of life.172 The author comments Areas of uncertainty in thetreatment of AK that requirefurthat a head-to-head study of imiquimod vs. PDT was required ther studies include (i) measurement and relevance of vitamin to enable more accurate calculations. Muston et al. used effiD and (ii) prospective studies looking at the effect of treatcacy data from the literature to assess the cost\u2013benefit ratio ment ofAKs on subsequent SCCreduction. and reported that the costs and effectiveness of PDT compare well withothertreatmentsfor AK.173 12.0 Recommended audit points At a practical local level, prescriptions that enable the patient tore-treat, extend treatment or treat new areas have an Inthelast20consecutive patients withAKis there cleardocuinherent economic value and enable the patient to exercise mentation of: their judgement. Imiquimod and ingenol mebutate are dis- (cid:129) the location of the AKs indicated on a drawing or a pensed in aliquot packages that are single-treatment doses and head andneckor body map; provided in a number to complete a course of treatment. This (cid:129) the grade or bulk of the AKs (e.g. grade 1, 2, 3 or tightly defines the cost per course of treatment and the terridescriptive; seeTable 1); tory that can be treated. By contrast, Gibbs and Davis noted (cid:129) treatment modality anddosage; that patients used only 31% of the content of a tube of 5-FU (cid:129) information (e.g. a PIL or other suitable source of infor5% in a single course of treatment, which enables substantial mation) provided to the patient on side-effects of treatflexibility in thecourseandterritory oftreatment.174,175 ment,where relevant; (cid:129) the patient having received information on the name 11.0 Future directions andnature oftheir diagnosis; (cid:129) information provided for the GP in diagnosis and future Future directions need to address the human element of bearmanagement, in primary carewhere relevant. ing thelong-term diagnosis of AKand thetechnical challenges of treating AK effectively with low morbidity and acceptable The audit recommendation of 20 cases per department is to cost. The clinical challenge comprises an ageing person with reduce variation in the results due to a single patient, and to barely symptomatic dry areas of skin.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 18}, {"text": "audit recommendation of 20 cases per department is to cost. The clinical challenge comprises an ageing person with reduce variation in the results due to a single patient, and to barely symptomatic dry areas of skin. Education, prevention allow benchmarking between different units. However, and empowerment at this stage may help avoid the situation departments unable to achieve this recommendation may in 10\u201320 years where multiple untreated AKs accumulate and choose toauditallcases seenin thepreceding12 months. the patient presents with advanced AKs mingled with possible SCCs. The earliest stage in the prevention strategy shares 13.0 Summary ground with strategies for the avoidance of skin cancer, and equally shares its concerns of compromised vitamin D levels The findings are summarized in Table 2. See the text for and loss of the indirect benefits of UV exposure. Collaborative details ofevidence. work between patient groups and primary and secondary care Actinic keratoses are a multifocal manifestation of sun damshould aim to find a suitable balanced approach to global care age, comprising a spectrum of clinical complaint and patholof patientswith thisdiagnosis. ogy. They are relapsing and remitting and constitute a chronic Technical assessment of the efficacy of treatments should disease. Most patients can be diagnosed and managed in pricontinue, but usingastandardized modelofreporting. Review mary care. In many instances, management may entail little or of data of AK treatment illustrates the variety of end points in no medical treatment other than advice on sun avoidance and studies, which makes comparison between them difficult. self-monitoring. Where there is clinical concern or the patient These include percentage clearance of lesion number within a specifically wants treatment, therapy can be employed taking person, percentage of people within a study having complete into consideration the specifics of the situation. If there is clearance, percentage of target lesions clearing, and mathematdiagnostic concern or failure torespond tofirst-line treatment, ical models with projected AK behaviour based on withina histological specimen, such as obtained at curettage, shave study data. or formal excision, may be diagnostic and curative. Where The time points at which these measures are defined also AKs are multiple or confluent, at sites of poor healing or with vary and have the scope to alter the result greatly. Treatments poorresponsetostandardtherapies, PDTmay behelpful.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 18}, {"text": "which these measures are defined also AKs are multiple or confluent, at sites of poor healing or with vary and have the scope to alter the result greatly. Treatments poorresponsetostandardtherapies, PDTmay behelpful. Such have optimum times when their outcome is at its best, which patients may also warrant long-term follow-up for the assocican be up to 10 weeks after completion of treatment in the ated increased risk ofNMSC. case of imiquimod. Equally, in the setting of a chronic relapsing and remitting disease, 12and 24-month evaluation Acknowledgments points are relevant. As AK is a multifocal manifestation of sun damage, AKs within a zone at 12 months may represent a We are very grateful to everyone who commented on the mix of relapse and new lesions, which can further complicate draft duringtheconsultation period. \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 18}, {"text": "38 Guidelinesforactinickeratosis2017, D.deBerkeretal. References 22 Dodson JM, DeSpain J, Hewett JE etal. Malignant potential of actinic keratoses and the controversy over treatment. A patient1 BellHK,OrmerodAD.WritingaBritishAssociationofDermatolorientedperspective.ArchDermatol1991;127:1029\u201331. ogists clinical guideline: an update on the process and guidance 23 Foote JA, Harris RB, Giuliano AR etal. Predictors for cutaneous forauthors.BrJDermatol2009;160:725\u20138. basaland squamous-cell carcinoma among actinically damaged 2 Brouwers MC, Kho ME, Browman GP etal. AGREE II: advancing adults.IntJCancer2001;95:7\u201311. guideline development, reporting and evaluation in health care. 24 Chen GJ, Feldman SR, Williford PM etal. Clinical diagnosis of CMAJ2010;182:E839\u201342. actinic keratosis identifies an elderly population at high risk of 3 Olsen EA, Abernethy ML, Kulp-Shorten C etal. A double-blind, developingskincancer.DermatolSurg2005;31:43\u20137. vehicle-controlled study evaluating masoprocol cream in the 25 LambertSR,MladkovaN,GulatiAetal.Keydifferencesidentified treatment of actinic keratoses on the head and neck. J Am Acad betweenactinickeratosisandcutaneoussquamouscellcarcinoma Dermatol1991;24:738\u201343. bytranscriptomeprofiling.BrJCancer2014;110:520\u20139. 4 Vatve M, Ortonne JP, Birch-Machin MA etal. Management of 26 Werner RN, Sammain A, Erdmann R etal. The natural history of field change in actinic keratosis. Br J Dermatol 2007; 157(Suppl. actinic keratosis: a systematic review. Br J Dermatol 2013; 2):21\u20134. 169:502\u201318. 5 Freeman RG. Carcinogenic effects of solar radiation and preven27 Janda M. Teledermatology: its use in the detection and managetionmeasures.Cancer1968;21:1114\u201320. mentofactinickeratosis.CurrProblDermatol2015;46:101\u20137. 6 Ziegler A, Jonason AS, Leffell DJ etal. Sunburn and p53 in the 28 Huerta-Brogeras M, Olmos O, Borbujo J etal. Validation of deronsetofskincancer.Nature1994;372:773\u20136. moscopyasareal-timenoninvasivediagnosticimagingtechnique 7 BarrBB,BentonEC,McLarenKetal.Papillomavirusinfectionand foractinickeratosis.ArchDermatol2012;148:1159\u201364. skincancerinrenalallograftrecipients.Lancet1989;2:224\u20135. 29 Scottish Intercollegiate Guidelines Network. Management of dia8 Baudouin C, Charveron M, Tarroux R etal. Environmental pollubetes:anationalclinicalguideline.Availableat:www.sign.ac.uk/ tantsandskincancer.CellBiolToxicol2002;18:341\u20138. pdf/sign116.pdf(lastaccessed16September2016). 9 Wong ST, Chan HL, Teo SK. The spectrum of cutaneous and 30 Shigaki C, Kruse RL, Mehr D etal. Motivation and diabetes selfinternal malignancies in chronic arsenic toxicity. Singapore Med J management.ChronicIlln2010;6:202\u201314. 1998;39:171\u20133. 31 Stockfleth E. The paradigm shift in treating actinic keratosis: a 10 Norris JF. Sunscreens, suntans, and skin cancer. Local councils comprehensivestrategy.JDrugsDermatol2012;11:1462\u20137. should remove sunbeds from leisure centres. BMJ 1996; 32 Gupta AK, Paquet M, Villanueva E etal. Interventions for actinic 313:941\u20132. keratoses.CochraneDatabaseSystRev2012;12:CD004415. 11 BajdikCD,GallagherRP,AstrakianakisGetal.Non-solarultravio33 Esmann S, Vinding GR, Christensen KB etal. Assessing the influlet radiationand the risk of basaland squamous cell skincancer. ence of actinic keratosis on patients\u2019 quality of life: the AKQoL BrJCancer1996;73:1612\u20134. questionnaire.BrJDermatol2013;168:277\u201383. 12 RoestMA,KeaneFM,AgnewKetal.Multiplesquamousskincar34 EsmannS.Patients\u2019perspectivesonactinickeratosis.CurrProblDercinomas following excess sunbed use. J R Soc Med 2001; 94:636\u2013 matol2015;46:8\u201313. 7. 35 PomerantzH,HoganD,EilersDetal.Long-termefficacyoftopi13 AtkinsD,BangRH,SternbergMRetal.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 19}, {"text": "skincancer. ence of actinic keratosis on patients\u2019 quality of life: the AKQoL BrJCancer1996;73:1612\u20134. questionnaire.BrJDermatol2013;168:277\u201383. 12 RoestMA,KeaneFM,AgnewKetal.Multiplesquamousskincar34 EsmannS.Patients\u2019perspectivesonactinickeratosis.CurrProblDercinomas following excess sunbed use. J R Soc Med 2001; 94:636\u2013 matol2015;46:8\u201313. 7. 35 PomerantzH,HoganD,EilersDetal.Long-termefficacyoftopi13 AtkinsD,BangRH,SternbergMRetal.Reliablemethodstoevalcal fluorouracil cream, 5%, for treating actinic keratosis: a ranuate the burden of actinic keratoses. J Invest Dermatol 2006; domizedclinicaltrial.JAMADermatol2015;151:952\u201360. 126:591\u20134. 36 Witheiler DD, Lawrence N, Cox SE etal. Long-term efficacy and 14 O\u2019Beirn SFO, Judge P, Maccon CF etal. Skin cancer in County safety of Jessner\u2019s solution and 35% trichloroacetic acid versus Galway, Ireland. In: Proceedings of the Sixth National Cancer Conference, 5% fluorouracilin the treatmentofwidespreadfacial actinickersponsoredbytheAmericanCancerSocietyInc.andtheNationalCancerInstitute, atoses.DermatolSurg1997;23:191\u20136. September1968.Philadelphia:Lippincott,1970;489\u2013500. 37 KaminakaC,YamamotoY,YoneiNetal.Phenolpeelsasanovelther15 Harvey I, Frankel S, Marks R etal. Non-melanoma skin cancer apeuticapproachforactinickeratosisandBowendisease:prospective and solar keratoses. I. Methods and descriptive results of the pilottrialwithassessmentofclinical,histologic,andimmunohistoSouthWalesSkinCancerStudy.BrJCancer1996;74:1302\u20137. chemicalcorrelations.JAmAcadDermatol2009;60:615\u201325. 16 Memon AA, Tomenson JA, Bothwell J etal. Prevalence of solar 38 Ditre CM. Treatment of actinic keratosis and photodamaged skin damageandactinickeratosisinaMerseysidepopulation.BrJDerwith 5-fluorouracil 5% cream and glycolic acid peels. Cosmet Dermatol2000;142:1154\u20139. matol2004;17:25\u20137. 17 Eder J, Prillinger K, Korn A etal. Prevalence of actinic keratosis 39 Swinehart JM. Salicylic acid ointment peeling of the hands and among dermatology outpatients in Austria. Br J Dermatol 2014; forearms.Effectivenonsurgicalremovalofpigmentedlesionsand 171:1415\u201321. actinicdamage.JDermatolSurgOncol1992;18:495\u20138. 18 Flohil SC, van der Leest RJ, Dowlatshahi EA etal. Prevalence of 40 Werner RN, Stockfleth E, Connolly SM etal. Evidenceand conactinickeratosisanditsriskfactorsinthegeneralpopulation:the sensus-based(S3)GuidelinesfortheTreatmentofActinicKeratoRotterdamStudy.JInvestDermatol2013;133:1971\u20138. sis \u2013 International League of Dermatological Societies in 19 Marks R, Foley P, Goodman G etal. Spontaneous remission of cooperation with the European Dermatology Forum \u2013 short versolarkeratoses:thecaseforconservativemanagement.BrJDermasion.JEurAcadDermatolVenereol2015;29:2069\u201379. tol1986;115:649\u201355. 41 National Institute for Health and Care Excellence. Improving 20 Criscione VD, Weinstock MA, Naylor MF etal. Actinic keratoses: outcomes for people with skin tumours including melanoma. naturalhistoryandriskofmalignanttransformationintheVeterAvailable at: https://www.nice.org.uk/guidance/csg8 (last ansAffairsTopicalTretinoinChemopreventionTrial.Cancer2009; accessed16September2016). 115:2523\u201330. 42 Chetty P, Choi F, Mitchell T. Primary care review of actinic ker21 Marks R, Rennie G, Selwood TS. Malignant transformation of atosis and its therapeutic options: a global perspective. Dermatol solarkeratosestosquamouscellcarcinoma.Lancet1988;1:795\u20137. Ther(Heidelb)2015;5:19\u201335. BritishJournalofDermatology(2017)176,pp20\u201343 \u00a92017BritishAssociationofDermatologists Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 19}, {"text": "Guidelinesforactinickeratosis2017, D.deBerkeretal. 39 43 Englert C, Hughes B. A review of actinic keratosis for the nurse 62 Eaglstein WH, Weinstein GD, Frost P. Fluorouracil: mechanism practitioner: diagnosis, treatment, and clinical pearls. J Am Acad of action in human skin and actinic keratoses. I. Effect on DNA NursePract2012;24:290\u20136. synthesisinvivo.ArchDermatol1970;101:132\u20139. 44 Primary Care Dermatology Society. Actinic (solar) keratosis: pri63 Ingenol mebutate (Picato) for actinic keratosis. Med Lett Drugs Ther mary care treatment pathway. Available at: http://www.pcds.or2012;54:35\u20136. g.uk/ee/images/uploads/general/Actinic_%28Solar%29_Kerato64 StockflethE, KerlH, ZwingersT etal. Low-dose5-fluorouracilin sis_Primary_Care_Treatment_Pathway.pdf (last accessed 16 combination with salicylic acid as a new lesion-directed option September2016). to treat topically actinic keratoses: histological and clinical study 45 Primary Care Dermatology Society. Actinic keratosis (syn. solar results.BrJDermatol2011;165:1101\u20138. keratosis). Available at: http://www.pcds.org.uk/clinical-gui65 GuptaAK,PaquetM.Networkmeta-analysisoftheoutcome\u2018pardance/actinic-keratosis-syn.-solar-keratosis (last accessed 16 ticipant complete clearance\u2019 in non-immunosuppressed particiSeptember2016). pantsofeightinterventionsforactinickeratosis:afollow-upona 46 ThompsonSC,JolleyD,MarksR.Reductionofsolarkeratosesby Cochranereview.BrJDermatol2013;169:250\u20139. regularsunscreenuse.NEnglJMed1993;329:1147\u201351. 66 Kurwa HA, Yong-Gee SA, Seed PT etal. A randomized paired 47 Naylor MF, Boyd A, Smith DW etal. High sun protection factor comparison of photodynamic therapy and topical 5-fluorouracil sunscreens in the suppression of actinic neoplasia. Arch Dermatol in the treatment of actinic keratoses. J Am Acad Dermatol 1999; 1995;131:170\u20135. 41:414\u20138. 48 Green A, Williams G, Neale R etal. Daily sunscreen application 67 Robinson TA, Kligman AM. Treatment of solar keratoses of the and betacarotene supplementation in preventionof basal-celland extremities with retinoic acid and 5-fluorouracil. Br J Dermatol squamous-cell carcinomas of the skin: a randomised controlled 1975;92:703\u20136. trial.Lancet1999;354:723\u20139. 68 Pearlman DL. Weekly pulse dosing: effective and comfortable 49 Ulrich C, Jurgensen JS, Degen A etal. Prevention of non-melatopical 5-fluorouracil treatment of multiple facial actinic kernomaskincancerinorgantransplantpatientsbyregularuseofa atoses.JAmAcadDermatol1991;25:665\u20137. sunscreen:a24months,prospective,case\u2013controlstudy.BrJDer69 Epstein E. Does intermittent \u2018pulse\u2019 topical 5-fluorouracil therapy matol2009;161(Suppl.3):78\u201384. allow destruction of actinic keratoses without significant inflam50 Euvrard S, Kanitakis J, Claudy A. Skin cancers after organ transmation?JAmAcadDermatol1998;38:77\u201380. plantation.NEnglJMed2003;348:1681\u201391. 70 Jury CS, Ramraka-Jones VS, Gudi V etal. A randomized trial of 51 deGraafYG,KennedyC,WolterbeekRetal.Photodynamicthertopical5%5-fluorouracil(Efudixcream)inthetreatmentofactiapy does not prevent cutaneous squamous-cell carcinoma in nickeratosescomparingdailywithweeklytreatment.BrJDermatol organ-transplant recipients: results of a randomized-controlled 2005;153:808\u201310. trial.JInvestDermatol2006;126:569\u201374. 71 Schlaak M, Simon JC. Topical treatment of actinic keratoses with 52 HarwoodCA,MesherD,McGregorJMetal.Asurveillancemodel low-dose5-fluorouracilincombinationwithsalicylicacid\u2013pilot forskin cancer in organ transplant recipients: a 22-yearprospecstudy.JDtschDermatolGes2010;8:174\u20138. tive study in an ethnically diverse population. Am J Transplant 72 Stockfleth E, Zwingers T, Willers C. Recurrence rates and patient 2013;13:119\u201329. assessedoutcomesof0.5%5-fluorouracilincombinationwithsali53 Dragieva G, Hafner J, Dummer R etal. Topical photodynamic cylicacidtreatingactinickeratoses.EurJDermatol2012;22:370\u20134.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 20}, {"text": "recipients: a 22-yearprospecstudy.JDtschDermatolGes2010;8:174\u20138. tive study in an ethnically diverse population. Am J Transplant 72 Stockfleth E, Zwingers T, Willers C. Recurrence rates and patient 2013;13:119\u201329. assessedoutcomesof0.5%5-fluorouracilincombinationwithsali53 Dragieva G, Hafner J, Dummer R etal. Topical photodynamic cylicacidtreatingactinickeratoses.EurJDermatol2012;22:370\u20134. therapyin thetreatmentofactinickeratosesand Bowen\u2019sdisease 73 Szeimies RM, Dirschka T, Prechtl A etal. Efficacy of low-dose 5intransplantrecipients.Transplantation2004;77:115\u201321. fluorouracil/salicylic acid in actinic keratoses in relation to treat54 McNamara IR, Muir J, Galbraith AJ. Acitretin for prophylaxis of mentduration.JDtschDermatolGes2015;13:430\u20138. cutaneous malignancies after cardiac transplantation. J Heart Lung 74 Hadley G, Derry S, Moore RA. Imiquimod for actinic keratosis: Transplant2002;21:1201\u20135. systematic review and meta-analysis. J Invest Dermatol 2006; 55 Jirakulaporn T,EndrizziB, LindgrenBetal. Capecitabineforskin 126:1251\u20135. cancer prevention in solid organ transplant recipients. Clin Trans75 Stockfleth E, Meyer T, Benninghoff B etal. A randomized, douplant2011;25:541\u20138. ble-blind,vehicle-controlledstudytoassess5%imiquimodcream 56 Black HS, Herd JA, Goldberg LH etal. Effect of a low-fat diet on forthetreatmentofmultipleactinickeratoses.ArchDermatol2002; theincidenceofactinickeratosis.NEnglJMed1994;330:1272\u20135. 138:1498\u2013502. 57 RiversJK,ArletteJ,ShearNetal.Topicaltreatmentofactinicker76 Korman N, Moy R, Ling M etal. Dosing with 5% imiquimod atoseswith 3.0%diclofenacin 2.5%hyaluronangel.Br JDermatol cream 3 times per week for the treatment of actinic keratosis: 2002;146:94\u2013100. resultsoftwophase3,randomized,double-blind,parallel-group, 58 Akarsu S, Aktan S, Atahan A etal. Comparison of topical 3% vehicle-controlledtrials.ArchDermatol2005;141:467\u201373. diclofenac sodium gel and 5% imiquimod cream for the treat77 Alomar A, Bichel J, McRae S. Vehicle-controlled, randomized, mentofactinickeratoses.ClinExpDermatol2011;36:479\u201384. double-blindstudytoassesssafetyandefficacyofimiquimod5% 59 Szeimies RM, Gerritsen MJ, Gupta G etal. Imiquimod 5% cream creamappliedoncedaily3daysperweekinoneortwocourses for the treatment of actinic keratosis: results from a phase III, of treatment of actinic keratoses on the head. Br J Dermatol 2007; randomized, double-blind, vehicle-controlled, clinical trial with 157:133\u201341. histology.JAmAcadDermatol2004;51:547\u201355. 78 FoleyP,MerlinK,CummingSetal.Acomparisonofcryotherapy 60 Jorizzo J, Dinehart S, Matheson R etal. Vehicle-controlled, douand imiquimod for treatment of actinic keratoses: lesion clearble-blind, randomized study of imiquimod 5% cream applied ance, safety, and skin quality outcomes. J Drugs Dermatol 2011; 3days per week in one or two courses of treatment for actinic 10:1432\u20138. keratosesonthehead.JAmAcadDermatol2007;57:265\u20138. 79 Pini AM, Koch S, Scharer L etal. Eruptive keratoacanthoma fol61 DarlingtonS,WilliamsG,NealeRetal.Arandomizedcontrolledtrial lowing topical imiquimod for in situ squamous cell carcinoma of toassesssunscreenapplicationandbetacarotenesupplementationin the skin in a renal transplant recipient. J Am Acad Dermatol 2008; thepreventionofsolarkeratoses.ArchDermatol2003;139:451\u20135. 59(5Suppl.):S116\u20137. \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 20}, {"text": "Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 20}, {"text": "40 Guidelinesforactinickeratosis2017, D.deBerkeretal. 80 Serra-GuillenC,NagoreE,HuesoLetal.Arandomizedcompara96 Anderson L, Melgaard A, Schmeider G etal. Two-day topical tivestudyoftoleranceandsatisfactioninthetreatmentofactinic treatment with ingenol mebutate gel, 0.05% for actinic keratoses keratosisofthefaceandscalpbetween5%imiquimodcreamand on the trunk and extremities: analysis of data pooled from two photodynamictherapywithmethylaminolaevulinate.BrJDermatol trials.JAmAcadDermatol2012;66:AB159. 2011;164:429\u201333. 97 US Food and Drug Administration. Picato (ingenol mebutate) 81 Krawtchenko N, Roewert-Huber J, Ulrich M etal. A randomised gel: drug safety communication \u2013 FDA warns of severe adverse studyoftopical5%imiquimodvs.topical5-fluorouracilvs.cryoevents,requireslabelchanges.Availableat:http://www.fda.gov/ surgery in immunocompetent patients with actinic keratoses: a Safety/MedWatch/SafetyInformation/SafetyAlertsforHumanMedicomparisonofclinicalandhistologicaloutcomesincluding1-year calProducts/ucm459311.htm(lastaccessed16September2016). follow-up.BrJDermatol2007;157(Suppl.2):34\u201340. 98 KuJ.Clinicalreview.NDA202833.PICATOTM(ingenolmebutate 82 LeePK,HarwellWB,LovenKHetal.Long-termclinicaloutcomes gel, PEP005 gel). Available at: http://www.accessdata.fda.- following treatment of actinic keratosis with imiquimod 5% gov/drugsatfda_docs/nda/2012/202833Orig1s000MedR.pdf cream.DermatolSurg2005;31:659\u201364. (lastaccessed16September2016). 83 HankeCW,BeerKR,StockflethEetal.Imiquimod2.5%and3.75% 99 Erlendsson AM, Karmisholt KE, Haak CS etal. Topical corticosforthetreatmentofactinickeratoses:resultsoftwoplacebo-conteroidhasnoinfluenceon inflammation or efficacyafteringenol trolledstudiesofdailyapplicationtothefaceandbaldingscalpfor mebutatetreatmentofgradeItoIIIactinickeratoses(AK):arantwo3-weekcycles.JAmAcadDermatol2010;62:573\u201381. domizedclinicaltrial.JAmAcadDermatol2016;74:709\u201315. 84 Hanke CW, Swanson N, Bruce S etal. Complete clearance is sus100 IanhezM,FleuryLFJr,MiotHAetal.Retinoidsforpreventionand tained for at least 12months after treatment of actinic keratoses treatmentofactinickeratosis.AnBrasDermatol2013;88:585\u201393. of the face or balding scalp via daily dosing with imiquimod 101 KangS, GoldfarbMT,Weiss JS etal.Assessmentofadapalenegel 3.75%or2.5%cream.JDrugsDermatol2011;10:165\u201370. for the treatment of actinic keratoses and lentigines: a random85 SwansonN,AbramovitsW,BermanBetal.Imiquimod2.5%and izedtrial.JAmAcadDermatol2003;49:83\u201390. 3.75% for the treatment of actinic keratoses: results of two pla102 WeinstockMA,BinghamSF,LewRAetal.Topicaltretinointhercebo-controlled studies of daily application to the face and baldapyandall-causemortality.ArchDermatol2009;145:18\u201324. ing scalp for two 2-week cycles. J Am Acad Dermatol 2010; 103 Thai KE, Fergin P, Freeman M etal. A prospective study of the 62:582\u201390. useofcryosurgeryforthetreatmentofactinickeratoses.IntJDer86 Jorizzo JL, Markowitz O, Lebwohl MG etal. A randomized, doumatol2004;43:687\u201392. ble-blinded, placebo-controlled, multicenter, efficacy and safety 104 Szeimies RM, Karrer S, Radakovic-Fijan S etal. Photodynamic study of 3.75% imiquimod cream following cryosurgery for the therapy using topical methyl 5-aminolevulinate compared with treatmentofactinickeratoses.JDrugsDermatol2010;9:1101\u20138. cryotherapy for actinic keratosis: a prospective, randomized 87 Goldenberg G, Linkner RV, Singer G etal. An investigatorstudy.JAmAcadDermatol2002;47:258\u201362. initiated study to assess the safety and efficacy of imiquimod 105 FreemanM,VinciulloC,FrancisDetal.Acomparisonofphotody3.75% cream when used after cryotherapy in the treatment of namictherapyusingtopicalmethylaminolevulinate(Metvix)with hypertrophic actinic keratoses on dorsal hands and forearms. J single cycle cryotherapy in patients with actinic keratosis: a ClinAesthetDermatol2013;6:36\u201343. prospective,randomizedstudy.JDermatologTreat2003;14:99\u2013106. 88 Smith SR, Morhenn VB, Piacquadio DJ. Bilateral comparison of 106 KaufmannR,SpelmanL,WeightmanWetal.Multicentreintrainthe efficacyandtolerabilityof3% diclofenacsodiumgeland 5% dividual randomized trial of topical methyl aminolaevulinate5-fluorouracil cream in the treatment of actinic keratoses of the photodynamic therapy vs. cryotherapy for multiple actinic kerfaceandscalp.JDrugsDermatol2006;5:156\u20139. atosesontheextremities.BrJDermatol2008;158:994\u20139. 89 Segatto MM, Dornelles SI, Silveira VB etal.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 21}, {"text": "of topical methyl aminolaevulinate5-fluorouracil cream in the treatment of actinic keratoses of the photodynamic therapy vs. cryotherapy for multiple actinic kerfaceandscalp.JDrugsDermatol2006;5:156\u20139. atosesontheextremities.BrJDermatol2008;158:994\u20139. 89 Segatto MM, Dornelles SI, Silveira VB etal. Comparative study of 107 Morton C, Campbell S, Gupta G etal. Intraindividual, right\u2013left actinickeratosistreatmentwith3%diclofenacsodiumand5%5comparison of topical methyl aminolaevulinate-photodynamic fluorouracil.AnBrasDermatol2013;88:732\u20138. therapyandcryotherapyinsubjectswithactinickeratoses:amul90 Wolf JE, Taylor JR, Tschen E etal. Topical 3.0% diclofenac in ticentre, randomized controlled study. Br J Dermatol 2006; 2.5% hyaluronan gel in the treatment of actinic keratoses. Int J 155:1029\u201336. Dermatol2001;40:709\u201313. 108 Chiarello SE. Cryopeeling (extensive cryosurgery) for treatment 91 Pflugfelder A, Welter AK, Leiter U etal. Open label randomized of actinic keratoses: an update and comparison. Dermatol Surg studycomparing3monthsvs.6monthstreatmentofactinicker2000;26:728\u201332. atoses with 3% diclofenac in 2.5% hyaluronic acid gel: a trial of 109 AbadirDM.Combinationoftopical5-fluorouracilwithcryothertheGermanDermatologicCooperativeOncologyGroup.JEurAcad apy for treatment of actinic keratoses. J Dermatol Surg Oncol 1983; DermatolVenereol2012;26:48\u201353. 9:403\u20134. 92 Anderson L, Schmieder GJ, Werschler WP etal. Randomized, 110 JorizzoJ,WeissJ,FurstKetal.Effectofa1-weektreatmentwith double-blind,double-dummy,vehicle-controlledstudyofingenol 0.5% topical fluorouracil on occurrence of actinic keratosis after mebutate gel 0.025% and 0.05% for actinic keratosis. J Am Acad cryosurgery: a randomized, vehicle-controlled clinical trial. Arch Dermatol2009;60:934\u201343. Dermatol2004;140:813\u20136. 93 Lebwohl M,SwansonN, AndersonLL etal. Ingenol mebutategel 111 MotleyR,KerseyP,LawrenceCetal.Multiprofessionalguidelines foractinickeratosis.NEnglJMed2012;366:1010\u20139. forthemanagementofthepatientwithprimarycutaneoussqua94 Lebwohl M, Shumack S, Stein Gold L etal. Long-term follow-up mouscellcarcinoma.BrJDermatol2002;146:18\u201325. study of ingenol mebutate gel for the treatment of actinic ker112 MoriartyM,DunnJ,DarraghAetal.Etretinateintreatmentofactiatoses.JAMADermatol2013;149:666\u201370. nickeratosis.Adouble-blindcrossoverstudy.Lancet1982;1:364\u20135. 95 Garbe C, Basset-Seguin N, Poulin Y etal. Efficacy and safety of 113 Watson AB. Preventative effect of etretinate therapy on multiple follow-upfieldtreatmentofactinickeratosiswithingenolmebuactinickeratoses.CancerDetectPrev1986;9:161\u20135. tate 0.015% gel: a randomized, controlled 12-month study. Br J 114 Smit JV, de Sevaux RG, Blokx WA etal. Acitretin treatment in Dermatol2016;174:505\u201313. (pre)malignant skin disorders of renal transplant recipients: BritishJournalofDermatology(2017)176,pp20\u201343 \u00a92017BritishAssociationofDermatologists Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 21}, {"text": "Guidelinesforactinickeratosis2017, D.deBerkeretal. 41 histologic and immunohistochemical effects. J Am Acad Dermatol 130 Scola N, Terras S, Georgas D etal. A randomized, half-side com2004;50:189\u201396. parativestudyofaminolaevulinatephotodynamictherapyvs.CO 2 115 EBPGExpertGrouponRenalTransplantation.Europeanbestpraclaserablationinimmunocompetentpatientswithmultipleactinic tice guidelines for renal transplantation. Section IV: long-term keratoses.BrJDermatol2012;167:1366\u201373. management of the transplant recipient. IV.6.2. Cancer risk after 131 Patel G, Armstrong AW, Eisen DB. Efficacy of photodynamic renal transplantation. Skin cancers: prevention and treatment. therapy versus other interventions in randomized clinical trials NephrolDialTransplant2002;17:S31\u20136. for the treatment of actinic keratoses: a systematic review and 116 Kovach BT, Murphy G, Otley CC etal. Oral retinoids for chemometa-analysis.JAMADermatol2014;150:1281\u20138. prevention of skin cancers in organ transplant recipients: results 132 Serra-Guillen C, Nagore E, Hueso L etal. A randomized pilot ofasurvey.TransplantProc2006;38:1366\u20138. comparative study of topical methyl aminolevulinate photody117 Elmets CA, Viner JL, Pentland AP etal. Chemoprevention of nonnamic therapy versus imiquimod 5% versus sequential applicamelanomaskincancerwithcelecoxib:arandomized,double-blind, tionofboththerapiesinimmunocompetentpatientswithactinic placebo-controlledtrial.JNatlCancerInst2010;102:1835\u201344. keratosis: clinical and histologic outcomes. J Am Acad Dermatol 118 LiebmanTN,SteinJA,PolskyD.Cyclo-oxygenase-2inhibitorsfor 2012;66:e131\u20137. chemoprevention of nonmelanoma skin cancer: is there a role 133 Hadley J, Tristani-Firouzi P, Hull C etal. Results of an investigafortheseagents?JAmAcadDermatol2013;68:173\u20136. tor-initiated single-blind split-face comparison of photodynamic 119 Dirschka T, Radny P, Dominicus R etal. Photodynamic therapy therapy and 5% imiquimod cream for the treatment of actinic withBF-200ALAforthetreatmentofactinickeratosis:resultsof keratoses.DermatolSurg2012;38:722\u20137. a multicentre, randomized, observer-blind phase III study in 134 Sotiriou E, Apalla Z, Maliamani F etal. Intraindividual, right-left comparison with a registered methyl-5-aminolaevulinate cream comparison of topical 5-aminolevulinic acid photodynamic therandplacebo.BrJDermatol2012;166:137\u201346. apy vs. 5% imiquimod cream for actinic keratoses on the upper 120 Szeimies RM, Radny P, Sebastian M etal. Photodynamic therapy extremities.JEurAcadDermatolVenereol2009;23:1061\u20135. withBF-200ALAforthetreatmentofactinickeratosis:resultsof 135 HauschildA,StockflethE,PoppGetal.Optimizationofphotodya prospective, randomized, double-blind, placebo-controlled namic therapy with a novel self-adhesive 5-aminolaevulinic acid phaseIIIstudy.BrJDermatol2010;163:386\u201394. patch:resultsoftworandomizedcontrolledphaseIIIstudies.BrJ 121 Morton CA, Szeimies RM, Sidoroff A etal. European guidelines Dermatol2009;160:1066\u201374. for topical photodynamic therapy part 1: treatment delivery and 136 Szeimies RM, Stockfleth E, Popp G etal. Long-term follow-up of currentindications\u2013actinickeratoses,Bowen\u2019sdisease,basalcell photodynamic therapy with a self-adhesive 5-aminolaevulinic carcinoma.JEurAcadDermatolVenereol2013;27:536\u201344. acidpatch:12monthsdata.BrJDermatol2010;162:410\u20134. 122 Klein A, Karrer S, Horner C etal. Comparing cold-air analgesia, 137 Wiegell SR, Haedersdal M, Philipsen PA etal. Continuous activasystemically administered analgesia and scalp nerve blocks for tion of PpIX by daylight is as effective as and less painful than pain management during photodynamic therapy for actinic kerconventional photodynamic therapy for actinic keratoses; a ranatosisofthescalppresentingasfieldcancerization:arandomized domized, controlled, single-blinded study.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 22}, {"text": "for tion of PpIX by daylight is as effective as and less painful than pain management during photodynamic therapy for actinic kerconventional photodynamic therapy for actinic keratoses; a ranatosisofthescalppresentingasfieldcancerization:arandomized domized, controlled, single-blinded study. Br J Dermatol 2008; controlledtrial.BrJDermatol2015;173:192\u2013200. 158:740\u20136. 123 Petersen B, Wiegell SR, Wulf HC. Light protection of the skin 138 Wiegell SR, Haedersdal M, Eriksen P etal. Photodynamic therapy after photodynamic therapy reduces inflammation: an unblinded of actinic keratoses with 8% and 16% methyl aminolaevulinate randomizedcontrolledstudy.BrJDermatol2014;171:175\u20138. andhome-baseddaylightexposure:adouble-blindedrandomized 124 Tarstedt M, Rosdahl I, Berne B etal. A randomized multicenter clinicaltrial.BrJDermatol2009;160:1308\u201314. study to compare two treatment regimens of topical methyl 139 Wiegell SR, Fabricius S, Stender IM etal. A randomized, multiaminolevulinate (Metvix(cid:1))-PDT in actinic keratosis of the face centre studyof directeddaylight exposure timesof 1\u00bd vs.2\u00bd h andscalp.ActaDermVenereol2005;85:424\u20138. in daylight-mediated photodynamic therapy with methyl amino125 Piacquadio DJ, Chen DM, Farber HF etal. Photodynamic therapy laevulinate in patients with multiple thin actinic keratoses of the withaminolevulinicacidtopicalsolutionandvisiblebluelightin faceandscalp.BrJDermatol2011;164:1083\u201390. the treatment of multiple actinic keratoses of the face and scalp: 140 Rubel DM, Spelman L, Murrell DF etal. Daylight photodynamic investigator-blinded, phase 3, multicenter trials. Arch Dermatol therapywithmethylaminolevulinatecreamasaconvenient,sim2004;140:41\u20136. ilarly effective, nearly painless alternative to conventional photo126 Taub AF, Garretson CB. A randomized, blinded, bilateral intraindynamic therapy in actinic keratosis treatment: a randomized dividual, vehicle-controlled trial of the use of photodynamic controlledtrial.BrJDermatol2014;171:1164\u201371. therapy with 5-aminolevulinic acid and blue light for the treat141 Lacour JP, Ulrich C, Gilaberte Y etal. Daylight photodynamic mentofactinickeratosesoftheupperextremities.JDrugsDermatol therapy with methyl aminolevulinate cream is effective and 2011;10:1049\u201356. nearly painless in treating actinic keratoses: a randomised, inves127 Tschen EH, Wong DS, Pariser DM etal. Photodynamic therapy tigator-blinded, controlled, phase III study throughout Europe. J using aminolaevulinic acid for patients with nonhyperkeratotic EurAcadDermatolVenereol2015;29:2342\u20138. actinic keratoses of the face and scalp: phase IV multicentre 142 Morton CA, Wulf HC, Szeimies RM etal. Practical approach to clinical trial with 12-month follow up. Br J Dermatol 2006; the use of daylight photodynamic therapy with topical methyl 155:1262\u20139. aminolevulinate for actinic keratosis: a European consensus. J Eur 128 SotiriouE,ApallaZ,ChovardaEetal.Singlevs.fractionatedphoAcadDermatolVenereol2015;29:1718\u201323. todynamictherapyforfaceandscalpactinickeratoses:arandom143 SeeJA,ShumackS,MurrellDFetal.Consensusrecommendations ized,intraindividualcomparisontrialwith12-monthfollow-up.J on the use of daylight photodynamic therapy with methyl EurAcadDermatolVenereol2012;26:36\u201340. aminolevulinatecreamforactinickeratosesin Australia.AustralasJ 129 Togsverd-Bo K, Haak CS, Thaysen-Petersen D etal. Intensified Dermatol2016;57:167\u201374. photodynamic therapy of actinic keratoses with fractional CO 144 Wiegell SR, Wulf HC, Szeimies RM etal.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 22}, {"text": "of daylight photodynamic therapy with methyl EurAcadDermatolVenereol2012;26:36\u201340. aminolevulinatecreamforactinickeratosesin Australia.AustralasJ 129 Togsverd-Bo K, Haak CS, Thaysen-Petersen D etal. Intensified Dermatol2016;57:167\u201374. photodynamic therapy of actinic keratoses with fractional CO 144 Wiegell SR, Wulf HC, Szeimies RM etal. Daylight photodynamic 2 laser:arandomizedclinicaltrial.BrJDermatol2012;166:1262\u20139. therapy for actinic keratosis: an international consensus: \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 2, "page": 22}, {"text": "42 Guidelinesforactinickeratosis2017, D.deBerkeretal. International Society for Photodynamic Therapy in Dermatology. 164 Cannon PS, O\u2019Donnell B, Huilgol SC etal. The ophthalmic sideJEurAcadDermatolVenereol2012;26:673\u20139. effects of imiquimod therapy in the management of periocular 145 Genovese G, Fai D, Fai C etal. Daylight methyl-aminolevulinate skinlesions.BrJOphthalmol2011;95:1682\u20135. photodynamictherapyversusingenolmebutateforthetreatment 165 Tallon B, Turnbull N. 5% Fluorouracil chemowraps in the manof actinic keratoses: an intraindividual comparative analysis. Deragement of widespread lower leg solar keratoses and squamous matolTher2016;29:191\u20136. cellcarcinoma.AustralasJDermatol2013;54:313\u20136. 146 de Vries K, Prens EP. Laser treatment and its implications for 166 Wallingford SC, Russell SA, Vail A etal. Actinic keratoses, actinic photodamagedskinand actinickeratosis.CurrProblDermatol2015; field change and associations with squamous cell carcinoma in 46:129\u201335. renal transplant recipients in Manchester, U.K. Acta Derm Venereol 147 Ostertag JU, Quaedvlieg PJ, van der Geer S etal. A clinical com2015;95:830\u20134. parison and long-term follow-up of topical 5-fluorouracil versus 167 InghamAI,WeightmanW.Theefficacyandsafetyoftopical5% laserresurfacinginthetreatmentofwidespreadactinickeratoses. 5-fluorouracil in renal transplant recipients for the treatment of LasersSurgMed2006;38:731\u20139. actinickeratoses.AustralasJDermatol2014;55:204\u20138. 148 Hantash BM, Stewart DB, Cooper ZA etal. Facial resurfacing for 168 Annemans L, Caekelbergh K, Roelandts R etal. Real-life practice nonmelanoma skin cancer prophylaxis. Arch Dermatol 2006; study of the clinical outcome and cost-effectiveness of photody142:976\u201382. namic therapy using methyl aminolevulinate (MAL-PDT) in the 149 SherrySD,MilesBA,FinnRA.Long-termefficacyofcarbondioxmanagement of actinic keratosis and basal cell carcinoma. Eur J idelaserresurfacingforfacialactinickeratosis.JOralMaxillofacSurg Dermatol2008;18:539\u201346. 2007;65:1135\u20139. 169 CaekelberghK,AnnemansL,LambertJetal.Economicevaluation 150 IyerS,FriedliA,BowesLetal.Fullfacelaserresurfacing:therapy of methyl aminolaevulinate-based photodynamic therapy in the and prophylaxis for actinic keratoses and non-melanoma skin management of actinic keratosis and basal cell carcinoma. Br J cancer.LasersSurgMed2004;34:114\u20139. Dermatol2006;155:784\u201390. 151 Ostertag JU, Quaedvlieg PJF, Neumann MHAM, Krekels GA. 170 Colombo GL, Chimenti S, Di Matteo S etal. Cost-effectiveness Recurrence rates and long-term follow-up after laser resurfacing analysis of topical treatments for actinic keratosis in the perspecasatreatmentforwidespreadactinickeratosesinthefaceandon tive of the Italian health care system. G Ital Dermatol Venereol 2010; thescalp.DermatolSurg2006;32:261\u20137. 145:573\u201381. 152 GanSD,HsuSH,ChuangGetal.Ablativefractionallasertherapy 171 GoldMH. Pharmacoeconomicanalysisofthe treatmentofmultiforthetreatmentofactinickeratosis:asplit-facestudy.JAmAcad pleactinickeratoses.JDrugsDermatol2008;7:23\u20135. Dermatol2016;74:387\u20139. 172 Wilson EC. Cost effectiveness of imiquimod 5% cream compared 153 Prens SP, de Vries K, Neumann HA etal. Non-ablative fractional with methyl aminolevulinate-based photodynamic therapy in the resurfacingincombinationwithtopicaltretinoincreamasafield treatment of non-hyperkeratotic, non-hypertrophic actinic (solar) treatment modality for multiple actinic keratosis: a pilot study keratoses: a decision tree model. Pharmacoeconomics 2010; 28:1055\u2013 and areview of other fieldtreatmentmodalities.J DermatologTreat 64. 2013;24:227\u201331. 173 MustonD,DownsA,RivesV.Aneconomicevaluationoftopical 154 ColemanWP,YarboroughJM,MandySH.Dermabrasionforprotreatments for actinic keratosis.", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 23}, {"text": "keratosis: a pilot study keratoses: a decision tree model. Pharmacoeconomics 2010; 28:1055\u2013 and areview of other fieldtreatmentmodalities.J DermatologTreat 64. 2013;24:227\u201331. 173 MustonD,DownsA,RivesV.Aneconomicevaluationoftopical 154 ColemanWP,YarboroughJM,MandySH.Dermabrasionforprotreatments for actinic keratosis. J Dermatolog Treat 2009; 20:266\u2013 phylaxis and treatment of actinic keratoses. Dermatol Surg 1996; 75. 22:17\u201321. 174 GibbsS,DavisT.Usageandpatientexperienceof5%fluorouracil 155 WintonGB,SalascheSJ.Dermabrasionofthescalpasatreatment cream.ClinExpDermatol2015;40:88. foractinicdamage.JAmAcadDermatol1986;14:661\u20138. 175 PereraE,McGuiganS,SinclairR.Costforthetreatmentofactinic 156 MastrolonardoM.Topicaldiclofenac3%gelpluscryotherapyfor keratosisontheriseinAustralia.F1000Res2014;3:184. treatmentofmultipleandrecurrentactinickeratoses.ClinExpDer176 Pariser DM, Lowe NJ, Stewart DM etal. Photodynamic therapy matol2009;34:33\u20135. with topical methyl aminolevulinate for actinic keratosis: results 157 BerlinJM,RigelDS.Diclofenacsodium3%gelinthetreatmentof of a prospective randomized multicenter trial. J Am Acad Dermatol actinickeratosespostcryosurgery.JDrugsDermatol2008;7:669\u201373. 2003;48:227\u201332. 158 VanderGeerS,KrekelsGA.Treatmentofactinickeratosesonthe 177 Colechin E, Sims A, Reay C etal. Ambulight photodynamic therdorsum of the hands: ALA-PDT versus diclofenac 3% gel folapy.Availableat:http://www.nice.org.uk/guidance/mtg6/doculowed by ALA-PDT. A placebo-controlled, double-blind, pilot ments/ambulight-photodynamic-therapy-for-the-treatment-ofstudy.JDermatologTreat2009;20:259\u201365. nonmelanoma-skin-cancer-external-assessment-centre-report2 159 Gilbert DJ. Treatmentof actinic keratoseswith sequential combi- (lastaccessed16September2016). nationof5-fluorouracilandphotodynamictherapy.JDrugsDerma178 Gov.uk. National tariff payment system 2014/15. Available at: tol2005;4:161\u20133. https://www.gov.uk/government/publications/national-tariff-pay160 ShaffelburgM.Treatmentofactinickeratoseswithsequentialuse ment-system-2014-to-2015.(lastaccessed16September2016). ofphotodynamictherapy;and imiquimod5% cream.JDrugs Der179 Personal Social Services Research Unit. Unit costs of health and matol2009;8:35\u20139. social care 2015. Available at: http://www.pssru.ac.uk/project161 HooverWD3rd,JorizzoJL,ClarkARetal.Efficacyofcryosurgery pages/unit-costs/2015(lastaccessed16September2016). and5-fluorouracilcream0.5%combinationtherapyforthetreatmentofactinickeratosis.Cutis2014;94:255\u20139. Supporting information 162 Tuppurainen K. Cryotherapy for eyelid and periocular basal cell carcinomas: outcome in 166 cases over an 8-year period. Graefes AdditionalSupportingInformationmaybefoundintheonline ArchClinExpOphthalmol1995;233:205\u20138. version ofthis articleat thepublisher\u2019s website: 163 Couch SM, Custer PL. Topical 5-fluorouracil for the treatment of periocular actinic keratosis and low-grade squamous malignancy. Appendix S1.Search strategy. OphthalPlastReconstrSurg2012;28:181\u20133. BritishJournalofDermatology(2017)176,pp20\u201343 \u00a92017BritishAssociationofDermatologists Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 23}, {"text": "Guidelinesforactinickeratosis2017, D.deBerkeretal. 43 Appendix 1 Levelsofevidence Levelofevidence Typeofevidence 1++ High-qualitymeta-analyses,systematicreviewsofRCTs,orRCTswithaverylowriskofbias 1+ Well-conductedmeta-analyses,systematicreviewsofRCTs,orRCTswithalowriskofbias 1(cid:3) Meta-analyses,systematicreviewsofRCTs,orRCTswithahighriskofbiasa 2++ High-qualitysystematicreviewsofcase\u2013controlorcohortstudies.High-qualitycase\u2013controlorcohortstudieswitha verylowriskofconfounding,biasorchanceandahighprobabilitythattherelationshipiscausal 2+ Well-conductedcase\u2013controlorcohortstudieswithalowriskofconfounding,biasorchance,andamoderateprobability thattherelationshipiscausal 2(cid:3) Case\u2013controlorcohortstudieswithahighriskofconfounding,biasorchanceandasignificantriskthattherelationship isnotcausala 3 Nonanalyticalstudies(forexamplecasereports,caseseries) 4 Expertopinion,formalconsensus RCT,randomizedcontrolledtrial.aStudieswithalevelofevidence\u2018(cid:3)\u2019shouldnotbeusedasabasisformakingarecommendation. Appendix 2 Strengthsofrecommendation Class Evidence A Atleastonemeta-analysis,systematicrevieworRCTratedas1++,anddirectlyapplicabletothetargetpopulation,or AsystematicreviewofRCTsorabodyofevidenceconsistingprincipallyofstudiesratedas1+,directlyapplicabletothetarget populationanddemonstratingoverallconsistencyofresults,or EvidencedrawnfromaNICEtechnologyappraisal B Abodyofevidenceincludingstudiesratedas2++,directlyapplicabletothetargetpopulationanddemonstratingoverallconsistency ofresults,or Extrapolatedevidencefromstudiesratedas1++or1+ C Abodyofevidenceincludingstudiesratedas2+,directlyapplicabletothetargetpopulationanddemonstratingoverallconsistency ofresults,or Extrapolatedevidencefromstudiesratedas2++ D Evidencelevel3or4,or Extrapolatedevidencefromstudiesratedas2+,or Formalconsensus D(GPP) Agoodpracticepoint(GPP)isarecommendationforbestpracticebasedontheexperienceoftheGuidelineDevelopmentGroup RCT,randomizedcontrolledtrial;NICE,NationalInstituteforHealthandCareExcellence. \u00a92017BritishAssociationofDermatologists BritishJournalofDermatology(2017)176,pp20\u201343 Downloaded from https://academic.oup.com/bjd/article/176/1/20/6747903 by guest on 05 February 2026", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 24}, {"text": "NO COMPROMISE, JUST CLEARANCE Bimzelx\u00ae (bimekizumab) offers the opportunity for complete, fast, and lasting skin clearance and proven PsA efficacy 1\u20137 51.5% 68.2% 75.9% 76.9% (n=222/431) 50.6% (n=238/349) (n=265/349) (N=52)\u2020 (n=135/267) and of biologic-na\u00efve of patients of patients of patients and TNFi-IR PsA patients with PsO achieved with PsO achieved with PsO achieved achieved ACR 50 at PASI 100 at Week 16 PASI 75 at Week 4 PASI 100 at 5 years3 Week 104/100, respectively\u20211,4\u20136 (vs 1.2% placebo [n=1/86], p<0.0001)*,**2 (vs 1.2% placebo [n=1/86], p<0.0001)*,**2 BIMZELX was well tolerated, the most frequently reported adverse reactions were: upper respiratory tract infections and oral candidiasis. Other common reported adverse reactions include tinea infections, ear infections, herpes simplex infections, oropharyngeal candidiasis, gastroenteritis, folliculitis, headache, rash, dermatitis, eczema, acne, injection site reactions, fatigue, and vulvovaginal mycotic infection (including vulvovaginal candidiasis).4 This promotional material has been created and funded by UCB Pharma Ltd and These data are from different clinical trials and cannot be directly compared. is intended for healthcare professionals in the UK. Co-primary endpoints PASI 90 and IGA 0/1 at Week 16 were met.**Secondary endpoints. \u2020N= mNRI, missing data BIMZELX is indicated for the treatment of: moderate to severe plaque PsO in adults were imputed with mNRI (patients with missing data following treatment discontinuation due to lack of efficacy who are candidates for systemic therapy; active PsA, alone or in combination or a TRAE were counted as non-responders; multiple imputation methodology was used for other missing data). with methotrexate, in adults who have had an inadequate response, or who have \u202143.9% (n=189/431), and 43.4% (n=116/267) of biologic-na\u00efve and TNFi-IR PsA patients achieved the primary been intolerant, to one or more DMARDs; active nr-axSpA with objective signs of endpoint of ACR 50 at Week 16 in BE OPTIMAL and BE COMPLETE, respectively (vs 10.0% [n=28/281] and 6.8% inflammation as indicated by elevated CRP and/or MRI, in adults who have responded [n=9/133] placebo, p<0.0001); 54.5% (n=235/431) and 51.7% (n=138/267) maintained it at Week 52 (NRI).", "source": "actinic keratosis guideline.pdf", "chunk_id": 0, "page": 25}, {"text": "in adults who have responded ACR 50, \u226550% response in the American College of Rheumatology criteria; AS, ankylosing spondylitis; CRP, inadequately or are intolerant to conventional therapy; and active moderate to severe C-reactive protein; DMARD, disease-modifying antirheumatic drug; HS, hidradenitis suppurativa; IGA, Investigator\u2019s HS (acne inversa) in adults with an inadequate response to conventional systemic HS Global Assessment; (m)NRI, (modified) non-responder imputation; MRI, magnetic resonance imaging; nrtherapy.4 axSpA, non-radiographic axial spondyloarthritis; NSAID, non-steroidal anti-inflammatory drug; PASI 75/90/100, Prescribing information for United Kingdom click here. \u226575/90/100% improvement from baseline in Psoriasis Area and Severity Index; PsA, psoriatic arthritis; PsD, psoriatic Please refer to the SmPC for further information. disease; PsO, psoriasis; TNFi-IR, tumour necrosis factor-\u03b1 inhibitor \u2013 inadequate responder; TRAE, treatmentrelated adverse event. References: 1. Gordon KB, et al. Lancet. 2021;397(10273):475\u2013486. 2. Blauvelt. 2025. AAD Presentation 62275. 3. Mease PJ, et al. Rheumatol Ther. 2024;11(5):1363\u20131382. 4. BIMZELX SmPC. 5. Ritchlin CT, et al. Ann Rheum Dis. 2023;82(11):1404\u20131414. 6. Coates LC, et al. RMD Open. 2024;10(1):e003855. 7. Strober B, et al. AAD 2024;oral \uf071This medicine is subject to additional monitoring. This will allow quick presentation. identification of new safety information. Adverse events should be reported. Reporting forms and information can be found at www.yellowcard.mhra.gov.uk for the UK. Adverse events should also be reported to UCB Pharma Ltd at UCBCares.UK@UCB.com or 0800 2793177 for UK. GB-BK-2500315 | July 2025 UCB Biopharma SRL, 2025. All rights reserved.", "source": "actinic keratosis guideline.pdf", "chunk_id": 1, "page": 25}] \ No newline at end of file diff --git a/guidelines/index/faiss.index b/guidelines/index/faiss.index new file mode 100644 index 0000000000000000000000000000000000000000..479024b0dc94349482d84015406915d8888f7be4 --- /dev/null +++ b/guidelines/index/faiss.index @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:faf9cd914f52b84a55d486a156b4756f28dbc1a92abeafc121077402e1fa53f4 +size 145965 diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/mcp_server/server.py b/mcp_server/server.py new file mode 100644 index 0000000000000000000000000000000000000000..ee58af181f950ec6008709306e72499c9378fb53 --- /dev/null +++ b/mcp_server/server.py @@ -0,0 +1,286 @@ +""" +SkinProAI MCP Server - Pure JSON-RPC 2.0 stdio server (no mcp library required). + +Uses sys.executable (venv Python) so all ML packages (torch, transformers, etc.) +are available. Tools are loaded lazily on first call. + +Run standalone: python mcp_server/server.py +(Should start silently, waiting on stdin.) +""" + +import sys +import json +import os + +# Ensure project root is on path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from mcp_server.tool_registry import get_monet, get_convnext, get_gradcam, get_rag + + +# --------------------------------------------------------------------------- +# Tool implementations +# --------------------------------------------------------------------------- + +def _monet_analyze(arguments: dict) -> dict: + from PIL import Image + image = Image.open(arguments["image_path"]).convert("RGB") + return get_monet().analyze(image) + + +def _classify_lesion(arguments: dict) -> dict: + from PIL import Image + image = Image.open(arguments["image_path"]).convert("RGB") + monet_scores = arguments.get("monet_scores") + return get_convnext().classify( + clinical_image=image, + derm_image=None, + monet_scores=monet_scores, + ) + + +def _generate_gradcam(arguments: dict) -> dict: + from PIL import Image + import tempfile + image = Image.open(arguments["image_path"]).convert("RGB") + result = get_gradcam().analyze(image) + + gradcam_file = tempfile.NamedTemporaryFile(suffix="_gradcam.png", delete=False) + gradcam_path = gradcam_file.name + gradcam_file.close() + result["overlay"].save(gradcam_path) + + return { + "gradcam_path": gradcam_path, + "predicted_class": result["predicted_class"], + "predicted_class_full": result["predicted_class_full"], + "confidence": result["confidence"], + } + + +def _search_guidelines(arguments: dict) -> dict: + query = arguments.get("query", "") + diagnosis = arguments.get("diagnosis") or "" + rag = get_rag() + context, references = rag.get_management_context(diagnosis, query) + references_display = rag.format_references_for_display(references) + return { + "context": context, + "references": references, + "references_display": references_display, + } + + +def _compare_images(arguments: dict) -> dict: + from PIL import Image + import tempfile + image1 = Image.open(arguments["image1_path"]).convert("RGB") + image2 = Image.open(arguments["image2_path"]).convert("RGB") + + from models.overlay_tool import get_overlay_tool + comparison = get_overlay_tool().generate_comparison_overlay( + image1, image2, label1="Previous", label2="Current" + ) + comparison_path = comparison["path"] + + monet = get_monet() + prev_result = monet.analyze(image1) + curr_result = monet.analyze(image2) + + monet_deltas = {} + for name in curr_result["features"]: + prev_val = prev_result["features"].get(name, 0.0) + curr_val = curr_result["features"][name] + delta = curr_val - prev_val + if abs(delta) > 0.1: + monet_deltas[name] = { + "previous": prev_val, + "current": curr_val, + "delta": delta, + } + + # Generate GradCAM for both images so the frontend can show a side-by-side comparison + prev_gradcam_path = None + curr_gradcam_path = None + try: + gradcam = get_gradcam() + prev_gc = gradcam.analyze(image1) + curr_gc = gradcam.analyze(image2) + + f1 = tempfile.NamedTemporaryFile(suffix="_gradcam.png", delete=False) + prev_gradcam_path = f1.name + f1.close() + prev_gc["overlay"].save(prev_gradcam_path) + + f2 = tempfile.NamedTemporaryFile(suffix="_gradcam.png", delete=False) + curr_gradcam_path = f2.name + f2.close() + curr_gc["overlay"].save(curr_gradcam_path) + except Exception: + pass # GradCAM comparison is best-effort + + return { + "comparison_path": comparison_path, + "monet_deltas": monet_deltas, + "prev_gradcam_path": prev_gradcam_path, + "curr_gradcam_path": curr_gradcam_path, + } + + +TOOLS = { + "monet_analyze": _monet_analyze, + "classify_lesion": _classify_lesion, + "generate_gradcam": _generate_gradcam, + "search_guidelines": _search_guidelines, + "compare_images": _compare_images, +} + +TOOLS_LIST = [ + { + "name": "monet_analyze", + "description": "Extract MONET concept-presence scores from a skin lesion image.", + "inputSchema": { + "type": "object", + "properties": {"image_path": {"type": "string"}}, + "required": ["image_path"], + }, + }, + { + "name": "classify_lesion", + "description": "Classify a skin lesion using ConvNeXt dual-encoder.", + "inputSchema": { + "type": "object", + "properties": { + "image_path": {"type": "string"}, + "monet_scores": {"type": "array"}, + }, + "required": ["image_path"], + }, + }, + { + "name": "generate_gradcam", + "description": "Generate a Grad-CAM attention overlay for a skin lesion image.", + "inputSchema": { + "type": "object", + "properties": {"image_path": {"type": "string"}}, + "required": ["image_path"], + }, + }, + { + "name": "search_guidelines", + "description": "Search clinical guidelines RAG for management context.", + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string"}, + "diagnosis": {"type": "string"}, + }, + "required": ["query"], + }, + }, + { + "name": "compare_images", + "description": "Generate comparison overlay and MONET deltas for two lesion images.", + "inputSchema": { + "type": "object", + "properties": { + "image1_path": {"type": "string"}, + "image2_path": {"type": "string"}, + }, + "required": ["image1_path", "image2_path"], + }, + }, +] + + +# --------------------------------------------------------------------------- +# JSON-RPC 2.0 dispatcher +# --------------------------------------------------------------------------- + +def handle_request(request: dict): + method = request.get("method") + req_id = request.get("id") # None for notifications + params = request.get("params", {}) + + if method == "initialize": + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "SkinProAI", "version": "1.0.0"}, + }, + } + + if method in ("notifications/initialized",): + return None # notification — no response + + if method == "tools/list": + return { + "jsonrpc": "2.0", + "id": req_id, + "result": {"tools": TOOLS_LIST}, + } + + if method == "tools/call": + name = params.get("name") + arguments = params.get("arguments", {}) + if name not in TOOLS: + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32601, "message": f"Unknown tool: {name}"}, + } + try: + result = TOOLS[name](arguments) + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": json.dumps(result)}], + "isError": False, + }, + } + except Exception as e: + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": f"Tool error: {e}"}], + "isError": True, + }, + } + + # Unknown method with id → method not found + if req_id is not None: + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32601, "message": f"Method not found: {method}"}, + } + + return None # unknown notification — ignore + + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + +def main(): + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + request = json.loads(line) + except json.JSONDecodeError: + continue + response = handle_request(request) + if response is not None: + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/mcp_server/tool_registry.py b/mcp_server/tool_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..0dcb5b294cde66c84888505fb4fdff80e5c241df --- /dev/null +++ b/mcp_server/tool_registry.py @@ -0,0 +1,55 @@ +""" +Lazy singleton loader for all 4 ML models used by the MCP server. +Fixes sys.path so the subprocess can import from models/. +""" + +import sys +import os + +# Ensure project root is on path (this file lives at project_root/mcp_server/tool_registry.py) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +_monet = None +_convnext = None +_gradcam = None +_rag = None + + +def get_monet(): + global _monet + if _monet is None: + from models.monet_tool import MonetTool + _monet = MonetTool() + _monet.load() + return _monet + + +def get_convnext(): + global _convnext + if _convnext is None: + from models.convnext_classifier import ConvNeXtClassifier + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + _convnext = ConvNeXtClassifier( + checkpoint_path=os.path.join(root, "models", "seed42_fold0.pt") + ) + _convnext.load() + return _convnext + + +def get_gradcam(): + global _gradcam + if _gradcam is None: + from models.gradcam_tool import GradCAMTool + _gradcam = GradCAMTool(classifier=get_convnext()) + _gradcam.load() + return _gradcam + + +def get_rag(): + global _rag + if _rag is None: + from models.guidelines_rag import get_guidelines_rag + _rag = get_guidelines_rag() + if not _rag.loaded: + _rag.load_index() + return _rag diff --git a/models/convnext_classifier.py b/models/convnext_classifier.py new file mode 100644 index 0000000000000000000000000000000000000000..fc6b47d05c40078fc25007fab64e3a7930bb2855 --- /dev/null +++ b/models/convnext_classifier.py @@ -0,0 +1,383 @@ +""" +ConvNeXt Classifier Tool - Skin lesion classification using ConvNeXt + MONET features +Loads seed42_fold0.pt checkpoint and performs classification. +""" + +import torch +import torch.nn as nn +import numpy as np +from PIL import Image +from torchvision import transforms +from typing import Optional, Dict, List, Tuple +import timm + + +# Class names for the 11-class skin lesion classification +CLASS_NAMES = [ + 'AKIEC', 'BCC', 'BEN_OTH', 'BKL', 'DF', + 'INF', 'MAL_OTH', 'MEL', 'NV', 'SCCKA', 'VASC' +] + +CLASS_FULL_NAMES = { + 'AKIEC': 'Actinic Keratosis / Intraepithelial Carcinoma', + 'BCC': 'Basal Cell Carcinoma', + 'BEN_OTH': 'Benign Other', + 'BKL': 'Benign Keratosis-like Lesion', + 'DF': 'Dermatofibroma', + 'INF': 'Inflammatory', + 'MAL_OTH': 'Malignant Other', + 'MEL': 'Melanoma', + 'NV': 'Melanocytic Nevus', + 'SCCKA': 'Squamous Cell Carcinoma / Keratoacanthoma', + 'VASC': 'Vascular Lesion' +} + + +class ConvNeXtDualEncoder(nn.Module): + """ + Dual-image ConvNeXt model matching the trained checkpoint. + Processes BOTH clinical and dermoscopy images through shared backbone. + + Metadata input: 19 dimensions + - age (1): normalized age + - sex (4): one-hot encoded + - site (7): one-hot encoded (reduced from 14) + - MONET (7): 7 MONET feature scores + """ + + def __init__( + self, + model_name: str = 'convnext_base.fb_in22k_ft_in1k', + metadata_dim: int = 19, + num_classes: int = 11, + dropout: float = 0.3 + ): + super().__init__() + + self.backbone = timm.create_model( + model_name, + pretrained=False, + num_classes=0 + ) + backbone_dim = self.backbone.num_features # 1024 for convnext_base + + # Metadata MLP: 19 -> 64 + self.meta_mlp = nn.Sequential( + nn.Linear(metadata_dim, 64), + nn.LayerNorm(64), + nn.GELU(), + nn.Dropout(dropout) + ) + + # Classifier: 2112 -> 512 -> 256 -> 11 + # Input: clinical(1024) + derm(1024) + meta(64) = 2112 + fusion_dim = backbone_dim * 2 + 64 + self.classifier = nn.Sequential( + nn.Linear(fusion_dim, 512), + nn.LayerNorm(512), + nn.GELU(), + nn.Dropout(dropout), + nn.Linear(512, 256), + nn.LayerNorm(256), + nn.GELU(), + nn.Dropout(dropout), + nn.Linear(256, num_classes) + ) + + self.metadata_dim = metadata_dim + self.num_classes = num_classes + self.backbone_dim = backbone_dim + + def forward( + self, + clinical_img: torch.Tensor, + derm_img: Optional[torch.Tensor] = None, + metadata: Optional[torch.Tensor] = None + ) -> torch.Tensor: + """ + Forward pass with dual images. + + Args: + clinical_img: [B, 3, H, W] clinical image tensor + derm_img: [B, 3, H, W] dermoscopy image tensor (uses clinical if None) + metadata: [B, 19] metadata tensor (zeros if None) + + Returns: + logits: [B, 11] + """ + # Process clinical image + clinical_features = self.backbone(clinical_img) + + # Process dermoscopy image + if derm_img is not None: + derm_features = self.backbone(derm_img) + else: + derm_features = clinical_features + + # Process metadata + if metadata is not None: + meta_features = self.meta_mlp(metadata) + else: + batch_size = clinical_features.size(0) + meta_features = torch.zeros( + batch_size, 64, + device=clinical_features.device + ) + + # Concatenate: [B, 1024] + [B, 1024] + [B, 64] = [B, 2112] + fused = torch.cat([clinical_features, derm_features, meta_features], dim=1) + logits = self.classifier(fused) + + return logits + + +class ConvNeXtClassifier: + """ + ConvNeXt classifier tool for skin lesion classification. + Uses dual images (clinical + dermoscopy) and MONET features. + """ + + # Site mapping for metadata encoding + SITE_MAPPING = { + 'head': 0, 'neck': 0, 'face': 0, # head_neck_face + 'trunk': 1, 'back': 1, 'chest': 1, 'abdomen': 1, + 'upper': 2, 'arm': 2, 'hand': 2, # upper extremity + 'lower': 3, 'leg': 3, 'foot': 3, 'thigh': 3, # lower extremity + 'genital': 4, 'oral': 5, 'acral': 6, + } + + SEX_MAPPING = {'male': 0, 'female': 1, 'other': 2, 'unknown': 3} + + def __init__( + self, + checkpoint_path: str = "models/seed42_fold0.pt", + device: Optional[str] = None + ): + self.checkpoint_path = checkpoint_path + self.device = device + self.model = None + self.loaded = False + + # Image preprocessing + self.transform = transforms.Compose([ + transforms.Resize((384, 384)), + transforms.ToTensor(), + transforms.Normalize( + mean=[0.485, 0.456, 0.406], + std=[0.229, 0.224, 0.225] + ) + ]) + + def load(self): + """Load the ConvNeXt model from checkpoint""" + if self.loaded: + return + + # Determine device + if self.device is None: + if torch.cuda.is_available(): + self.device = "cuda" + elif torch.backends.mps.is_available(): + self.device = "mps" + else: + self.device = "cpu" + + # Create model + self.model = ConvNeXtDualEncoder( + model_name='convnext_base.fb_in22k_ft_in1k', + metadata_dim=19, + num_classes=11, + dropout=0.3 + ) + + # Load checkpoint + checkpoint = torch.load( + self.checkpoint_path, + map_location=self.device, + weights_only=False + ) + + if isinstance(checkpoint, dict) and 'model_state_dict' in checkpoint: + self.model.load_state_dict(checkpoint['model_state_dict']) + else: + self.model.load_state_dict(checkpoint) + + self.model.to(self.device) + self.model.eval() + self.loaded = True + + def encode_metadata( + self, + age: Optional[float] = None, + sex: Optional[str] = None, + site: Optional[str] = None, + monet_scores: Optional[List[float]] = None + ) -> torch.Tensor: + """ + Encode metadata into 19-dim vector. + + Layout: [age(1), sex(4), site(7), monet(7)] = 19 + + Args: + age: Patient age in years + sex: 'male', 'female', 'other', or None + site: Anatomical site string + monet_scores: List of 7 MONET feature scores + + Returns: + torch.Tensor of shape [19] + """ + features = [] + + # Age (1 dim) - normalized + age_norm = (age - 50) / 30 if age is not None else 0.0 + features.append(age_norm) + + # Sex (4 dim) - one-hot + sex_onehot = [0.0] * 4 + if sex: + sex_idx = self.SEX_MAPPING.get(sex.lower(), 3) + sex_onehot[sex_idx] = 1.0 + features.extend(sex_onehot) + + # Site (7 dim) - one-hot + site_onehot = [0.0] * 7 + if site: + site_lower = site.lower() + for key, idx in self.SITE_MAPPING.items(): + if key in site_lower: + site_onehot[idx] = 1.0 + break + features.extend(site_onehot) + + # MONET (7 dim) + if monet_scores is not None and len(monet_scores) == 7: + features.extend(monet_scores) + else: + features.extend([0.0] * 7) + + return torch.tensor(features, dtype=torch.float32) + + def preprocess_image(self, image: Image.Image) -> torch.Tensor: + """Preprocess PIL image for model input""" + if image.mode != "RGB": + image = image.convert("RGB") + return self.transform(image).unsqueeze(0) + + def classify( + self, + clinical_image: Image.Image, + derm_image: Optional[Image.Image] = None, + age: Optional[float] = None, + sex: Optional[str] = None, + site: Optional[str] = None, + monet_scores: Optional[List[float]] = None, + top_k: int = 5 + ) -> Dict: + """ + Classify a skin lesion. + + Args: + clinical_image: Clinical (close-up) image + derm_image: Dermoscopy image (optional, uses clinical if None) + age: Patient age + sex: Patient sex + site: Anatomical site + monet_scores: 7 MONET feature scores + top_k: Number of top predictions to return + + Returns: + dict with 'predictions', 'probabilities', 'top_class', 'confidence' + """ + if not self.loaded: + self.load() + + # Preprocess images + clinical_tensor = self.preprocess_image(clinical_image).to(self.device) + + if derm_image is not None: + derm_tensor = self.preprocess_image(derm_image).to(self.device) + else: + derm_tensor = None + + # Encode metadata + metadata = self.encode_metadata(age, sex, site, monet_scores) + metadata_tensor = metadata.unsqueeze(0).to(self.device) + + # Run inference + with torch.no_grad(): + logits = self.model(clinical_tensor, derm_tensor, metadata_tensor) + probs = torch.softmax(logits, dim=1)[0].cpu().numpy() + + # Get top-k predictions + top_indices = np.argsort(probs)[::-1][:top_k] + + predictions = [] + for idx in top_indices: + predictions.append({ + 'class': CLASS_NAMES[idx], + 'full_name': CLASS_FULL_NAMES[CLASS_NAMES[idx]], + 'probability': float(probs[idx]) + }) + + return { + 'predictions': predictions, + 'probabilities': probs.tolist(), + 'top_class': CLASS_NAMES[top_indices[0]], + 'confidence': float(probs[top_indices[0]]), + 'all_classes': CLASS_NAMES, + } + + def __call__( + self, + clinical_image: Image.Image, + derm_image: Optional[Image.Image] = None, + **kwargs + ) -> Dict: + """Shorthand for classify()""" + return self.classify(clinical_image, derm_image, **kwargs) + + +# Singleton instance +_convnext_instance = None + + +def get_convnext_classifier(checkpoint_path: str = "models/seed42_fold0.pt") -> ConvNeXtClassifier: + """Get or create ConvNeXt classifier instance""" + global _convnext_instance + if _convnext_instance is None: + _convnext_instance = ConvNeXtClassifier(checkpoint_path) + return _convnext_instance + + +if __name__ == "__main__": + import sys + + print("ConvNeXt Classifier Test") + print("=" * 50) + + classifier = ConvNeXtClassifier() + print("Loading model...") + classifier.load() + print("Model loaded!") + + if len(sys.argv) > 1: + image_path = sys.argv[1] + print(f"\nClassifying: {image_path}") + + image = Image.open(image_path).convert("RGB") + + # Example with mock MONET scores + monet_scores = [0.2, 0.1, 0.05, 0.3, 0.7, 0.1, 0.05] + + result = classifier.classify( + clinical_image=image, + age=55, + sex="male", + site="back", + monet_scores=monet_scores + ) + + print("\nTop Predictions:") + for pred in result['predictions']: + print(f" {pred['probability']:.1%} - {pred['class']} ({pred['full_name']})") diff --git a/models/explainability.py b/models/explainability.py new file mode 100644 index 0000000000000000000000000000000000000000..be738bac5f91618e6bd2abba7a19896c9ec6158e --- /dev/null +++ b/models/explainability.py @@ -0,0 +1,183 @@ +# models/explainability.py + +import torch +import torch.nn.functional as F +import numpy as np +import cv2 +from typing import Tuple +from PIL import Image + +class GradCAM: + """ + Gradient-weighted Class Activation Mapping + Shows which regions of image are important for prediction + """ + + def __init__(self, model: torch.nn.Module, target_layer: str = None): + """ + Args: + model: The neural network + target_layer: Layer name to compute CAM on (usually last conv layer) + """ + self.model = model + self.gradients = None + self.activations = None + + # Auto-detect target layer if not specified + if target_layer is None: + # Use last ConvNeXt stage + self.target_layer = model.convnext.stages[-1] + else: + self.target_layer = dict(model.named_modules())[target_layer] + + # Register hooks + self.target_layer.register_forward_hook(self._save_activation) + self.target_layer.register_full_backward_hook(self._save_gradient) + + def _save_activation(self, module, input, output): + """Save forward activations""" + self.activations = output.detach() + + def _save_gradient(self, module, grad_input, grad_output): + """Save backward gradients""" + self.gradients = grad_output[0].detach() + + def generate_cam( + self, + image: torch.Tensor, + target_class: int = None + ) -> np.ndarray: + """ + Generate Class Activation Map + + Args: + image: Input image [1, 3, H, W] + target_class: Class to generate CAM for (None = predicted class) + + Returns: + cam: Activation map [H, W] normalized to 0-1 + """ + self.model.eval() + + # Forward pass + output = self.model(image) + + # Use predicted class if not specified + if target_class is None: + target_class = output.argmax(dim=1).item() + + # Zero gradients + self.model.zero_grad() + + # Backward pass for target class + output[0, target_class].backward() + + # Get gradients and activations + gradients = self.gradients[0] # [C, H, W] + activations = self.activations[0] # [C, H, W] + + # Global average pooling of gradients + weights = gradients.mean(dim=(1, 2)) # [C] + + # Weighted sum of activations + cam = torch.zeros(activations.shape[1:], dtype=torch.float32) + for i, w in enumerate(weights): + cam += w * activations[i] + + # ReLU + cam = F.relu(cam) + + # Normalize to 0-1 + cam = cam.cpu().numpy() + cam = cam - cam.min() + if cam.max() > 0: + cam = cam / cam.max() + + return cam + + def overlay_cam_on_image( + self, + image: np.ndarray, # [H, W, 3] RGB + cam: np.ndarray, # [h, w] + alpha: float = 0.5, + colormap: int = cv2.COLORMAP_JET + ) -> np.ndarray: + """ + Overlay CAM heatmap on original image + + Returns: + overlay: [H, W, 3] RGB image with heatmap + """ + H, W = image.shape[:2] + + # Resize CAM to image size + cam_resized = cv2.resize(cam, (W, H)) + + # Convert to heatmap + heatmap = cv2.applyColorMap( + np.uint8(255 * cam_resized), + colormap + ) + heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) + + # Blend with original image + overlay = (alpha * heatmap + (1 - alpha) * image).astype(np.uint8) + + return overlay + +class AttentionVisualizer: + """Visualize MedSigLIP attention maps""" + + def __init__(self, model): + self.model = model + + def get_attention_maps(self, image: torch.Tensor) -> np.ndarray: + """ + Extract attention maps from MedSigLIP + + Returns: + attention: [num_heads, H, W] attention weights + """ + # Forward pass + with torch.no_grad(): + _ = self.model(image) + + # Get last layer attention from MedSigLIP + # Shape: [batch, num_heads, seq_len, seq_len] + attention = self.model.medsiglip_features + + # Average across heads and extract spatial attention + # This is model-dependent - adjust based on MedSigLIP architecture + + # Placeholder implementation + # You'll need to adapt this to your specific MedSigLIP implementation + return np.random.rand(14, 14) # Placeholder + + def overlay_attention( + self, + image: np.ndarray, + attention: np.ndarray, + alpha: float = 0.6 + ) -> np.ndarray: + """Overlay attention map on image""" + H, W = image.shape[:2] + + # Resize attention to image size + attention_resized = cv2.resize(attention, (W, H)) + + # Normalize + attention_resized = (attention_resized - attention_resized.min()) + if attention_resized.max() > 0: + attention_resized = attention_resized / attention_resized.max() + + # Create colored overlay + heatmap = cv2.applyColorMap( + np.uint8(255 * attention_resized), + cv2.COLORMAP_VIRIDIS + ) + heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) + + # Blend + overlay = (alpha * heatmap + (1 - alpha) * image).astype(np.uint8) + + return overlay \ No newline at end of file diff --git a/models/gradcam_tool.py b/models/gradcam_tool.py new file mode 100644 index 0000000000000000000000000000000000000000..3d17e47be8e637f28dcab2fafbf81e5781e18b3b --- /dev/null +++ b/models/gradcam_tool.py @@ -0,0 +1,285 @@ +""" +Grad-CAM Tool - Visual explanation of ConvNeXt predictions +Shows which regions of the image the model focuses on. +""" + +import torch +import torch.nn.functional as F +import numpy as np +from PIL import Image +from torchvision import transforms +from typing import Optional, Tuple +import cv2 + + +class GradCAM: + """ + Grad-CAM implementation for ConvNeXt model. + Generates heatmaps showing model attention. + """ + + def __init__(self, model, target_layer=None): + """ + Args: + model: ConvNeXtDualEncoder model + target_layer: Layer to extract gradients from (default: last conv layer) + """ + self.model = model + self.gradients = None + self.activations = None + + # Hook the target layer (last stage of backbone) + if target_layer is None: + target_layer = model.backbone.stages[-1] + + target_layer.register_forward_hook(self._save_activation) + target_layer.register_full_backward_hook(self._save_gradient) + + def _save_activation(self, module, input, output): + """Save activations during forward pass""" + self.activations = output.detach() + + def _save_gradient(self, module, grad_input, grad_output): + """Save gradients during backward pass""" + self.gradients = grad_output[0].detach() + + def generate( + self, + image_tensor: torch.Tensor, + target_class: Optional[int] = None, + derm_tensor: Optional[torch.Tensor] = None, + metadata: Optional[torch.Tensor] = None + ) -> np.ndarray: + """ + Generate Grad-CAM heatmap. + + Args: + image_tensor: Input image tensor [1, 3, H, W] + target_class: Class index to visualize (default: predicted class) + derm_tensor: Optional dermoscopy image tensor + metadata: Optional metadata tensor + + Returns: + CAM heatmap as numpy array [H, W] normalized to 0-1 + """ + self.model.eval() + + # Forward pass + output = self.model(image_tensor, derm_tensor, metadata) + + if target_class is None: + target_class = output.argmax(dim=1).item() + + # Backward pass for target class + self.model.zero_grad() + output[0, target_class].backward() + + # Get gradients and activations + gradients = self.gradients[0] # [C, H, W] + activations = self.activations[0] # [C, H, W] + + # Global average pooling of gradients + weights = gradients.mean(dim=(1, 2)) # [C] + + # Weighted combination of activation maps + cam = torch.zeros(activations.shape[1:], dtype=torch.float32, device=activations.device) + for i, w in enumerate(weights): + cam += w * activations[i] + + # ReLU and normalize + cam = F.relu(cam) + cam = cam.cpu().numpy() + + if cam.max() > 0: + cam = (cam - cam.min()) / (cam.max() - cam.min()) + + return cam + + def overlay( + self, + image: np.ndarray, + cam: np.ndarray, + alpha: float = 0.5, + colormap: int = cv2.COLORMAP_JET + ) -> np.ndarray: + """ + Overlay CAM heatmap on original image. + + Args: + image: Original image [H, W, 3] RGB uint8 + cam: CAM heatmap [H, W] float 0-1 + alpha: Overlay transparency + colormap: OpenCV colormap + + Returns: + Overlaid image [H, W, 3] RGB uint8 + """ + H, W = image.shape[:2] + + # Resize CAM to image size + cam_resized = cv2.resize(cam, (W, H)) + + # Apply colormap + heatmap = cv2.applyColorMap( + np.uint8(255 * cam_resized), + colormap + ) + heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) + + # Overlay + overlay = (alpha * heatmap + (1 - alpha) * image).astype(np.uint8) + + return overlay + + +class GradCAMTool: + """ + High-level Grad-CAM tool for ConvNeXt classifier. + """ + + def __init__(self, classifier=None): + """ + Args: + classifier: ConvNeXtClassifier instance (will create one if None) + """ + self.classifier = classifier + self.gradcam = None + self.loaded = False + + # Preprocessing + self.transform = transforms.Compose([ + transforms.Resize((384, 384)), + transforms.ToTensor(), + transforms.Normalize( + mean=[0.485, 0.456, 0.406], + std=[0.229, 0.224, 0.225] + ) + ]) + + def load(self): + """Load classifier and setup Grad-CAM""" + if self.loaded: + return + + if self.classifier is None: + from models.convnext_classifier import ConvNeXtClassifier + self.classifier = ConvNeXtClassifier() + self.classifier.load() + + self.gradcam = GradCAM(self.classifier.model) + self.loaded = True + + def generate_heatmap( + self, + image: Image.Image, + target_class: Optional[int] = None + ) -> Tuple[np.ndarray, np.ndarray, int, float]: + """ + Generate Grad-CAM heatmap for an image. + + Args: + image: PIL Image + target_class: Class to visualize (default: predicted) + + Returns: + Tuple of (overlay_image, cam_heatmap, predicted_class, confidence) + """ + if not self.loaded: + self.load() + + # Ensure RGB + if image.mode != "RGB": + image = image.convert("RGB") + + # Preprocess + image_np = np.array(image.resize((384, 384))) + image_tensor = self.transform(image).unsqueeze(0).to(self.classifier.device) + + # Get prediction first + with torch.no_grad(): + logits = self.classifier.model(image_tensor) + probs = torch.softmax(logits, dim=1)[0] + pred_class = probs.argmax().item() + confidence = probs[pred_class].item() + + # Use predicted class if not specified + if target_class is None: + target_class = pred_class + + # Generate CAM + cam = self.gradcam.generate(image_tensor, target_class) + + # Create overlay + overlay = self.gradcam.overlay(image_np, cam, alpha=0.5) + + return overlay, cam, pred_class, confidence + + def analyze( + self, + image: Image.Image, + target_class: Optional[int] = None + ) -> dict: + """ + Full analysis with Grad-CAM visualization. + + Args: + image: PIL Image + target_class: Class to visualize + + Returns: + Dict with overlay_image, cam, prediction info + """ + from models.convnext_classifier import CLASS_NAMES, CLASS_FULL_NAMES + + overlay, cam, pred_class, confidence = self.generate_heatmap(image, target_class) + + return { + "overlay": Image.fromarray(overlay), + "cam": cam, + "predicted_class": CLASS_NAMES[pred_class], + "predicted_class_full": CLASS_FULL_NAMES[CLASS_NAMES[pred_class]], + "confidence": confidence, + "class_index": pred_class, + } + + def __call__(self, image: Image.Image, target_class: Optional[int] = None) -> dict: + return self.analyze(image, target_class) + + +# Singleton +_gradcam_instance = None + + +def get_gradcam_tool() -> GradCAMTool: + """Get or create Grad-CAM tool instance""" + global _gradcam_instance + if _gradcam_instance is None: + _gradcam_instance = GradCAMTool() + return _gradcam_instance + + +if __name__ == "__main__": + import sys + + print("Grad-CAM Tool Test") + print("=" * 50) + + tool = GradCAMTool() + print("Loading model...") + tool.load() + print("Model loaded!") + + if len(sys.argv) > 1: + image_path = sys.argv[1] + print(f"\nAnalyzing: {image_path}") + + image = Image.open(image_path).convert("RGB") + result = tool.analyze(image) + + print(f"\nPrediction: {result['predicted_class']} ({result['confidence']:.1%})") + print(f"Full name: {result['predicted_class_full']}") + + # Save overlay + output_path = image_path.rsplit(".", 1)[0] + "_gradcam.png" + result["overlay"].save(output_path) + print(f"\nGrad-CAM overlay saved to: {output_path}") diff --git a/models/guidelines_rag.py b/models/guidelines_rag.py new file mode 100644 index 0000000000000000000000000000000000000000..59600a6811d130ba02781899d49d5435aa6f9362 --- /dev/null +++ b/models/guidelines_rag.py @@ -0,0 +1,349 @@ +""" +Guidelines RAG System - Retrieval-Augmented Generation for clinical guidelines +Uses FAISS for vector similarity search on chunked guideline PDFs. +""" + +import os +import json +import re +from pathlib import Path +from typing import List, Dict, Optional, Tuple + +import numpy as np + +# Paths +GUIDELINES_DIR = Path(__file__).parent.parent / "guidelines" +INDEX_DIR = GUIDELINES_DIR / "index" +FAISS_INDEX_PATH = INDEX_DIR / "faiss.index" +CHUNKS_PATH = INDEX_DIR / "chunks.json" + +# Chunking parameters +CHUNK_SIZE = 500 # tokens (approximate) +CHUNK_OVERLAP = 50 # tokens overlap between chunks + + +class GuidelinesRAG: + """ + RAG system for clinical guidelines. + Extracts text from PDFs, chunks it, creates embeddings, and provides search. + """ + + def __init__(self): + self.index = None + self.chunks = [] + self.embedder = None + self.loaded = False + + def _load_embedder(self): + """Load sentence transformer model for embeddings""" + if self.embedder is None: + from sentence_transformers import SentenceTransformer + self.embedder = SentenceTransformer('all-MiniLM-L6-v2') + + def _extract_pdf_text(self, pdf_path: Path) -> str: + """Extract text from a PDF file""" + try: + import pdfplumber + text_parts = [] + with pdfplumber.open(pdf_path) as pdf: + for page in pdf.pages: + page_text = page.extract_text() + if page_text: + text_parts.append(page_text) + return "\n\n".join(text_parts) + except ImportError: + # Fallback to PyPDF2 + from PyPDF2 import PdfReader + reader = PdfReader(pdf_path) + text_parts = [] + for page in reader.pages: + text = page.extract_text() + if text: + text_parts.append(text) + return "\n\n".join(text_parts) + + def _clean_text(self, text: str) -> str: + """Clean extracted text""" + # Remove excessive whitespace + text = re.sub(r'\s+', ' ', text) + # Remove page numbers and headers + text = re.sub(r'\n\d+\s*\n', '\n', text) + # Fix broken words from line breaks + text = re.sub(r'(\w)-\s+(\w)', r'\1\2', text) + return text.strip() + + def _extract_pdf_with_pages(self, pdf_path: Path) -> List[Tuple[str, int]]: + """Extract text from PDF with page numbers""" + try: + import pdfplumber + pages = [] + with pdfplumber.open(pdf_path) as pdf: + for i, page in enumerate(pdf.pages, 1): + page_text = page.extract_text() + if page_text: + pages.append((page_text, i)) + return pages + except ImportError: + from PyPDF2 import PdfReader + reader = PdfReader(pdf_path) + pages = [] + for i, page in enumerate(reader.pages, 1): + text = page.extract_text() + if text: + pages.append((text, i)) + return pages + + def _chunk_text(self, text: str, source: str, page_num: int = 0) -> List[Dict]: + """ + Chunk text into overlapping segments. + Returns list of dicts with 'text', 'source', 'chunk_id', 'page'. + """ + # Approximate tokens by words (rough estimate: 1 token ≈ 0.75 words) + words = text.split() + chunk_words = int(CHUNK_SIZE * 0.75) + overlap_words = int(CHUNK_OVERLAP * 0.75) + + chunks = [] + start = 0 + chunk_id = 0 + + while start < len(words): + end = start + chunk_words + chunk_text = ' '.join(words[start:end]) + + # Try to end at sentence boundary + if end < len(words): + last_period = chunk_text.rfind('.') + if last_period > len(chunk_text) * 0.7: + chunk_text = chunk_text[:last_period + 1] + + chunks.append({ + 'text': chunk_text, + 'source': source, + 'chunk_id': chunk_id, + 'page': page_num + }) + + start = end - overlap_words + chunk_id += 1 + + return chunks + + def build_index(self, force_rebuild: bool = False) -> bool: + """ + Build FAISS index from guideline PDFs. + Returns True if index was built, False if loaded from cache. + """ + # Check if index already exists + if not force_rebuild and FAISS_INDEX_PATH.exists() and CHUNKS_PATH.exists(): + return self.load_index() + + print("Building guidelines index...") + self._load_embedder() + + # Create index directory + INDEX_DIR.mkdir(parents=True, exist_ok=True) + + # Extract and chunk all PDFs with page tracking + all_chunks = [] + pdf_files = list(GUIDELINES_DIR.glob("*.pdf")) + + for pdf_path in pdf_files: + print(f" Processing: {pdf_path.name}") + pages = self._extract_pdf_with_pages(pdf_path) + pdf_chunks = 0 + for page_text, page_num in pages: + cleaned = self._clean_text(page_text) + chunks = self._chunk_text(cleaned, pdf_path.name, page_num) + all_chunks.extend(chunks) + pdf_chunks += len(chunks) + print(f" -> {pdf_chunks} chunks from {len(pages)} pages") + + if not all_chunks: + print("No chunks extracted from PDFs!") + return False + + self.chunks = all_chunks + print(f"Total chunks: {len(self.chunks)}") + + # Generate embeddings + print("Generating embeddings...") + texts = [c['text'] for c in self.chunks] + embeddings = self.embedder.encode(texts, show_progress_bar=True) + embeddings = np.array(embeddings).astype('float32') + + # Build FAISS index + import faiss + dimension = embeddings.shape[1] + self.index = faiss.IndexFlatIP(dimension) # Inner product (cosine with normalized vectors) + + # Normalize embeddings for cosine similarity + faiss.normalize_L2(embeddings) + self.index.add(embeddings) + + # Save index and chunks + faiss.write_index(self.index, str(FAISS_INDEX_PATH)) + with open(CHUNKS_PATH, 'w') as f: + json.dump(self.chunks, f) + + print(f"Index saved to {INDEX_DIR}") + self.loaded = True + return True + + def load_index(self) -> bool: + """Load persisted FAISS index and chunks""" + if not FAISS_INDEX_PATH.exists() or not CHUNKS_PATH.exists(): + return False + + import faiss + self.index = faiss.read_index(str(FAISS_INDEX_PATH)) + + with open(CHUNKS_PATH, 'r') as f: + self.chunks = json.load(f) + + self._load_embedder() + self.loaded = True + return True + + def search(self, query: str, k: int = 5) -> List[Dict]: + """ + Search for relevant guideline chunks. + Returns list of chunks with similarity scores. + """ + if not self.loaded: + if not self.load_index(): + self.build_index() + + import faiss + + # Encode query + query_embedding = self.embedder.encode([query]) + query_embedding = np.array(query_embedding).astype('float32') + faiss.normalize_L2(query_embedding) + + # Search + scores, indices = self.index.search(query_embedding, k) + + results = [] + for score, idx in zip(scores[0], indices[0]): + if idx < len(self.chunks): + chunk = self.chunks[idx].copy() + chunk['score'] = float(score) + results.append(chunk) + + return results + + def get_management_context(self, diagnosis: str, features: Optional[str] = None) -> Tuple[str, List[Dict]]: + """ + Get formatted context from guidelines for management recommendations. + Returns tuple of (context_string, references_list). + References can be used for citation hyperlinks. + """ + # Build search query + query = f"{diagnosis} management treatment recommendations" + if features: + query += f" {features}" + + chunks = self.search(query, k=5) + + if not chunks: + return "No relevant guidelines found.", [] + + # Build context and collect references + context_parts = [] + references = [] + + # Unicode superscript digits + superscripts = ['¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'] + + for i, chunk in enumerate(chunks, 1): + source = chunk['source'].replace('.pdf', '') + page = chunk.get('page', 0) + ref_id = f"ref{i}" + superscript = superscripts[i-1] if i <= len(superscripts) else f"[{i}]" + + # Add reference marker with superscript + context_parts.append(f"[Source {superscript}] {chunk['text']}") + + # Collect reference info + references.append({ + 'id': ref_id, + 'source': source, + 'page': page, + 'file': chunk['source'], + 'score': chunk.get('score', 0) + }) + + context = "\n\n".join(context_parts) + return context, references + + def format_references_for_prompt(self, references: List[Dict]) -> str: + """Format references for inclusion in LLM prompt""" + if not references: + return "" + + lines = ["\n**References:**"] + for ref in references: + lines.append(f"[{ref['id']}] {ref['source']}, p.{ref['page']}") + return "\n".join(lines) + + def format_references_for_display(self, references: List[Dict]) -> str: + """ + Format references with markers that frontend can parse into hyperlinks. + Uses format: [REF:id:source:page:file:superscript] + """ + if not references: + return "" + + # Unicode superscript digits + superscripts = ['¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'] + + lines = ["\n[REFERENCES]"] + for i, ref in enumerate(references, 1): + superscript = superscripts[i-1] if i <= len(superscripts) else f"[{i}]" + # Format: [REF:ref1:Melanoma Guidelines:5:melanoma.pdf:¹] + lines.append(f"[REF:{ref['id']}:{ref['source']}:{ref['page']}:{ref['file']}:{superscript}]") + lines.append("[/REFERENCES]") + return "\n".join(lines) + + +# Singleton instance +_rag_instance = None + + +def get_guidelines_rag() -> GuidelinesRAG: + """Get or create RAG instance""" + global _rag_instance + if _rag_instance is None: + _rag_instance = GuidelinesRAG() + return _rag_instance + + +if __name__ == "__main__": + print("=" * 60) + print(" Guidelines RAG System - Index Builder") + print("=" * 60) + + rag = GuidelinesRAG() + + # Build or rebuild index + import sys + force = "--force" in sys.argv + rag.build_index(force_rebuild=force) + + # Test search + print("\n" + "=" * 60) + print(" Testing Search") + print("=" * 60) + + test_queries = [ + "melanoma management", + "actinic keratosis treatment", + "surgical excision margins" + ] + + for query in test_queries: + print(f"\nQuery: '{query}'") + results = rag.search(query, k=2) + for r in results: + print(f" [{r['score']:.3f}] {r['source']}: {r['text'][:100]}...") diff --git a/models/medgemma_agent.py b/models/medgemma_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..71fdd2406878058d04507bf158d923bac23dae91 --- /dev/null +++ b/models/medgemma_agent.py @@ -0,0 +1,927 @@ +""" +MedGemma Agent - LLM agent with tool calling and staged thinking feedback + +Pipeline: MedGemma independent exam → Tools (MONET/ConvNeXt/GradCAM) → MedGemma reconciliation → Management +""" + +import sys +import time +import random +import json +import os +import subprocess +import threading +from typing import Optional, Generator, Dict, Any +from PIL import Image + + +class MCPClient: + """ + Minimal MCP client that communicates with a FastMCP subprocess over stdio. + + Uses raw newline-delimited JSON-RPC 2.0 so the main process (Python 3.9) + does not need the mcp library. The subprocess is launched with python3.11 + which has mcp installed. + """ + + def __init__(self): + self._process = None + self._lock = threading.Lock() + self._id_counter = 0 + + def _next_id(self) -> int: + self._id_counter += 1 + return self._id_counter + + def _send(self, obj: dict): + line = json.dumps(obj) + "\n" + self._process.stdin.write(line) + self._process.stdin.flush() + + def _recv(self) -> dict: + while True: + line = self._process.stdout.readline() + if not line: + raise RuntimeError("MCP server closed connection unexpectedly") + line = line.strip() + if not line: + continue + msg = json.loads(line) + # Skip server-initiated notifications (no "id" key) + if "id" in msg: + return msg + + def _initialize(self): + """Send MCP initialize handshake.""" + req_id = self._next_id() + self._send({ + "jsonrpc": "2.0", + "id": req_id, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "SkinProAI", "version": "1.0.0"}, + }, + }) + self._recv() # consume initialize response + # Confirm initialization + self._send({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }) + + def start(self): + """Spawn the MCP server subprocess and complete the handshake.""" + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + server_script = os.path.join(root, "mcp_server", "server.py") + self._process = subprocess.Popen( + [sys.executable, server_script], # use same venv Python (has all ML packages) + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, # line-buffered + ) + self._initialize() + + def call_tool_sync(self, tool_name: str, arguments: dict) -> dict: + """Call a tool synchronously and return the parsed result dict.""" + with self._lock: + req_id = self._next_id() + self._send({ + "jsonrpc": "2.0", + "id": req_id, + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments}, + }) + response = self._recv() + + # Protocol-level error (e.g. unknown method) + if "error" in response: + raise RuntimeError( + f"MCP tool '{tool_name}' failed: {response['error']}" + ) + + result = response["result"] + content_text = result["content"][0]["text"] + + # Tool-level error (isError=True means the tool itself raised an exception) + if result.get("isError"): + raise RuntimeError(f"MCP tool '{tool_name}' error: {content_text}") + + return json.loads(content_text) + + def stop(self): + """Terminate the MCP server subprocess.""" + if self._process: + try: + self._process.stdin.close() + self._process.terminate() + self._process.wait(timeout=5) + except Exception: + pass + self._process = None + + +# Rotating verbs for spinner effect +ANALYSIS_VERBS = [ + "Analyzing", "Examining", "Processing", "Inspecting", "Evaluating", + "Scanning", "Assessing", "Reviewing", "Studying", "Interpreting" +] + +# Comprehensive visual exam prompt (combined from 4 separate stages) +COMPREHENSIVE_EXAM_PROMPT = """Perform a systematic dermoscopic examination of this skin lesion. Assess ALL of the following in a SINGLE concise analysis: + +1. PATTERN: Overall architecture, symmetry (symmetric/asymmetric), organization +2. COLORS: List all colors present (brown, black, blue, white, red, pink) and distribution +3. BORDER: Sharp vs gradual, regular vs irregular, any disruptions +4. STRUCTURES: Pigment network, dots/globules, streaks, blue-white veil, regression, vessels + +Then provide: +- Top 3 differential diagnoses with brief reasoning +- Concern level (1-5, where 5=urgent) +- Single most important feature driving your assessment + +Be CONCISE - focus on clinically relevant findings only.""" + + +def get_verb(): + """Get a random analysis verb for spinner effect""" + return random.choice(ANALYSIS_VERBS) + + +class MedGemmaAgent: + """ + Medical image analysis agent with: + - Staged thinking display (no emojis) + - Tool calling (MONET, ConvNeXt, Grad-CAM) + - Streaming responses + """ + + def __init__(self, verbose: bool = True): + self.verbose = verbose + self.pipe = None + self.model_id = "google/medgemma-4b-it" + self.loaded = False + + # Tools (legacy direct instances, kept for fallback / non-MCP use) + self.monet_tool = None + self.convnext_tool = None + self.gradcam_tool = None + self.rag_tool = None + self.tools_loaded = False + + # MCP client + self.mcp_client = None + + # State for confirmation flow + self.last_diagnosis = None + self.last_monet_result = None + self.last_image = None + self.last_medgemma_exam = None # Store independent MedGemma findings + self.last_reconciliation = None + + def reset_state(self): + """Reset analysis state for new analysis (keeps models loaded)""" + self.last_diagnosis = None + self.last_monet_result = None + self.last_image = None + self.last_medgemma_exam = None + self.last_reconciliation = None + + def _print(self, message: str): + """Print if verbose""" + if self.verbose: + print(message, flush=True) + + def load_model(self): + """Load MedGemma model""" + if self.loaded: + return + + self._print("Initializing MedGemma agent...") + + import torch + from transformers import pipeline + + self._print(f"Loading model: {self.model_id}") + + if torch.cuda.is_available(): + device = "cuda" + self._print(f"Using GPU: {torch.cuda.get_device_name(0)}") + elif torch.backends.mps.is_available(): + device = "mps" + self._print("Using Apple Silicon (MPS)") + else: + device = "cpu" + self._print("Using CPU") + + model_kwargs = dict( + torch_dtype=torch.bfloat16 if device != "cpu" else torch.float32, + device_map="auto", + ) + + start = time.time() + self.pipe = pipeline( + "image-text-to-text", + model=self.model_id, + model_kwargs=model_kwargs + ) + + self._print(f"Model loaded in {time.time() - start:.1f}s") + self.loaded = True + + def load_tools(self): + """Load tool models (MONET + ConvNeXt + Grad-CAM + RAG)""" + if self.tools_loaded: + return + + from models.monet_tool import MonetTool + self.monet_tool = MonetTool() + self.monet_tool.load() + + from models.convnext_classifier import ConvNeXtClassifier + self.convnext_tool = ConvNeXtClassifier() + self.convnext_tool.load() + + from models.gradcam_tool import GradCAMTool + self.gradcam_tool = GradCAMTool(classifier=self.convnext_tool) + self.gradcam_tool.load() + + from models.guidelines_rag import get_guidelines_rag + self.rag_tool = get_guidelines_rag() + if not self.rag_tool.loaded: + self.rag_tool.load_index() + + self.tools_loaded = True + + def load_tools_via_mcp(self): + """Start the MCP server subprocess and mark tools as loaded.""" + if self.tools_loaded: + return + self.mcp_client = MCPClient() + self.mcp_client.start() + self.tools_loaded = True + + def _multi_pass_visual_exam(self, image, question: Optional[str] = None) -> Generator[str, None, Dict[str, str]]: + """ + MedGemma performs comprehensive visual examination BEFORE tools run. + Single prompt covers pattern, colors, borders, structures, and differentials. + Returns findings dict after yielding all output. + """ + findings = {} + + yield f"\n[STAGE:medgemma_exam]MedGemma Visual Examination[/STAGE]\n" + yield f"[THINKING]Performing systematic dermoscopic assessment...[/THINKING]\n" + + # Build prompt with optional clinical question + exam_prompt = COMPREHENSIVE_EXAM_PROMPT + if question: + exam_prompt += f"\n\nCLINICAL QUESTION: {question}" + + messages = [ + { + "role": "user", + "content": [ + {"type": "image", "image": image}, + {"type": "text", "text": exam_prompt} + ] + } + ] + + try: + time.sleep(0.2) + output = self.pipe(messages, max_new_tokens=400) + result = output[0]["generated_text"][-1]["content"] + findings['synthesis'] = result + + yield f"[RESPONSE]\n" + words = result.split() + for i, word in enumerate(words): + time.sleep(0.015) + yield word + (" " if i < len(words) - 1 else "") + yield f"\n[/RESPONSE]\n" + + except Exception as e: + findings['synthesis'] = f"Analysis failed: {e}" + yield f"[ERROR]Visual examination failed: {e}[/ERROR]\n" + + self.last_medgemma_exam = findings + return findings + + def _reconcile_findings( + self, + image, + medgemma_exam: Dict[str, str], + monet_result: Dict[str, Any], + convnext_result: Dict[str, Any], + question: Optional[str] = None + ) -> Generator[str, None, None]: + """ + MedGemma reconciles its independent findings with tool outputs. + Identifies agreements, disagreements, and provides integrated assessment. + """ + yield f"\n[STAGE:reconciliation]Reconciling MedGemma Findings with Tool Results[/STAGE]\n" + yield f"[THINKING]Comparing independent visual assessment against AI classification tools...[/THINKING]\n" + + top = convnext_result['predictions'][0] + runner_up = convnext_result['predictions'][1] if len(convnext_result['predictions']) > 1 else None + + # Build MONET features string + monet_top = sorted(monet_result["features"].items(), key=lambda x: x[1], reverse=True)[:5] + monet_str = ", ".join([f"{k.replace('MONET_', '').replace('_', ' ')}: {v:.0%}" for k, v in monet_top]) + + reconciliation_prompt = f"""You performed an independent visual examination of this lesion and concluded: + +YOUR ASSESSMENT: +{medgemma_exam.get('synthesis', 'Not available')[:600]} + +The AI classification tools produced these results: +- ConvNeXt classifier: {top['full_name']} ({top['probability']:.1%} confidence) +{f"- Runner-up: {runner_up['full_name']} ({runner_up['probability']:.1%})" if runner_up else ""} +- Key MONET features: {monet_str} + +{f'CLINICAL QUESTION: {question}' if question else ''} + +Reconcile your visual findings with the AI classification: +1. AGREEMENT/DISAGREEMENT: Do your findings support the AI diagnosis? Any conflicts? +2. INTEGRATED ASSESSMENT: Final diagnosis considering all evidence +3. CONFIDENCE (1-10): How certain? What would change your assessment? + +Be concise and specific.""" + + messages = [ + { + "role": "user", + "content": [ + {"type": "image", "image": image}, + {"type": "text", "text": reconciliation_prompt} + ] + } + ] + + try: + output = self.pipe(messages, max_new_tokens=300) + reconciliation = output[0]["generated_text"][-1]["content"] + self.last_reconciliation = reconciliation + + yield f"[RESPONSE]\n" + words = reconciliation.split() + for i, word in enumerate(words): + time.sleep(0.015) + yield word + (" " if i < len(words) - 1 else "") + yield f"\n[/RESPONSE]\n" + + except Exception as e: + yield f"[ERROR]Reconciliation failed: {e}[/ERROR]\n" + + def analyze_image_stream( + self, + image_path: str, + question: Optional[str] = None, + max_tokens: int = 512, + use_tools: bool = True + ) -> Generator[str, None, None]: + """ + Stream analysis with new pipeline: + 1. MedGemma independent multi-pass exam + 2. MONET + ConvNeXt + GradCAM tools + 3. MedGemma reconciliation + 4. Confirmation request + """ + if not self.loaded: + yield "[STAGE:loading]Initializing MedGemma...[/STAGE]\n" + self.load_model() + + yield f"[STAGE:image]{get_verb()} image...[/STAGE]\n" + + try: + image = Image.open(image_path).convert("RGB") + self.last_image = image + except Exception as e: + yield f"[ERROR]Failed to load image: {e}[/ERROR]\n" + return + + # Load tools early via MCP subprocess + if use_tools and not self.tools_loaded: + yield f"[STAGE:tools]Loading analysis tools...[/STAGE]\n" + self.load_tools_via_mcp() + + # ===== PHASE 1: MedGemma Independent Visual Examination ===== + medgemma_exam = {} + for chunk in self._multi_pass_visual_exam(image, question): + yield chunk + if isinstance(chunk, dict): + medgemma_exam = chunk + medgemma_exam = self.last_medgemma_exam or {} + + monet_result = None + convnext_result = None + + if use_tools: + # ===== PHASE 2: Run Classification Tools ===== + yield f"\n[STAGE:tools_run]Running AI Classification Tools[/STAGE]\n" + yield f"[THINKING]Now running MONET and ConvNeXt to compare against visual examination...[/THINKING]\n" + + # MONET Feature Extraction + time.sleep(0.2) + yield f"\n[STAGE:monet]MONET Feature Extraction[/STAGE]\n" + + try: + monet_result = self.mcp_client.call_tool_sync( + "monet_analyze", {"image_path": image_path} + ) + self.last_monet_result = monet_result + + yield f"[TOOL_OUTPUT:MONET Features]\n" + for name, score in monet_result["features"].items(): + short_name = name.replace("MONET_", "").replace("_", " ").title() + bar_filled = int(score * 10) + bar = "|" + "=" * bar_filled + "-" * (10 - bar_filled) + "|" + yield f" {short_name}: {bar} {score:.0%}\n" + yield f"[/TOOL_OUTPUT]\n" + + except Exception as e: + yield f"[ERROR]MONET failed: {e}[/ERROR]\n" + + # ConvNeXt Classification + time.sleep(0.2) + yield f"\n[STAGE:convnext]ConvNeXt Classification[/STAGE]\n" + + try: + monet_scores = monet_result["vector"] if monet_result else None + convnext_result = self.mcp_client.call_tool_sync( + "classify_lesion", + { + "image_path": image_path, + "monet_scores": monet_scores, + }, + ) + self.last_diagnosis = convnext_result + + yield f"[TOOL_OUTPUT:Classification Results]\n" + for pred in convnext_result["predictions"][:5]: + prob = pred['probability'] + bar_filled = int(prob * 20) + bar = "|" + "=" * bar_filled + "-" * (20 - bar_filled) + "|" + yield f" {pred['class']}: {bar} {prob:.1%}\n" + yield f" {pred['full_name']}\n" + yield f"[/TOOL_OUTPUT]\n" + + top = convnext_result['predictions'][0] + yield f"[RESULT]ConvNeXt Primary: {top['full_name']} ({top['probability']:.1%})[/RESULT]\n" + + except Exception as e: + yield f"[ERROR]ConvNeXt failed: {e}[/ERROR]\n" + + # Grad-CAM Visualization + time.sleep(0.2) + yield f"\n[STAGE:gradcam]Grad-CAM Attention Map[/STAGE]\n" + + try: + gradcam_result = self.mcp_client.call_tool_sync( + "generate_gradcam", {"image_path": image_path} + ) + gradcam_path = gradcam_result["gradcam_path"] + yield f"[GRADCAM_IMAGE:{gradcam_path}]\n" + except Exception as e: + yield f"[ERROR]Grad-CAM failed: {e}[/ERROR]\n" + + # ===== PHASE 3: MedGemma Reconciliation ===== + if convnext_result and monet_result and medgemma_exam: + for chunk in self._reconcile_findings( + image, medgemma_exam, monet_result, convnext_result, question + ): + yield chunk + + # Yield confirmation request + if convnext_result: + top = convnext_result['predictions'][0] + yield f"\n[CONFIRM:diagnosis]Do you agree with the integrated assessment?[/CONFIRM]\n" + + def generate_management_guidance( + self, + user_confirmed: bool = True, + user_feedback: Optional[str] = None + ) -> Generator[str, None, None]: + """ + Generate LESION-SPECIFIC management guidance using RAG + MedGemma reasoning. + References specific findings from this analysis, not generic textbook management. + """ + if not self.last_diagnosis: + yield "[ERROR]No diagnosis available. Please analyze an image first.[/ERROR]\n" + return + + top = self.last_diagnosis['predictions'][0] + runner_up = self.last_diagnosis['predictions'][1] if len(self.last_diagnosis['predictions']) > 1 else None + diagnosis = top['full_name'] + + if not user_confirmed and user_feedback: + yield f"[THINKING]Clinician provided alternative assessment: {user_feedback}[/THINKING]\n" + diagnosis = user_feedback + + # Stage: RAG Search + time.sleep(0.3) + yield f"\n[STAGE:guidelines]Searching clinical guidelines for {diagnosis}...[/STAGE]\n" + + # Get RAG context via MCP + features_desc = self.last_monet_result.get('description', '') if self.last_monet_result else '' + rag_data = self.mcp_client.call_tool_sync( + "search_guidelines", + {"query": features_desc, "diagnosis": diagnosis}, + ) + context = rag_data["context"] + references = rag_data["references"] + + # Check guideline relevance + has_relevant_guidelines = False + if references: + diagnosis_lower = diagnosis.lower() + for ref in references: + source_lower = ref['source'].lower() + if any(term in diagnosis_lower for term in ['melanoma']) and 'melanoma' in source_lower: + has_relevant_guidelines = True + break + elif 'actinic' in diagnosis_lower and 'actinic' in source_lower: + has_relevant_guidelines = True + break + elif ref.get('score', 0) > 0.7: + has_relevant_guidelines = True + break + + if not references or not has_relevant_guidelines: + yield f"[THINKING]No specific published guidelines for {diagnosis}. Using clinical knowledge.[/THINKING]\n" + context = "No specific clinical guidelines available." + references = [] + + # Build MONET features for context + monet_features = "" + if self.last_monet_result: + top_features = sorted(self.last_monet_result["features"].items(), key=lambda x: x[1], reverse=True)[:5] + monet_features = ", ".join([f"{k.replace('MONET_', '').replace('_', ' ')}: {v:.0%}" for k, v in top_features]) + + # Stage: Lesion-Specific Management Reasoning + time.sleep(0.3) + yield f"\n[STAGE:management]Generating Lesion-Specific Management Plan[/STAGE]\n" + yield f"[THINKING]Creating management plan tailored to THIS lesion's specific characteristics...[/THINKING]\n" + + management_prompt = f"""Generate a CONCISE management plan for this lesion: + +DIAGNOSIS: {diagnosis} ({top['probability']:.1%}) +{f"Alternative: {runner_up['full_name']} ({runner_up['probability']:.1%})" if runner_up else ""} +KEY FEATURES: {monet_features} + +{f"GUIDELINES: {context[:800]}" if context else ""} + +Provide: +1. RECOMMENDED ACTION: Biopsy, excision, monitoring, or discharge - with specific reasoning +2. URGENCY: Routine vs urgent vs same-day referral +3. KEY CONCERNS: What features drive this recommendation + +Be specific to THIS lesion. 3-5 sentences maximum.""" + + messages = [ + { + "role": "user", + "content": [ + {"type": "image", "image": self.last_image}, + {"type": "text", "text": management_prompt} + ] + } + ] + + # Generate response + start = time.time() + try: + output = self.pipe(messages, max_new_tokens=250) + response = output[0]["generated_text"][-1]["content"] + + yield f"[RESPONSE]\n" + words = response.split() + for i, word in enumerate(words): + time.sleep(0.015) + yield word + (" " if i < len(words) - 1 else "") + yield f"\n[/RESPONSE]\n" + + except Exception as e: + yield f"[ERROR]Management generation failed: {e}[/ERROR]\n" + + # Output references (pre-formatted by MCP server) + if references: + yield rag_data["references_display"] + + yield f"\n[COMPLETE]Lesion-specific management plan generated in {time.time() - start:.1f}s[/COMPLETE]\n" + + # Store response for recommendation extraction + self.last_management_response = response + + def extract_recommendation(self) -> Generator[str, None, Dict[str, Any]]: + """ + Extract structured recommendation from management guidance. + Determines: BIOPSY, EXCISION, FOLLOWUP, or DISCHARGE + For BIOPSY/EXCISION, gets coordinates from MedGemma. + """ + if not self.last_management_response or not self.last_image: + yield "[ERROR]No management guidance available[/ERROR]\n" + return {"action": "UNKNOWN"} + + yield f"\n[STAGE:recommendation]Extracting Clinical Recommendation[/STAGE]\n" + + # Ask MedGemma to classify the recommendation + classification_prompt = f"""Based on the management plan you just provided: + +{self.last_management_response[:1000]} + +Classify the PRIMARY recommended action into exactly ONE of these categories: +- BIOPSY: If punch biopsy, shave biopsy, or incisional biopsy is recommended +- EXCISION: If complete surgical excision is recommended +- FOLLOWUP: If monitoring with repeat photography/dermoscopy is recommended +- DISCHARGE: If the lesion is clearly benign and no follow-up needed + +Respond with ONLY the category name (BIOPSY, EXCISION, FOLLOWUP, or DISCHARGE) on the first line. +Then on the second line, provide a brief (1 sentence) justification.""" + + messages = [ + { + "role": "user", + "content": [ + {"type": "image", "image": self.last_image}, + {"type": "text", "text": classification_prompt} + ] + } + ] + + try: + output = self.pipe(messages, max_new_tokens=100) + response = output[0]["generated_text"][-1]["content"].strip() + lines = response.split('\n') + action = lines[0].strip().upper() + justification = lines[1].strip() if len(lines) > 1 else "" + + # Validate action + valid_actions = ["BIOPSY", "EXCISION", "FOLLOWUP", "DISCHARGE"] + if action not in valid_actions: + # Try to extract from response + for valid in valid_actions: + if valid in response.upper(): + action = valid + break + else: + action = "FOLLOWUP" # Default to safe option + + yield f"[RESULT]Recommended Action: {action}[/RESULT]\n" + yield f"[OBSERVATION]{justification}[/OBSERVATION]\n" + + result = { + "action": action, + "justification": justification + } + + return result + + except Exception as e: + yield f"[ERROR]Failed to extract recommendation: {e}[/ERROR]\n" + return {"action": "UNKNOWN", "error": str(e)} + + def compare_followup_images( + self, + previous_image_path: str, + current_image_path: str + ) -> Generator[str, None, None]: + """ + Compare a follow-up image with the previous one. + Runs full analysis pipeline on current image, then compares findings. + """ + yield f"\n[STAGE:comparison]Follow-up Comparison Analysis[/STAGE]\n" + + try: + current_image = Image.open(current_image_path).convert("RGB") + except Exception as e: + yield f"[ERROR]Failed to load images: {e}[/ERROR]\n" + return + + # Store previous analysis state + prev_exam = self.last_medgemma_exam + + # Generate comparison image and MONET deltas via MCP + yield f"\n[STAGE:current_analysis]Analyzing Current Image[/STAGE]\n" + + if self.tools_loaded: + try: + compare_data = self.mcp_client.call_tool_sync( + "compare_images", + { + "image1_path": previous_image_path, + "image2_path": current_image_path, + }, + ) + yield f"[COMPARISON_IMAGE:{compare_data['comparison_path']}]\n" + + # Side-by-side GradCAM comparison if both paths available + prev_gc = compare_data.get("prev_gradcam_path") + curr_gc = compare_data.get("curr_gradcam_path") + if prev_gc and curr_gc: + yield f"[GRADCAM_COMPARE:{prev_gc}:{curr_gc}]\n" + + # Display MONET feature deltas + if compare_data["monet_deltas"]: + yield f"[TOOL_OUTPUT:Feature Comparison]\n" + for name, delta_info in compare_data["monet_deltas"].items(): + prev_val = delta_info["previous"] + curr_val = delta_info["current"] + diff = delta_info["delta"] + short_name = name.replace("MONET_", "").replace("_", " ").title() + direction = "↑" if diff > 0 else "↓" + yield f" {short_name}: {prev_val:.0%} → {curr_val:.0%} ({direction}{abs(diff):.0%})\n" + yield f"[/TOOL_OUTPUT]\n" + + except Exception as e: + yield f"[ERROR]MCP comparison failed: {e}[/ERROR]\n" + + # MedGemma comparison analysis + comparison_prompt = f"""You are comparing TWO images of the same skin lesion taken at different times. + +PREVIOUS ANALYSIS: +{prev_exam.get('synthesis', 'Not available')[:500] if prev_exam else 'Not available'} + +Now examine the CURRENT image and compare to your memory of the previous findings. + +Assess for changes in: +1. SIZE: Has the lesion grown, shrunk, or stayed the same? +2. COLOR: Any new colors appeared? Any colors faded? +3. SHAPE/SYMMETRY: Has the shape changed? More or less symmetric? +4. BORDERS: Sharper, more irregular, or unchanged? +5. STRUCTURES: New dermoscopic structures? Lost structures? + +Provide your assessment: +- CHANGE_LEVEL: SIGNIFICANT_CHANGE / MINOR_CHANGE / STABLE / IMPROVED +- Specific changes observed +- Clinical recommendation based on changes""" + + messages = [ + { + "role": "user", + "content": [ + {"type": "image", "image": current_image}, + {"type": "text", "text": comparison_prompt} + ] + } + ] + + try: + yield f"[THINKING]Comparing current image to previous findings...[/THINKING]\n" + output = self.pipe(messages, max_new_tokens=400) + comparison_result = output[0]["generated_text"][-1]["content"] + + yield f"[RESPONSE]\n" + words = comparison_result.split() + for i, word in enumerate(words): + time.sleep(0.02) + yield word + (" " if i < len(words) - 1 else "") + yield f"\n[/RESPONSE]\n" + + # Extract change level + change_level = "UNKNOWN" + for level in ["SIGNIFICANT_CHANGE", "MINOR_CHANGE", "STABLE", "IMPROVED"]: + if level in comparison_result.upper(): + change_level = level + break + + if change_level == "SIGNIFICANT_CHANGE": + yield f"[RESULT]⚠️ SIGNIFICANT CHANGES DETECTED - Further evaluation recommended[/RESULT]\n" + elif change_level == "IMPROVED": + yield f"[RESULT]✓ LESION IMPROVED - Continue monitoring[/RESULT]\n" + elif change_level == "STABLE": + yield f"[RESULT]✓ LESION STABLE - Continue scheduled follow-up[/RESULT]\n" + else: + yield f"[RESULT]Minor changes noted - Clinical correlation recommended[/RESULT]\n" + + except Exception as e: + yield f"[ERROR]Comparison analysis failed: {e}[/ERROR]\n" + + yield f"\n[COMPLETE]Follow-up comparison complete[/COMPLETE]\n" + + def chat(self, message: str, image_path: Optional[str] = None) -> str: + """Simple chat interface""" + if not self.loaded: + self.load_model() + + content = [] + if image_path: + image = Image.open(image_path).convert("RGB") + content.append({"type": "image", "image": image}) + content.append({"type": "text", "text": message}) + + messages = [{"role": "user", "content": content}] + output = self.pipe(messages, max_new_tokens=512) + return output[0]["generated_text"][-1]["content"] + + def chat_followup(self, message: str) -> Generator[str, None, None]: + """ + Handle follow-up questions using the stored analysis context. + Uses the last analyzed image and diagnosis to provide contextual responses. + """ + if not self.loaded: + yield "[ERROR]Model not loaded[/ERROR]\n" + return + + if not self.last_diagnosis or not self.last_image: + yield "[ERROR]No previous analysis context. Please analyze an image first.[/ERROR]\n" + return + + # Build context from previous analysis + top_diagnosis = self.last_diagnosis['predictions'][0] + differentials = ", ".join([ + f"{p['class']} ({p['probability']:.0%})" + for p in self.last_diagnosis['predictions'][:3] + ]) + + monet_desc = "" + if self.last_monet_result: + monet_desc = self.last_monet_result.get('description', '') + + context_prompt = f"""You are a dermatology assistant helping with skin lesion analysis. + +PREVIOUS ANALYSIS CONTEXT: +- Primary diagnosis: {top_diagnosis['full_name']} ({top_diagnosis['probability']:.1%} confidence) +- Differential diagnoses: {differentials} +- Visual features: {monet_desc} + +The user has a follow-up question about this lesion. Please provide a helpful, medically accurate response. + +USER QUESTION: {message} + +Provide a concise, informative response. If the question is outside your expertise or requires in-person examination, say so.""" + + messages = [ + { + "role": "user", + "content": [ + {"type": "image", "image": self.last_image}, + {"type": "text", "text": context_prompt} + ] + } + ] + + try: + yield f"[THINKING]Considering your question in context of the previous analysis...[/THINKING]\n" + time.sleep(0.2) + + output = self.pipe(messages, max_new_tokens=400) + response = output[0]["generated_text"][-1]["content"] + + yield f"[RESPONSE]\n" + # Stream word by word for typewriter effect + words = response.split() + for i, word in enumerate(words): + time.sleep(0.02) + yield word + (" " if i < len(words) - 1 else "") + yield f"\n[/RESPONSE]\n" + + except Exception as e: + yield f"[ERROR]Failed to generate response: {e}[/ERROR]\n" + + +def main(): + """Interactive terminal interface""" + print("=" * 60) + print(" MedGemma Agent - Medical Image Analysis") + print("=" * 60) + + agent = MedGemmaAgent(verbose=True) + agent.load_model() + + print("\nCommands: analyze , chat , quit") + + while True: + try: + user_input = input("\n> ").strip() + if not user_input: + continue + + if user_input.lower() in ["quit", "exit", "q"]: + break + + parts = user_input.split(maxsplit=1) + cmd = parts[0].lower() + + if cmd == "analyze" and len(parts) > 1: + for chunk in agent.analyze_image_stream(parts[1].strip()): + print(chunk, end="", flush=True) + + elif cmd == "chat" and len(parts) > 1: + print(agent.chat(parts[1])) + + else: + print("Unknown command") + + except KeyboardInterrupt: + break + except Exception as e: + print(f"Error: {e}") + + +if __name__ == "__main__": + main() diff --git a/models/medsiglip_convnext_fusion.py b/models/medsiglip_convnext_fusion.py new file mode 100644 index 0000000000000000000000000000000000000000..ade3138ca80a62ae56a0932aaacac98b428e8f9f --- /dev/null +++ b/models/medsiglip_convnext_fusion.py @@ -0,0 +1,224 @@ +# models/medsiglip_convnext_fusion.py + +import torch +import torch.nn as nn +from typing import Dict, List, Tuple, Optional +import numpy as np +import timm +from transformers import AutoModel, AutoProcessor + +class MedSigLIPConvNeXtFusion(nn.Module): + """ + Your trained MedSigLIP-ConvNeXt fusion model from MILK10 challenge + Supports 11-class skin lesion classification + """ + + # Class names from your training + CLASS_NAMES = [ + 'AKIEC', # Actinic Keratoses and Intraepithelial Carcinoma + 'BCC', # Basal Cell Carcinoma + 'BEN_OTH', # Benign Other + 'BKL', # Benign Keratosis-like Lesions + 'DF', # Dermatofibroma + 'INF', # Inflammatory + 'MAL_OTH', # Malignant Other + 'MEL', # Melanoma + 'NV', # Melanocytic Nevi + 'SCCKA', # Squamous Cell Carcinoma and Keratoacanthoma + 'VASC' # Vascular Lesions + ] + + def __init__( + self, + num_classes: int = 11, + medsiglip_model: str = "google/medsiglip-base", + convnext_variant: str = "convnext_base", + fusion_dim: int = 512, + dropout: float = 0.3, + metadata_dim: int = 20 # For metadata features + ): + super().__init__() + + self.num_classes = num_classes + + # MedSigLIP Vision Encoder + print(f"Loading MedSigLIP: {medsiglip_model}") + self.medsiglip = AutoModel.from_pretrained(medsiglip_model) + self.medsiglip_processor = AutoProcessor.from_pretrained(medsiglip_model) + + # ConvNeXt Backbone + print(f"Loading ConvNeXt: {convnext_variant}") + self.convnext = timm.create_model( + convnext_variant, + pretrained=True, + num_classes=0, + global_pool='avg' + ) + + # Feature dimensions + self.medsiglip_dim = self.medsiglip.config.hidden_size # 768 + self.convnext_dim = self.convnext.num_features # 1024 + + # Optional metadata branch + self.use_metadata = metadata_dim > 0 + if self.use_metadata: + self.metadata_encoder = nn.Sequential( + nn.Linear(metadata_dim, 64), + nn.LayerNorm(64), + nn.GELU(), + nn.Dropout(0.2), + nn.Linear(64, 32) + ) + total_dim = self.medsiglip_dim + self.convnext_dim + 32 + else: + total_dim = self.medsiglip_dim + self.convnext_dim + + # Fusion layers + self.fusion = nn.Sequential( + nn.Linear(total_dim, fusion_dim), + nn.LayerNorm(fusion_dim), + nn.GELU(), + nn.Dropout(dropout), + nn.Linear(fusion_dim, fusion_dim // 2), + nn.LayerNorm(fusion_dim // 2), + nn.GELU(), + nn.Dropout(dropout) + ) + + # Classification head + self.classifier = nn.Linear(fusion_dim // 2, num_classes) + + # Store intermediate features for Grad-CAM + self.convnext_features = None + self.medsiglip_features = None + + # Register hooks + self.convnext.stages[-1].register_forward_hook(self._save_convnext_features) + + def _save_convnext_features(self, module, input, output): + """Hook to save ConvNeXt feature maps for Grad-CAM""" + self.convnext_features = output + + def forward( + self, + image: torch.Tensor, + metadata: Optional[torch.Tensor] = None + ) -> torch.Tensor: + """ + Forward pass + + Args: + image: [B, 3, H, W] tensor + metadata: [B, metadata_dim] optional metadata features + + Returns: + logits: [B, num_classes] + """ + # MedSigLIP features + medsiglip_out = self.medsiglip.vision_model(image) + medsiglip_features = medsiglip_out.pooler_output # [B, 768] + + # ConvNeXt features + convnext_features = self.convnext(image) # [B, 1024] + + # Concatenate vision features + fused = torch.cat([medsiglip_features, convnext_features], dim=1) + + # Add metadata if available + if self.use_metadata and metadata is not None: + metadata_features = self.metadata_encoder(metadata) + fused = torch.cat([fused, metadata_features], dim=1) + + # Fusion layers + fused = self.fusion(fused) + + # Classification + logits = self.classifier(fused) + + return logits + + def predict( + self, + image: torch.Tensor, + metadata: Optional[torch.Tensor] = None, + top_k: int = 5 + ) -> Dict: + """ + Get predictions with probabilities + + Args: + image: [B, 3, H, W] or [3, H, W] + metadata: Optional metadata features + top_k: Number of top predictions + + Returns: + Dictionary with predictions and features + """ + if image.dim() == 3: + image = image.unsqueeze(0) + + self.eval() + with torch.no_grad(): + logits = self.forward(image, metadata) + probs = torch.softmax(logits, dim=1) + + # Top-k predictions + top_probs, top_indices = torch.topk( + probs, + k=min(top_k, self.num_classes), + dim=1 + ) + + # Format results + predictions = [] + for i in range(top_probs.size(1)): + predictions.append({ + 'class': self.CLASS_NAMES[top_indices[0, i].item()], + 'probability': top_probs[0, i].item(), + 'class_idx': top_indices[0, i].item() + }) + + return { + 'predictions': predictions, + 'all_probabilities': probs[0].cpu().numpy(), + 'logits': logits[0].cpu().numpy(), + 'convnext_features': self.convnext_features, + 'medsiglip_features': self.medsiglip_features + } + + @classmethod + def load_from_checkpoint( + cls, + medsiglip_path: str, + convnext_path: Optional[str] = None, + ensemble_weights: tuple = (0.6, 0.4), + device: str = 'cpu' + ): + """ + Load model from your training checkpoints + + Args: + medsiglip_path: Path to MedSigLIP model weights + convnext_path: Path to ConvNeXt model weights (optional) + ensemble_weights: (w_medsiglip, w_convnext) + device: Device to load on + """ + model = cls(num_classes=11) + + # Load MedSigLIP weights + print(f"Loading MedSigLIP from: {medsiglip_path}") + medsiglip_state = torch.load(medsiglip_path, map_location=device) + + # Handle different checkpoint formats + if 'model_state_dict' in medsiglip_state: + model.load_state_dict(medsiglip_state['model_state_dict']) + else: + model.load_state_dict(medsiglip_state) + + # Store ensemble weights for prediction fusion + model.ensemble_weights = ensemble_weights + + model.to(device) + model.eval() + + return model \ No newline at end of file diff --git a/models/monet_concepts.py b/models/monet_concepts.py new file mode 100644 index 0000000000000000000000000000000000000000..d2ee6331529256bf7859ee0989041b87f6cfb844 --- /dev/null +++ b/models/monet_concepts.py @@ -0,0 +1,332 @@ +# models/monet_concepts.py + +import torch +import numpy as np +from typing import Dict, List +from dataclasses import dataclass + +@dataclass +class ConceptScore: + """Single MONET concept with score and evidence""" + name: str + score: float + confidence: float + description: str + clinical_relevance: str # How this affects diagnosis + +class MONETConceptScorer: + """ + MONET concept scoring using your trained metadata patterns + Integrates the boosting logic from your ensemble code + """ + + # MONET concepts used in your training + CONCEPT_DEFINITIONS = { + 'MONET_ulceration_crust': { + 'description': 'Ulceration or crusting present', + 'high_in': ['SCCKA', 'BCC', 'MAL_OTH'], + 'low_in': ['NV', 'BKL'], + 'threshold_high': 0.50 + }, + 'MONET_erythema': { + 'description': 'Redness or inflammation', + 'high_in': ['INF', 'BCC', 'SCCKA'], + 'low_in': ['MEL', 'NV'], + 'threshold_high': 0.40 + }, + 'MONET_pigmented': { + 'description': 'Pigmentation present', + 'high_in': ['MEL', 'NV', 'BKL'], + 'low_in': ['BCC', 'SCCKA', 'INF'], + 'threshold_high': 0.55 + }, + 'MONET_vasculature_vessels': { + 'description': 'Vascular structures visible', + 'high_in': ['VASC', 'BCC'], + 'low_in': ['MEL', 'NV'], + 'threshold_high': 0.35 + }, + 'MONET_hair': { + 'description': 'Hair follicles present', + 'high_in': ['NV', 'BKL'], + 'low_in': ['BCC', 'MEL'], + 'threshold_high': 0.30 + }, + 'MONET_gel_water_drop_fluid_dermoscopy_liquid': { + 'description': 'Gel/fluid artifacts', + 'high_in': [], + 'low_in': [], + 'threshold_high': 0.40 + }, + 'MONET_skin_markings_pen_ink_purple_pen': { + 'description': 'Pen markings present', + 'high_in': [], + 'low_in': [], + 'threshold_high': 0.40 + } + } + + # Class-specific patterns from your metadata boosting + CLASS_PATTERNS = { + 'MAL_OTH': { + 'sex': 'male', # 88.9% male + 'site_preference': 'trunk', + 'age_range': (60, 80), + 'key_concepts': {'MONET_ulceration_crust': 0.35} + }, + 'INF': { + 'key_concepts': { + 'MONET_erythema': 0.42, + 'MONET_pigmented': (None, 0.30) # Low pigmentation + } + }, + 'BEN_OTH': { + 'site_preference': ['head', 'neck', 'face'], # 47.7% + 'key_concepts': {'MONET_pigmented': (0.30, 0.50)} + }, + 'DF': { + 'site_preference': ['lower', 'leg', 'ankle', 'foot'], # 65.4% + 'age_range': (40, 65) + }, + 'SCCKA': { + 'age_range': (65, None), + 'key_concepts': { + 'MONET_ulceration_crust': 0.50, + 'MONET_pigmented': (None, 0.15) + } + }, + 'MEL': { + 'age_range': (55, None), # 61.8 years average + 'key_concepts': {'MONET_pigmented': 0.55} + }, + 'NV': { + 'age_range': (None, 45), # 42.0 years average + 'key_concepts': {'MONET_pigmented': 0.55} + } + } + + def __init__(self): + """Initialize MONET scorer with class patterns""" + self.class_names = [ + 'AKIEC', 'BCC', 'BEN_OTH', 'BKL', 'DF', + 'INF', 'MAL_OTH', 'MEL', 'NV', 'SCCKA', 'VASC' + ] + + def compute_concept_scores( + self, + metadata: Dict[str, float] + ) -> Dict[str, ConceptScore]: + """ + Compute MONET concept scores from metadata + + Args: + metadata: Dictionary with MONET scores, age, sex, site, etc. + + Returns: + Dictionary of concept scores + """ + concept_scores = {} + + for concept_name, definition in self.CONCEPT_DEFINITIONS.items(): + score = metadata.get(concept_name, 0.0) + + # Determine confidence based on how extreme the score is + if score > definition['threshold_high']: + confidence = min((score - definition['threshold_high']) / 0.2, 1.0) + level = "HIGH" + elif score < 0.2: + confidence = min((0.2 - score) / 0.2, 1.0) + level = "LOW" + else: + confidence = 0.5 + level = "MODERATE" + + # Clinical relevance + if level == "HIGH": + relevant_classes = definition['high_in'] + clinical_relevance = f"Supports: {', '.join(relevant_classes)}" + elif level == "LOW": + excluded_classes = definition['low_in'] + clinical_relevance = f"Against: {', '.join(excluded_classes)}" + else: + clinical_relevance = "Non-specific" + + concept_scores[concept_name] = ConceptScore( + name=concept_name.replace('MONET_', '').replace('_', ' ').title(), + score=score, + confidence=confidence, + description=f"{definition['description']} ({level})", + clinical_relevance=clinical_relevance + ) + + return concept_scores + + def apply_metadata_boosting( + self, + probs: np.ndarray, + metadata: Dict + ) -> np.ndarray: + """ + Apply your metadata boosting logic + This is directly from your ensemble optimization code + + Args: + probs: [11] probability array + metadata: Dictionary with age, sex, site, MONET scores + + Returns: + boosted_probs: [11] adjusted probabilities + """ + boosted_probs = probs.copy() + + # 1. MAL_OTH boosting + if metadata.get('sex') == 'male': + site = str(metadata.get('site', '')).lower() + if 'trunk' in site: + age = metadata.get('age_approx', 60) + ulceration = metadata.get('MONET_ulceration_crust', 0) + + score = 0 + score += 3 if metadata.get('sex') == 'male' else 0 + score += 2 if 'trunk' in site else 0 + score += 1 if 60 <= age <= 80 else 0 + score += 2 if ulceration > 0.35 else 0 + + confidence = score / 8.0 + if confidence > 0.5: + boosted_probs[6] *= (1.0 + confidence) # MAL_OTH index + + # 2. INF boosting + erythema = metadata.get('MONET_erythema', 0) + pigmentation = metadata.get('MONET_pigmented', 0) + + if erythema > 0.42 and pigmentation < 0.30: + confidence = min((erythema - 0.42) / 0.10 + 0.5, 1.0) + boosted_probs[5] *= (1.0 + confidence * 0.8) # INF index + + # 3. BEN_OTH boosting + site = str(metadata.get('site', '')).lower() + is_head_neck = any(x in site for x in ['head', 'neck', 'face']) + + if is_head_neck and 0.30 < pigmentation < 0.50: + ulceration = metadata.get('MONET_ulceration_crust', 0) + confidence = 0.7 if ulceration < 0.30 else 0.4 + boosted_probs[2] *= (1.0 + confidence * 0.5) # BEN_OTH index + + # 4. DF boosting + is_lower_ext = any(x in site for x in ['lower', 'leg', 'ankle', 'foot']) + + if is_lower_ext: + age = metadata.get('age_approx', 60) + if 40 <= age <= 65: + boosted_probs[4] *= 1.8 # DF index + elif 30 <= age <= 75: + boosted_probs[4] *= 1.5 + + # 5. SCCKA boosting + ulceration = metadata.get('MONET_ulceration_crust', 0) + age = metadata.get('age_approx', 60) + + if ulceration > 0.50 and age >= 65 and pigmentation < 0.15: + boosted_probs[9] *= 1.9 # SCCKA index + elif ulceration > 0.45 and age >= 60 and pigmentation < 0.20: + boosted_probs[9] *= 1.5 + + # 6. MEL vs NV age separation + if pigmentation > 0.55: + if age >= 55: + age_score = min((age - 55) / 20.0, 1.0) + boosted_probs[7] *= (1.0 + age_score * 0.5) # MEL + boosted_probs[8] *= (1.0 - age_score * 0.3) # NV + elif age <= 45: + age_score = min((45 - age) / 30.0, 1.0) + boosted_probs[7] *= (1.0 - age_score * 0.3) # MEL + boosted_probs[8] *= (1.0 + age_score * 0.5) # NV + + # 7. Exclusions based on pigmentation/erythema + if pigmentation > 0.50: + boosted_probs[0] *= 0.7 # AKIEC + boosted_probs[1] *= 0.6 # BCC + boosted_probs[5] *= 0.5 # INF + boosted_probs[9] *= 0.3 # SCCKA + + if erythema > 0.40: + boosted_probs[7] *= 0.7 # MEL + boosted_probs[8] *= 0.7 # NV + + if pigmentation < 0.20: + boosted_probs[7] *= 0.5 # MEL + boosted_probs[8] *= 0.5 # NV + + # Renormalize + return boosted_probs / boosted_probs.sum() + + def explain_prediction( + self, + probs: np.ndarray, + concept_scores: Dict[str, ConceptScore], + metadata: Dict + ) -> str: + """ + Generate natural language explanation + + Args: + probs: Class probabilities + concept_scores: MONET concept scores + metadata: Clinical metadata + + Returns: + Natural language explanation + """ + predicted_idx = np.argmax(probs) + predicted_class = self.class_names[predicted_idx] + confidence = probs[predicted_idx] + + explanation = f"**Primary Diagnosis: {predicted_class}**\n" + explanation += f"Confidence: {confidence:.1%}\n\n" + + # Key MONET features + explanation += "**Key Dermoscopic Features:**\n" + + sorted_concepts = sorted( + concept_scores.values(), + key=lambda x: x.score * x.confidence, + reverse=True + ) + + for i, concept in enumerate(sorted_concepts[:5], 1): + if concept.score > 0.3 or concept.score < 0.2: + explanation += f"{i}. {concept.name}: {concept.score:.2f} - {concept.description}\n" + if concept.clinical_relevance != "Non-specific": + explanation += f" → {concept.clinical_relevance}\n" + + # Clinical context + explanation += "\n**Clinical Context:**\n" + if 'age_approx' in metadata: + explanation += f"• Age: {metadata['age_approx']} years\n" + if 'sex' in metadata: + explanation += f"• Sex: {metadata['sex']}\n" + if 'site' in metadata: + explanation += f"• Location: {metadata['site']}\n" + + return explanation + + def get_top_concepts( + self, + concept_scores: Dict[str, ConceptScore], + top_k: int = 5, + min_score: float = 0.3 + ) -> List[ConceptScore]: + """Get top-k most important concepts""" + filtered = [ + cs for cs in concept_scores.values() + if cs.score >= min_score or cs.score < 0.2 # High or low + ] + + sorted_concepts = sorted( + filtered, + key=lambda x: x.score * x.confidence, + reverse=True + ) + + return sorted_concepts[:top_k] \ No newline at end of file diff --git a/models/monet_tool.py b/models/monet_tool.py new file mode 100644 index 0000000000000000000000000000000000000000..e03abb7057b6d2d64281000cf9102e7ad74d3281 --- /dev/null +++ b/models/monet_tool.py @@ -0,0 +1,354 @@ +""" +MONET Tool - Skin lesion feature extraction using MONET model +Correct implementation based on MONET tutorial: automatic_concept_annotation.ipynb +""" + +import torch +import torch.nn.functional as F +import numpy as np +import scipy.special +from PIL import Image +from typing import Optional, Dict, List +import torchvision.transforms as T + + +# The 7 MONET feature columns expected by ConvNeXt +MONET_FEATURES = [ + "MONET_ulceration_crust", + "MONET_hair", + "MONET_vasculature_vessels", + "MONET_erythema", + "MONET_pigmented", + "MONET_gel_water_drop_fluid_dermoscopy_liquid", + "MONET_skin_markings_pen_ink_purple_pen", +] + +# Concept terms for each MONET feature (multiple synonyms improve detection) +MONET_CONCEPT_TERMS = { + "MONET_ulceration_crust": ["ulceration", "crust", "crusting", "ulcer"], + "MONET_hair": ["hair", "hairy"], + "MONET_vasculature_vessels": ["blood vessels", "vasculature", "vessels", "telangiectasia"], + "MONET_erythema": ["erythema", "redness", "red"], + "MONET_pigmented": ["pigmented", "pigmentation", "melanin", "brown"], + "MONET_gel_water_drop_fluid_dermoscopy_liquid": ["dermoscopy gel", "fluid", "water drop", "immersion fluid"], + "MONET_skin_markings_pen_ink_purple_pen": ["pen marking", "ink", "surgical marking", "purple pen"], +} + +# Prompt templates (from MONET paper) +PROMPT_TEMPLATES = [ + "This is skin image of {}", + "This is dermatology image of {}", + "This is image of {}", +] + +# Reference prompts (baseline for contrastive scoring) +PROMPT_REFS = [ + ["This is skin image"], + ["This is dermatology image"], + ["This is image"], +] + + +def get_transform(n_px=224): + """Get MONET preprocessing transform""" + def convert_image_to_rgb(image): + return image.convert("RGB") + + return T.Compose([ + T.Resize(n_px, interpolation=T.InterpolationMode.BICUBIC), + T.CenterCrop(n_px), + convert_image_to_rgb, + T.ToTensor(), + T.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)), + ]) + + +class MonetTool: + """ + MONET tool for extracting concept presence scores from skin lesion images. + Uses the proper contrastive scoring method from the MONET paper. + """ + + def __init__(self, device: Optional[str] = None, use_hf: bool = True): + """ + Args: + device: Device to run on (cuda, mps, cpu) + use_hf: Use HuggingFace implementation (True) or original CLIP (False) + """ + self.model = None + self.processor = None + self.device = device + self.use_hf = use_hf + self.loaded = False + self.transform = get_transform(224) + + # Cache for concept embeddings + self._concept_embeddings = {} + + def load(self): + """Load MONET model""" + if self.loaded: + return + + # Determine device + if self.device is None: + if torch.cuda.is_available(): + self.device = "cuda:0" + elif torch.backends.mps.is_available(): + self.device = "mps" + else: + self.device = "cpu" + + if self.use_hf: + # HuggingFace implementation + from transformers import AutoProcessor, AutoModelForZeroShotImageClassification + + self.processor = AutoProcessor.from_pretrained("chanwkim/monet") + self.model = AutoModelForZeroShotImageClassification.from_pretrained("chanwkim/monet") + self.model.to(self.device) + self.model.eval() + else: + # Original CLIP implementation + import clip + + self.model, _ = clip.load("ViT-L/14", device=self.device, jit=False) + self.model.load_state_dict( + torch.hub.load_state_dict_from_url( + "https://aimslab.cs.washington.edu/MONET/weight_clip.pt" + ) + ) + self.model.eval() + + self.loaded = True + + # Pre-compute concept embeddings for all MONET features + self._precompute_concept_embeddings() + + def _encode_text(self, text_list: List[str]) -> torch.Tensor: + """Encode text to embeddings""" + if self.use_hf: + inputs = self.processor(text=text_list, return_tensors="pt", padding=True) + inputs = {k: v.to(self.device) for k, v in inputs.items()} + with torch.no_grad(): + embeddings = self.model.get_text_features(**inputs) + else: + import clip + tokens = clip.tokenize(text_list, truncate=True).to(self.device) + with torch.no_grad(): + embeddings = self.model.encode_text(tokens) + + return embeddings.cpu() + + def _encode_image(self, image: Image.Image) -> torch.Tensor: + """Encode image to embedding""" + image_tensor = self.transform(image).unsqueeze(0).to(self.device) + + if self.use_hf: + with torch.no_grad(): + embedding = self.model.get_image_features(image_tensor) + else: + with torch.no_grad(): + embedding = self.model.encode_image(image_tensor) + + return embedding.cpu() + + def _precompute_concept_embeddings(self): + """Pre-compute embeddings for all MONET concepts""" + for feature_name, concept_terms in MONET_CONCEPT_TERMS.items(): + self._concept_embeddings[feature_name] = self._get_concept_embedding(concept_terms) + + def _get_concept_embedding(self, concept_terms: List[str]) -> Dict: + """ + Generate prompt embeddings for a concept using multiple templates. + + Args: + concept_terms: List of synonymous terms for the concept + + Returns: + dict with target and reference embeddings + """ + # Target prompts: "This is skin image of {term}" + prompt_target = [ + [template.format(term) for term in concept_terms] + for template in PROMPT_TEMPLATES + ] + + # Flatten and encode + prompt_target_flat = [p for template_prompts in prompt_target for p in template_prompts] + target_embeddings = self._encode_text(prompt_target_flat) + + # Reshape to [num_templates, num_terms, embed_dim] + num_templates = len(PROMPT_TEMPLATES) + num_terms = len(concept_terms) + embed_dim = target_embeddings.shape[-1] + target_embeddings = target_embeddings.view(num_templates, num_terms, embed_dim) + + # Normalize + target_embeddings_norm = F.normalize(target_embeddings, dim=2) + + # Reference prompts: "This is skin image" + prompt_ref_flat = [p for ref_list in PROMPT_REFS for p in ref_list] + ref_embeddings = self._encode_text(prompt_ref_flat) + ref_embeddings = ref_embeddings.view(num_templates, -1, embed_dim) + ref_embeddings_norm = F.normalize(ref_embeddings, dim=2) + + return { + "target_embedding_norm": target_embeddings_norm, + "ref_embedding_norm": ref_embeddings_norm, + } + + def _calculate_concept_score( + self, + image_features_norm: torch.Tensor, + concept_embedding: Dict, + temp: float = 1 / np.exp(4.5944) + ) -> float: + """ + Calculate concept presence score using contrastive comparison. + + Args: + image_features_norm: Normalized image embedding [1, embed_dim] + concept_embedding: Dict with target and reference embeddings + temp: Temperature for softmax + + Returns: + Concept presence score (0-1) + """ + target_emb = concept_embedding["target_embedding_norm"].float() + ref_emb = concept_embedding["ref_embedding_norm"].float() + + # Similarity: [num_templates, num_terms] @ [embed_dim, 1] -> [num_templates, num_terms, 1] + target_similarity = target_emb @ image_features_norm.T.float() + ref_similarity = ref_emb @ image_features_norm.T.float() + + # Mean over terms for each template + target_mean = target_similarity.mean(dim=1).squeeze() # [num_templates] + ref_mean = ref_similarity.mean(dim=1).squeeze() # [num_templates] + + # Softmax between target and reference (contrastive scoring) + scores = scipy.special.softmax( + np.array([target_mean.numpy() / temp, ref_mean.numpy() / temp]), + axis=0 + ) + + # Return mean of target scores across templates + return float(scores[0].mean()) + + def extract_features(self, image: Image.Image) -> Dict[str, float]: + """ + Extract MONET feature scores from a skin lesion image. + + Args: + image: PIL Image to analyze + + Returns: + dict with 7 MONET feature scores (0-1 range) + """ + if not self.loaded: + self.load() + + # Ensure RGB + if image.mode != "RGB": + image = image.convert("RGB") + + # Get image embedding + image_features = self._encode_image(image) + image_features_norm = F.normalize(image_features, dim=1) + + # Calculate score for each MONET feature + features = {} + for feature_name in MONET_FEATURES: + concept_emb = self._concept_embeddings[feature_name] + score = self._calculate_concept_score(image_features_norm, concept_emb) + features[feature_name] = score + + return features + + def get_feature_vector(self, image: Image.Image) -> List[float]: + """Get MONET features as a list in the expected order.""" + features = self.extract_features(image) + return [features[f] for f in MONET_FEATURES] + + def get_feature_tensor(self, image: Image.Image) -> torch.Tensor: + """Get MONET features as a PyTorch tensor.""" + return torch.tensor(self.get_feature_vector(image), dtype=torch.float32) + + def describe_features(self, features: Dict[str, float], threshold: float = 0.6) -> str: + """Generate a natural language description of the MONET features.""" + descriptions = { + "MONET_ulceration_crust": "ulceration or crusting", + "MONET_hair": "visible hair", + "MONET_vasculature_vessels": "visible blood vessels", + "MONET_erythema": "erythema (redness)", + "MONET_pigmented": "pigmentation", + "MONET_gel_water_drop_fluid_dermoscopy_liquid": "dermoscopy gel/fluid", + "MONET_skin_markings_pen_ink_purple_pen": "pen markings", + } + + present = [] + for feature, score in features.items(): + if score >= threshold: + desc = descriptions.get(feature, feature) + present.append(f"{desc} ({score:.0%})") + + if not present: + # Show top features even if below threshold + sorted_features = sorted(features.items(), key=lambda x: x[1], reverse=True)[:3] + present = [f"{descriptions.get(f, f)} ({s:.0%})" for f, s in sorted_features] + + return "Detected features: " + ", ".join(present) + + def analyze(self, image: Image.Image) -> Dict: + """Full analysis returning features, vector, and description.""" + features = self.extract_features(image) + vector = [features[f] for f in MONET_FEATURES] + description = self.describe_features(features) + + return { + "features": features, + "vector": vector, + "description": description, + "feature_names": MONET_FEATURES, + } + + def __call__(self, image: Image.Image) -> Dict: + """Shorthand for analyze()""" + return self.analyze(image) + + +# Singleton instance +_monet_instance = None + + +def get_monet_tool() -> MonetTool: + """Get or create MONET tool instance""" + global _monet_instance + if _monet_instance is None: + _monet_instance = MonetTool() + return _monet_instance + + +if __name__ == "__main__": + import sys + + print("MONET Tool Test (Correct Implementation)") + print("=" * 50) + + tool = MonetTool(use_hf=True) + print("Loading model...") + tool.load() + print("Model loaded!") + + if len(sys.argv) > 1: + image_path = sys.argv[1] + print(f"\nAnalyzing: {image_path}") + image = Image.open(image_path).convert("RGB") + result = tool.analyze(image) + + print("\nMONET Features (Contrastive Scores):") + for name, score in result["features"].items(): + bar = "█" * int(score * 20) + print(f" {name}: {score:.3f} {bar}") + + print(f"\nDescription: {result['description']}") + print(f"\nVector: {[f'{v:.3f}' for v in result['vector']]}") diff --git a/models/overlay_tool.py b/models/overlay_tool.py new file mode 100644 index 0000000000000000000000000000000000000000..3ca16c1cf75495f3f88feecb5076f9563e70f9f9 --- /dev/null +++ b/models/overlay_tool.py @@ -0,0 +1,335 @@ +""" +Overlay Tool - Generates visual markers for biopsy sites and excision margins +""" + +import io +import tempfile +from typing import Tuple, Optional, Dict, Any +from PIL import Image, ImageDraw, ImageFont + + +class OverlayTool: + """ + Generates image overlays for clinical decision visualization: + - Biopsy site markers (circles) + - Excision margins (dashed outlines with margin indicators) + """ + + # Colors for different marker types + COLORS = { + 'biopsy': (255, 69, 0, 200), # Orange-red with alpha + 'excision': (220, 20, 60, 200), # Crimson with alpha + 'margin': (255, 215, 0, 180), # Gold for margin line + 'text': (255, 255, 255, 255), # White text + 'text_bg': (0, 0, 0, 180), # Semi-transparent black bg + } + + def __init__(self): + self.loaded = True + + def generate_biopsy_overlay( + self, + image: Image.Image, + center_x: float, + center_y: float, + radius: float = 0.05, + label: str = "Biopsy Site" + ) -> Dict[str, Any]: + """ + Generate biopsy site overlay with circle marker. + + Args: + image: PIL Image + center_x: X coordinate as fraction (0-1) of image width + center_y: Y coordinate as fraction (0-1) of image height + radius: Radius as fraction of image width + label: Text label for the marker + + Returns: + Dict with overlay image and metadata + """ + # Convert to RGBA for transparency + img = image.convert("RGBA") + width, height = img.size + + # Create overlay layer + overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Calculate pixel coordinates + cx = int(center_x * width) + cy = int(center_y * height) + r = int(radius * width) + + # Draw outer circle (thicker) + for offset in range(3): + draw.ellipse( + [cx - r - offset, cy - r - offset, cx + r + offset, cy + r + offset], + outline=self.COLORS['biopsy'], + width=2 + ) + + # Draw crosshairs + line_len = r // 2 + draw.line([(cx - line_len, cy), (cx + line_len, cy)], + fill=self.COLORS['biopsy'], width=2) + draw.line([(cx, cy - line_len), (cx, cy + line_len)], + fill=self.COLORS['biopsy'], width=2) + + # Draw label with background + try: + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 14) + except: + font = ImageFont.load_default() + + text_bbox = draw.textbbox((0, 0), label, font=font) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + + text_x = cx - text_width // 2 + text_y = cy + r + 10 + + # Background rectangle for text + padding = 4 + draw.rectangle( + [text_x - padding, text_y - padding, + text_x + text_width + padding, text_y + text_height + padding], + fill=self.COLORS['text_bg'] + ) + draw.text((text_x, text_y), label, fill=self.COLORS['text'], font=font) + + # Composite + result = Image.alpha_composite(img, overlay) + + # Save to temp file + temp_file = tempfile.NamedTemporaryFile(suffix="_biopsy_overlay.png", delete=False) + result.save(temp_file.name, "PNG") + temp_file.close() + + return { + "overlay": result, + "path": temp_file.name, + "type": "biopsy", + "coordinates": { + "center_x": center_x, + "center_y": center_y, + "radius": radius + }, + "label": label + } + + def generate_excision_overlay( + self, + image: Image.Image, + center_x: float, + center_y: float, + lesion_radius: float, + margin_mm: int = 5, + pixels_per_mm: float = 10.0, + label: str = "Excision Margin" + ) -> Dict[str, Any]: + """ + Generate excision margin overlay with inner (lesion) and outer (margin) boundaries. + + Args: + image: PIL Image + center_x: X coordinate as fraction (0-1) + center_y: Y coordinate as fraction (0-1) + lesion_radius: Lesion radius as fraction of image width + margin_mm: Excision margin in millimeters + pixels_per_mm: Estimated pixels per mm (for margin calculation) + label: Text label + + Returns: + Dict with overlay image and metadata + """ + img = image.convert("RGBA") + width, height = img.size + + overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Calculate coordinates + cx = int(center_x * width) + cy = int(center_y * height) + inner_r = int(lesion_radius * width) + + # Calculate margin in pixels + margin_px = int(margin_mm * pixels_per_mm) + outer_r = inner_r + margin_px + + # Draw outer margin (dashed effect using multiple arcs) + dash_length = 10 + for angle in range(0, 360, dash_length * 2): + draw.arc( + [cx - outer_r, cy - outer_r, cx + outer_r, cy + outer_r], + start=angle, + end=angle + dash_length, + fill=self.COLORS['margin'], + width=3 + ) + + # Draw inner lesion boundary (solid) + draw.ellipse( + [cx - inner_r, cy - inner_r, cx + inner_r, cy + inner_r], + outline=self.COLORS['excision'], + width=2 + ) + + # Draw margin indicator lines (radial) + for angle in [0, 90, 180, 270]: + import math + rad = math.radians(angle) + inner_x = cx + int(inner_r * math.cos(rad)) + inner_y = cy + int(inner_r * math.sin(rad)) + outer_x = cx + int(outer_r * math.cos(rad)) + outer_y = cy + int(outer_r * math.sin(rad)) + draw.line([(inner_x, inner_y), (outer_x, outer_y)], + fill=self.COLORS['margin'], width=2) + + # Draw labels + try: + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 12) + font_small = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 10) + except: + font = ImageFont.load_default() + font_small = font + + # Main label + text_bbox = draw.textbbox((0, 0), label, font=font) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + + text_x = cx - text_width // 2 + text_y = cy + outer_r + 15 + + padding = 4 + draw.rectangle( + [text_x - padding, text_y - padding, + text_x + text_width + padding, text_y + text_height + padding], + fill=self.COLORS['text_bg'] + ) + draw.text((text_x, text_y), label, fill=self.COLORS['text'], font=font) + + # Margin measurement label + margin_label = f"{margin_mm}mm margin" + margin_bbox = draw.textbbox((0, 0), margin_label, font=font_small) + margin_width = margin_bbox[2] - margin_bbox[0] + + margin_text_x = cx + outer_r + 5 + margin_text_y = cy - 6 + + draw.rectangle( + [margin_text_x - 2, margin_text_y - 2, + margin_text_x + margin_width + 2, margin_text_y + 12], + fill=self.COLORS['text_bg'] + ) + draw.text((margin_text_x, margin_text_y), margin_label, + fill=self.COLORS['margin'], font=font_small) + + # Composite + result = Image.alpha_composite(img, overlay) + + temp_file = tempfile.NamedTemporaryFile(suffix="_excision_overlay.png", delete=False) + result.save(temp_file.name, "PNG") + temp_file.close() + + return { + "overlay": result, + "path": temp_file.name, + "type": "excision", + "coordinates": { + "center_x": center_x, + "center_y": center_y, + "lesion_radius": lesion_radius, + "margin_mm": margin_mm, + "total_radius": outer_r / width + }, + "label": label + } + + def generate_comparison_overlay( + self, + image1: Image.Image, + image2: Image.Image, + label1: str = "Previous", + label2: str = "Current" + ) -> Dict[str, Any]: + """ + Generate side-by-side comparison of two images for follow-up. + + Args: + image1: First (previous) image + image2: Second (current) image + label1: Label for first image + label2: Label for second image + + Returns: + Dict with comparison image and metadata + """ + # Resize to same height + max_height = 400 + + # Calculate sizes maintaining aspect ratio + w1, h1 = image1.size + w2, h2 = image2.size + + ratio1 = max_height / h1 + ratio2 = max_height / h2 + + new_w1 = int(w1 * ratio1) + new_w2 = int(w2 * ratio2) + + img1 = image1.resize((new_w1, max_height), Image.Resampling.LANCZOS) + img2 = image2.resize((new_w2, max_height), Image.Resampling.LANCZOS) + + # Create comparison canvas + gap = 20 + total_width = new_w1 + gap + new_w2 + header_height = 30 + total_height = max_height + header_height + + canvas = Image.new("RGB", (total_width, total_height), (255, 255, 255)) + draw = ImageDraw.Draw(canvas) + + # Draw labels + try: + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 14) + except: + font = ImageFont.load_default() + + # Previous label + draw.rectangle([0, 0, new_w1, header_height], fill=(70, 130, 180)) + bbox1 = draw.textbbox((0, 0), label1, font=font) + text_w1 = bbox1[2] - bbox1[0] + draw.text(((new_w1 - text_w1) // 2, 8), label1, fill=(255, 255, 255), font=font) + + # Current label + draw.rectangle([new_w1 + gap, 0, total_width, header_height], fill=(60, 179, 113)) + bbox2 = draw.textbbox((0, 0), label2, font=font) + text_w2 = bbox2[2] - bbox2[0] + draw.text((new_w1 + gap + (new_w2 - text_w2) // 2, 8), label2, + fill=(255, 255, 255), font=font) + + # Paste images + canvas.paste(img1, (0, header_height)) + canvas.paste(img2, (new_w1 + gap, header_height)) + + # Draw divider + draw.line([(new_w1 + gap // 2, header_height), (new_w1 + gap // 2, total_height)], + fill=(200, 200, 200), width=2) + + temp_file = tempfile.NamedTemporaryFile(suffix="_comparison.png", delete=False) + canvas.save(temp_file.name, "PNG") + temp_file.close() + + return { + "comparison": canvas, + "path": temp_file.name, + "type": "comparison" + } + + +def get_overlay_tool() -> OverlayTool: + """Get overlay tool instance""" + return OverlayTool() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..77b15c697b86f542b78ec1e089e136ea707f6f12 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +# requirements.txt + +torch>=2.0.0 +torchvision>=0.15.0 +transformers>=4.40.0 +timm>=0.9.0 +gradio==4.44.0 +gradio-client==1.3.0 +opencv-python>=4.8.0 +numpy>=1.24.0 +Pillow>=10.0.0 +sentencepiece>=0.1.99 +accelerate>=0.25.0 +protobuf>=4.0.0 +mcp>=1.0.0 # installed via python3.11 (requires Python >=3.10) \ No newline at end of file diff --git a/test_models.py b/test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..be98832e29e1f69fa18a73af859535f3e8cab4a0 --- /dev/null +++ b/test_models.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Test script to verify model loading""" + +import torch +import torch.nn as nn +import timm +from transformers import AutoModel, AutoProcessor +import numpy as np + +DEVICE = "cpu" +print(f"Device: {DEVICE}") + +# ConvNeXt model definition (matching checkpoint) +class ConvNeXtDualEncoder(nn.Module): + def __init__(self, model_name="convnext_base.fb_in22k_ft_in1k", + metadata_dim=19, num_classes=11, dropout=0.3): + super().__init__() + self.backbone = timm.create_model(model_name, pretrained=False, num_classes=0) + backbone_dim = self.backbone.num_features + self.meta_mlp = nn.Sequential( + nn.Linear(metadata_dim, 64), nn.LayerNorm(64), nn.GELU(), nn.Dropout(dropout) + ) + fusion_dim = backbone_dim * 2 + 64 + self.classifier = nn.Sequential( + nn.Linear(fusion_dim, 512), nn.LayerNorm(512), nn.GELU(), nn.Dropout(dropout), + nn.Linear(512, 256), nn.LayerNorm(256), nn.GELU(), nn.Dropout(dropout), + nn.Linear(256, num_classes) + ) + + def forward(self, clinical_img, derm_img=None, metadata=None): + clinical_features = self.backbone(clinical_img) + derm_features = self.backbone(derm_img) if derm_img is not None else clinical_features + if metadata is not None: + meta_features = self.meta_mlp(metadata) + else: + meta_features = torch.zeros(clinical_features.size(0), 64, device=clinical_features.device) + fused = torch.cat([clinical_features, derm_features, meta_features], dim=1) + return self.classifier(fused) + + +# MedSigLIP model definition +class MedSigLIPClassifier(nn.Module): + def __init__(self, num_classes=11, model_name="google/siglip-base-patch16-384"): + super().__init__() + self.siglip = AutoModel.from_pretrained(model_name) + self.processor = AutoProcessor.from_pretrained(model_name) + hidden_dim = self.siglip.config.vision_config.hidden_size + self.classifier = nn.Sequential( + nn.Linear(hidden_dim, 512), nn.LayerNorm(512), nn.GELU(), nn.Dropout(0.3), + nn.Linear(512, num_classes) + ) + for param in self.siglip.parameters(): + param.requires_grad = False + + def forward(self, pixel_values): + vision_outputs = self.siglip.vision_model(pixel_values=pixel_values) + pooled_features = vision_outputs.pooler_output + return self.classifier(pooled_features) + + +if __name__ == "__main__": + print("\n[1/2] Loading ConvNeXt...") + convnext_model = ConvNeXtDualEncoder() + ckpt = torch.load("models/seed42_fold0.pt", map_location=DEVICE, weights_only=False) + convnext_model.load_state_dict(ckpt) + convnext_model.eval() + print(" ConvNeXt loaded!") + + print("\n[2/2] Loading MedSigLIP...") + medsiglip_model = MedSigLIPClassifier() + medsiglip_model.eval() + print(" MedSigLIP loaded!") + + # Quick inference test + print("\nTesting inference...") + dummy_img = torch.randn(1, 3, 384, 384) + with torch.no_grad(): + convnext_out = convnext_model(dummy_img) + print(f" ConvNeXt output: {convnext_out.shape}") + + dummy_pil = np.random.randint(0, 255, (384, 384, 3), dtype=np.uint8) + siglip_input = medsiglip_model.processor(images=[dummy_pil], return_tensors="pt") + siglip_out = medsiglip_model(siglip_input["pixel_values"]) + print(f" MedSigLIP output: {siglip_out.shape}") + + print("\nAll tests passed!") diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..2404ac1b1bd2a1864e5d2febdea61fd93b67d3c5 --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + + + + SkinProAI + + + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..007fc510e30d46ba5553e1e4711703bf0e08e588 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2937 @@ +{ + "name": "skinproai-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skinproai-web", + "version": "1.0.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^6.20.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.3.0", + "vite": "^5.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001768", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz", + "integrity": "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..3ad0e36c3c64e63b6b41bfdce5cdb303c1821c5b --- /dev/null +++ b/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "skinproai-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^6.20.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.3.0", + "vite": "^5.0.0" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1459a120ee6a25fe6e89045ef55903f80c5b6287 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,14 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { PatientsPage } from './pages/PatientsPage'; +import { ChatPage } from './pages/ChatPage'; + +export function App() { + return ( + + + } /> + } /> + + + ); +} diff --git a/web/src/components/MessageContent.css b/web/src/components/MessageContent.css new file mode 100644 index 0000000000000000000000000000000000000000..0777e337fb793c1657eae672c93b49b57df5bed7 --- /dev/null +++ b/web/src/components/MessageContent.css @@ -0,0 +1,250 @@ +/* ─── Root ───────────────────────────────────────────────────────────────── */ +.mc-root { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.9375rem; + line-height: 1.6; + color: var(--gray-800); + width: 100%; +} + +/* ─── Stage header ───────────────────────────────────────────────────────── */ +.mc-stage { + font-size: 0.75rem; + font-weight: 600; + color: var(--primary); + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 8px 0 2px; + border-top: 1px solid var(--gray-100); + margin-top: 4px; +} + +.mc-stage:first-child { + border-top: none; + margin-top: 0; + padding-top: 0; +} + +/* ─── Thinking text ──────────────────────────────────────────────────────── */ +.mc-thinking { + font-size: 0.8125rem; + color: var(--gray-500); + font-style: italic; +} + +/* ─── Response block (markdown) ──────────────────────────────────────────── */ +.mc-response { + color: var(--gray-800); +} + +.mc-response p { + margin: 0 0 8px; +} + +.mc-response p:last-child { + margin-bottom: 0; +} + +.mc-response strong { + font-weight: 600; + color: var(--gray-900); +} + +.mc-response em { + font-style: italic; +} + +.mc-response ul, +.mc-response ol { + margin: 4px 0 8px 20px; + padding: 0; +} + +.mc-response li { + margin-bottom: 2px; +} + +.mc-response h1, +.mc-response h2, +.mc-response h3, +.mc-response h4 { + font-size: 0.9375rem; + font-weight: 600; + color: var(--gray-900); + margin: 10px 0 4px; +} + +.mc-response code { + font-family: monospace; + font-size: 0.875em; + background: var(--gray-100); + padding: 1px 5px; + border-radius: 4px; +} + +/* ─── Tool output (monospace block) ─────────────────────────────────────── */ +.mc-tool-output { + background: var(--gray-900); + border-radius: 8px; + overflow: hidden; +} + +.mc-tool-output-label { + font-size: 0.6875rem; + font-weight: 600; + color: var(--gray-400); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 6px 12px 4px; + background: rgba(255, 255, 255, 0.05); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.mc-tool-output pre { + margin: 0; + padding: 10px 12px; + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.8rem; + line-height: 1.5; + color: #e2e8f0; + white-space: pre; + overflow-x: auto; +} + +/* ─── Image blocks (GradCAM, comparison) ────────────────────────────────── */ +.mc-image-block { + margin-top: 4px; +} + +.mc-image-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--gray-500); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 6px; +} + +.mc-gradcam-img { + width: 100%; + max-width: 380px; + border-radius: 10px; + border: 1px solid var(--gray-200); + display: block; +} + +.mc-comparison-img { + width: 100%; + max-width: 560px; + border-radius: 10px; + border: 1px solid var(--gray-200); + display: block; +} + +/* ─── GradCAM side-by-side comparison ───────────────────────────────────── */ +.mc-gradcam-compare { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + max-width: 560px; +} + +.mc-gradcam-compare-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.mc-gradcam-compare-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--gray-600); + text-align: center; +} + +.mc-gradcam-compare-img { + width: 100%; + border-radius: 8px; + border: 1px solid var(--gray-200); + display: block; +} + +/* ─── Result / error / complete / observation ───────────────────────────── */ +.mc-result { + background: linear-gradient(135deg, #f0fdf4, #dcfce7); + border: 1px solid #86efac; + border-radius: 8px; + padding: 8px 12px; + font-size: 0.875rem; + font-weight: 500; + color: #15803d; +} + +.mc-error { + background: #fef2f2; + border: 1px solid #fca5a5; + border-radius: 8px; + padding: 8px 12px; + font-size: 0.875rem; + color: #dc2626; +} + +.mc-complete { + font-size: 0.8rem; + color: var(--gray-400); + text-align: right; +} + +.mc-observation { + font-size: 0.875rem; + color: var(--gray-600); + font-style: italic; +} + +/* ─── Plain streaming text ───────────────────────────────────────────────── */ +.mc-text { + white-space: pre-wrap; + word-break: break-word; + color: var(--gray-700); + font-size: 0.875rem; +} + +/* ─── References ─────────────────────────────────────────────────────────── */ +.mc-references { + border-top: 1px solid var(--gray-100); + padding-top: 8px; + margin-top: 4px; +} + +.mc-references-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--gray-500); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; +} + +.mc-ref-item { + font-size: 0.8125rem; + color: var(--gray-600); + line-height: 1.5; +} + +.mc-ref-sup { + font-size: 0.6875rem; + vertical-align: super; + margin-right: 4px; + color: var(--primary); + font-weight: 600; +} + +.mc-ref-source { + font-style: italic; +} + +.mc-ref-page { + color: var(--gray-400); +} diff --git a/web/src/components/MessageContent.tsx b/web/src/components/MessageContent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c5daa43ade1278c8902c0ce0d35c1588d6f19dcc --- /dev/null +++ b/web/src/components/MessageContent.tsx @@ -0,0 +1,254 @@ +import ReactMarkdown from 'react-markdown'; +import './MessageContent.css'; + +// Serve any temp visualization image (GradCAM, comparison) through the API +const TEMP_IMG_URL = (path: string) => + `/api/patients/gradcam?path=${encodeURIComponent(path)}`; + +// ─── Types ───────────────────────────────────────────────────────────────── + +type Segment = + | { type: 'stage'; label: string } + | { type: 'thinking'; content: string } + | { type: 'response'; content: string } + | { type: 'tool_output'; label: string; content: string } + | { type: 'gradcam'; path: string } + | { type: 'comparison'; path: string } + | { type: 'gradcam_compare'; path1: string; path2: string } + | { type: 'result'; content: string } + | { type: 'error'; content: string } + | { type: 'complete'; content: string } + | { type: 'references'; content: string } + | { type: 'observation'; content: string } + | { type: 'text'; content: string }; + +// ─── Parser ──────────────────────────────────────────────────────────────── + +// Splits raw text by all known complete tag patterns (capturing group preserves them) +const TAG_SPLIT_RE = new RegExp( + '(' + + [ + '\\[STAGE:[^\\]]*\\][\\s\\S]*?\\[\\/STAGE\\]', + '\\[THINKING\\][\\s\\S]*?\\[\\/THINKING\\]', + '\\[RESPONSE\\][\\s\\S]*?\\[\\/RESPONSE\\]', + '\\[TOOL_OUTPUT:[^\\]]*\\][\\s\\S]*?\\[\\/TOOL_OUTPUT\\]', + '\\[GRADCAM_IMAGE:[^\\]]+\\]', + '\\[COMPARISON_IMAGE:[^\\]]+\\]', + '\\[GRADCAM_COMPARE:[^:\\]]+:[^\\]]+\\]', + '\\[RESULT\\][\\s\\S]*?\\[\\/RESULT\\]', + '\\[ERROR\\][\\s\\S]*?\\[\\/ERROR\\]', + '\\[COMPLETE\\][\\s\\S]*?\\[\\/COMPLETE\\]', + '\\[REFERENCES\\][\\s\\S]*?\\[\\/REFERENCES\\]', + '\\[OBSERVATION\\][\\s\\S]*?\\[\\/OBSERVATION\\]', + '\\[CONFIRM:[^\\]]*\\][\\s\\S]*?\\[\\/CONFIRM\\]', + ].join('|') + + ')', + 'g', +); + +// Strips known opening tags that haven't yet been closed (mid-stream partial content) +function cleanStreamingText(text: string): string { + return text.replace( + /\[(STAGE:[^\]]*|THINKING|RESPONSE|TOOL_OUTPUT:[^\]]*|RESULT|ERROR|COMPLETE|REFERENCES|OBSERVATION|CONFIRM:[^\]]*)\]/g, + '', + ); +} + +function parseContent(raw: string): Segment[] { + const segments: Segment[] = []; + + for (const part of raw.split(TAG_SPLIT_RE)) { + if (!part) continue; + + let m: RegExpMatchArray | null; + + if ((m = part.match(/^\[STAGE:([^\]]*)\]([\s\S]*)\[\/STAGE\]$/))) { + const label = m[2].trim(); + if (label) segments.push({ type: 'stage', label }); + + } else if ((m = part.match(/^\[THINKING\]([\s\S]*)\[\/THINKING\]$/))) { + const c = m[1].trim(); + if (c) segments.push({ type: 'thinking', content: c }); + + } else if ((m = part.match(/^\[RESPONSE\]([\s\S]*)\[\/RESPONSE\]$/))) { + const c = m[1].trim(); + if (c) segments.push({ type: 'response', content: c }); + + } else if ((m = part.match(/^\[TOOL_OUTPUT:([^\]]*)\]([\s\S]*)\[\/TOOL_OUTPUT\]$/))) { + segments.push({ type: 'tool_output', label: m[1], content: m[2] }); + + } else if ((m = part.match(/^\[GRADCAM_IMAGE:([^\]]+)\]$/))) { + segments.push({ type: 'gradcam', path: m[1] }); + + } else if ((m = part.match(/^\[COMPARISON_IMAGE:([^\]]+)\]$/))) { + segments.push({ type: 'comparison', path: m[1] }); + + } else if ((m = part.match(/^\[GRADCAM_COMPARE:([^:\]]+):([^\]]+)\]$/))) { + segments.push({ type: 'gradcam_compare', path1: m[1], path2: m[2] }); + + } else if ((m = part.match(/^\[RESULT\]([\s\S]*)\[\/RESULT\]$/))) { + const c = m[1].trim(); + if (c) segments.push({ type: 'result', content: c }); + + } else if ((m = part.match(/^\[ERROR\]([\s\S]*)\[\/ERROR\]$/))) { + const c = m[1].trim(); + if (c) segments.push({ type: 'error', content: c }); + + } else if ((m = part.match(/^\[COMPLETE\]([\s\S]*)\[\/COMPLETE\]$/))) { + const c = m[1].trim(); + if (c) segments.push({ type: 'complete', content: c }); + + } else if ((m = part.match(/^\[REFERENCES\]([\s\S]*)\[\/REFERENCES\]$/))) { + segments.push({ type: 'references', content: m[1].trim() }); + + } else if ((m = part.match(/^\[OBSERVATION\]([\s\S]*)\[\/OBSERVATION\]$/))) { + const c = m[1].trim(); + if (c) segments.push({ type: 'observation', content: c }); + + } else if ((m = part.match(/^\[CONFIRM:[^\]]*\]([\s\S]*)\[\/CONFIRM\]$/))) { + const c = m[1].trim(); + if (c) segments.push({ type: 'result', content: c }); + + } else { + // Plain text (may be mid-stream with incomplete opening tags) + const cleaned = cleanStreamingText(part); + if (cleaned.trim()) segments.push({ type: 'text', content: cleaned }); + } + } + + return segments; +} + +// ─── References renderer ─────────────────────────────────────────────────── + +function References({ content }: { content: string }) { + const refs = content.match(/\[REF:[^\]]+\]/g) ?? []; + if (!refs.length) return null; + + return ( +
+
References
+ {refs.map((ref, i) => { + // [REF:id:source:page:file:superscript] + const parts = ref.slice(1, -1).split(':'); + const source = parts[2] ?? ''; + const page = parts[3] ?? ''; + const sup = parts[5] ?? `[${i + 1}]`; + return ( +
+ {sup} + {source} + {page && , p.{page}} +
+ ); + })} +
+ ); +} + +// ─── Main component ──────────────────────────────────────────────────────── + +export function MessageContent({ text }: { text: string }) { + const segments = parseContent(text); + + return ( +
+ {segments.map((seg, i) => { + switch (seg.type) { + case 'stage': + return
{seg.label}
; + + case 'thinking': + return
{seg.content}
; + + case 'response': + return ( +
+ {seg.content} +
+ ); + + case 'tool_output': + return ( +
+ {seg.label &&
{seg.label}
} +
{seg.content}
+
+ ); + + case 'gradcam': + return ( +
+
Grad-CAM Attention Map
+ Grad-CAM attention map +
+ ); + + case 'comparison': + return ( +
+
Lesion Comparison
+ Side-by-side lesion comparison +
+ ); + + case 'gradcam_compare': + return ( +
+
Grad-CAM Comparison
+
+
+
Previous
+ Previous GradCAM +
+
+
Current
+ Current GradCAM +
+
+
+ ); + + case 'result': + return
{seg.content}
; + + case 'error': + return
{seg.content}
; + + case 'complete': + return
{seg.content}
; + + case 'references': + return ; + + case 'observation': + return
{seg.content}
; + + case 'text': + return seg.content.trim() ? ( +
{seg.content}
+ ) : null; + + default: + return null; + } + })} +
+ ); +} diff --git a/web/src/components/ToolCallCard.css b/web/src/components/ToolCallCard.css new file mode 100644 index 0000000000000000000000000000000000000000..78f4de32a87260118eb455b1e9eb6f41cdc9fa75 --- /dev/null +++ b/web/src/components/ToolCallCard.css @@ -0,0 +1,338 @@ +/* ─── Card container ─────────────────────────────────────────────────────── */ +.tool-card { + border: 1px solid var(--gray-200); + border-left: 3px solid var(--primary); + border-radius: 10px; + overflow: hidden; + background: var(--gray-50); + margin-top: 8px; +} + +.tool-card.loading { + border-left-color: var(--gray-400); +} + +.tool-card.error { + border-left-color: #ef4444; +} + +/* ─── Header (collapsed row) ─────────────────────────────────────────────── */ +.tool-card-header { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + transition: background 0.15s; +} + +.tool-card-header:hover:not(:disabled) { + background: var(--gray-100); +} + +.tool-card-header:disabled { + cursor: default; +} + +.tool-icon { + font-size: 1rem; + flex-shrink: 0; +} + +.tool-label { + flex: 1; + font-size: 0.875rem; + font-weight: 500; + color: var(--gray-700); + text-transform: capitalize; +} + +.tool-status { + font-size: 0.8125rem; + flex-shrink: 0; +} + +.tool-status.done { + color: var(--success, #22c55e); + font-weight: 600; +} + +.tool-status.calling { + color: var(--gray-500); + display: flex; + align-items: center; + gap: 5px; +} + +.tool-status.error-text { + color: #ef4444; +} + +.tool-header-summary { + font-size: 0.8125rem; + color: var(--gray-500); + font-weight: 400; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +.tool-chevron { + font-size: 0.625rem; + color: var(--gray-400); + margin-left: 2px; + flex-shrink: 0; +} + +/* ─── Spinner ────────────────────────────────────────────────────────────── */ +.spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid var(--gray-300); + border-top-color: var(--gray-600); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ─── Card body ──────────────────────────────────────────────────────────── */ +.tool-card-body { + padding: 14px; + border-top: 1px solid var(--gray-200); + background: white; +} + +/* ─── analyze_image ──────────────────────────────────────────────────────── */ +.analyze-result { + display: flex; + flex-direction: column; + gap: 12px; +} + +.analyze-top { + display: flex; + gap: 14px; + align-items: flex-start; +} + +.analyze-thumb { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: 8px; + border: 1px solid var(--gray-200); + flex-shrink: 0; +} + +.analyze-info { + flex: 1; + min-width: 0; +} + +.diagnosis-name { + font-size: 0.9375rem; + font-weight: 600; + color: var(--gray-900); + margin: 0 0 4px; + line-height: 1.3; +} + +.confidence-label { + font-size: 0.8125rem; + font-weight: 500; + margin: 0 0 6px; +} + +.confidence-bar-track { + height: 6px; + background: var(--gray-200); + border-radius: 999px; + overflow: hidden; +} + +.confidence-bar-fill { + height: 100%; + border-radius: 999px; + transition: width 0.3s ease; +} + +.analyze-summary { + font-size: 0.875rem; + color: var(--gray-700); + line-height: 1.6; + margin: 0; + border-top: 1px solid var(--gray-100); + padding-top: 10px; + white-space: pre-wrap; +} + +.other-predictions { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 6px; + border-top: 1px solid var(--gray-100); + padding-top: 10px; +} + +.prediction-row { + display: flex; + justify-content: space-between; + font-size: 0.8125rem; +} + +.pred-name { + color: var(--gray-600); +} + +.pred-pct { + color: var(--gray-500); + font-variant-numeric: tabular-nums; +} + +/* ─── compare_images ─────────────────────────────────────────────────────── */ +.compare-result { + display: flex; + flex-direction: column; + gap: 12px; +} + +.carousel { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.carousel-image-wrap { + position: relative; + display: inline-block; +} + +.carousel-image { + width: 200px; + height: 160px; + object-fit: cover; + border-radius: 10px; + border: 1px solid var(--gray-200); + display: block; +} + +.carousel-label { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.55); + color: white; + font-size: 0.75rem; + font-weight: 600; + padding: 3px 10px; + border-radius: 999px; + white-space: nowrap; +} + +.carousel-btn { + background: white; + border: 1px solid var(--gray-300); + border-radius: 6px; + width: 28px; + height: 28px; + cursor: pointer; + font-size: 0.75rem; + color: var(--gray-600); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.carousel-btn:hover { + background: var(--gray-100); +} + +.carousel-dots { + position: absolute; + bottom: -18px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 5px; +} + +.carousel-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--gray-300); + cursor: pointer; + transition: background 0.15s; +} + +.carousel-dot.active { + background: var(--primary); +} + +.compare-status { + font-size: 0.9375rem; + margin-top: 6px; +} + +.feature-changes { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.feature-row { + display: flex; + justify-content: space-between; + font-size: 0.8125rem; +} + +.feature-name { + color: var(--gray-600); + text-transform: capitalize; +} + +.feature-delta { + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +.compare-summary { + font-size: 0.875rem; + color: var(--gray-600); + line-height: 1.5; + margin: 0; + border-top: 1px solid var(--gray-100); + padding-top: 10px; +} + +/* ─── Generic fallback ───────────────────────────────────────────────────── */ +.generic-result { + font-size: 0.75rem; + background: var(--gray-50); + border-radius: 6px; + padding: 10px; + overflow-x: auto; + color: var(--gray-700); + margin: 0; + white-space: pre-wrap; + word-break: break-all; +} diff --git a/web/src/components/ToolCallCard.tsx b/web/src/components/ToolCallCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f0eab697c779d18f894603a36d290a340f57117d --- /dev/null +++ b/web/src/components/ToolCallCard.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from 'react'; +import { ToolCall } from '../types'; +import './ToolCallCard.css'; + +interface ToolCallCardProps { + toolCall: ToolCall; +} + +/** One-line summary shown in the collapsed header so results are visible at a glance */ +function CollapsedSummary({ toolCall }: { toolCall: ToolCall }) { + const r = toolCall.result; + if (!r) return null; + + if (toolCall.tool === 'analyze_image') { + const name = r.full_name ?? r.diagnosis; + const pct = r.confidence != null ? `${Math.round(r.confidence * 100)}%` : null; + if (name) return ( + + {name}{pct ? ` — ${pct}` : ''} + + ); + } + + if (toolCall.tool === 'compare_images') { + const key = r.status_label ?? 'STABLE'; + const cfg = STATUS_CONFIG[key] ?? { emoji: '⚪', label: key }; + return ( + + {cfg.emoji} {cfg.label} + + ); + } + + return null; +} + +export function ToolCallCard({ toolCall }: ToolCallCardProps) { + // Auto-expand when the tool completes so results are immediately visible. + // User can collapse manually afterwards. + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + if (toolCall.status === 'complete') setExpanded(true); + }, [toolCall.status]); + + const isLoading = toolCall.status === 'calling'; + const isError = toolCall.status === 'error'; + + const icon = toolCall.tool === 'compare_images' ? '🔄' : '🔬'; + const label = toolCall.tool.replace(/_/g, ' '); + + return ( +
+ + + {expanded && !isLoading && toolCall.result && ( +
+ {toolCall.tool === 'analyze_image' && ( + + )} + {toolCall.tool === 'compare_images' && ( + + )} + {toolCall.tool !== 'analyze_image' && toolCall.tool !== 'compare_images' && ( + + )} +
+ )} +
+ ); +} + +/* ─── analyze_image renderer ─────────────────────────────────────────────── */ + +function AnalyzeImageResult({ result }: { result: ToolCall['result'] }) { + if (!result) return null; + + const hasClassifier = result.diagnosis != null; + const topPrediction = result.all_predictions?.[0]; + const otherPredictions = result.all_predictions?.slice(1) ?? []; + const confidence = result.confidence ?? topPrediction?.probability ?? 0; + const pct = Math.round(confidence * 100); + const statusColor = pct >= 70 ? '#ef4444' : pct >= 40 ? '#f59e0b' : '#22c55e'; + + return ( +
+
+ {result.image_url && ( + Analyzed lesion + )} +
+ {hasClassifier ? ( + <> +

{result.full_name ?? result.diagnosis}

+

+ Confidence: {pct}% +

+
+
+
+ + ) : ( +

+ Visual assessment complete — classifier unavailable +

+ )} +
+
+ + {hasClassifier && otherPredictions.length > 0 && ( +
    + {otherPredictions.map(p => ( +
  • + {p.full_name ?? p.class} + {Math.round(p.probability * 100)}% +
  • + ))} +
+ )} +
+ ); +} + +/* ─── compare_images renderer ────────────────────────────────────────────── */ + +const STATUS_CONFIG: Record = { + STABLE: { label: 'Stable', color: '#22c55e', emoji: '🟢' }, + MINOR_CHANGE: { label: 'Minor Change', color: '#f59e0b', emoji: '🟡' }, + SIGNIFICANT_CHANGE: { label: 'Significant Change', color: '#ef4444', emoji: '🔴' }, + IMPROVED: { label: 'Improved', color: '#3b82f6', emoji: '🔵' }, +}; + +function CompareImagesResult({ result }: { result: ToolCall['result'] }) { + if (!result) return null; + + const statusKey = result.status_label ?? 'STABLE'; + const status = STATUS_CONFIG[statusKey] ?? { label: statusKey, color: '#6b7280', emoji: '⚪' }; + const featureChanges = Object.entries(result.feature_changes ?? {}); + + return ( +
+
+ Status: {status.label} {status.emoji} +
+ + {featureChanges.length > 0 && ( +
    + {featureChanges.map(([name, vals]) => { + const delta = vals.curr - vals.prev; + const sign = delta > 0 ? '+' : ''; + return ( +
  • + {name} + 0.1 ? '#f59e0b' : '#6b7280' }}> + {sign}{(delta * 100).toFixed(1)}% + +
  • + ); + })} +
+ )} + + {result.summary && ( +

{result.summary}

+ )} +
+ ); +} + +/* ─── Generic (unknown tool) renderer ───────────────────────────────────── */ + +function GenericResult({ result }: { result: ToolCall['result'] }) { + return ( +
+      {JSON.stringify(result, null, 2)}
+    
+ ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000000000000000000000000000000000000..68fc79f84f9c0e2ffe2047b832c05469972c372b --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,38 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #f8fafc; + color: #1e293b; + line-height: 1.5; +} + +button { + font-family: inherit; + cursor: pointer; +} + +input { + font-family: inherit; +} + +:root { + --primary: #6366f1; + --primary-hover: #4f46e5; + --success: #10b981; + --error: #ef4444; + --gray-50: #f8fafc; + --gray-100: #f1f5f9; + --gray-200: #e2e8f0; + --gray-300: #cbd5e1; + --gray-400: #94a3b8; + --gray-500: #64748b; + --gray-600: #475569; + --gray-700: #334155; + --gray-800: #1e293b; + --gray-900: #0f172a; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..813e3d7644c80c52478632375fa28423258e4cb2 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { App } from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/pages/ChatPage.css b/web/src/pages/ChatPage.css new file mode 100644 index 0000000000000000000000000000000000000000..be35e480f85e6d54b855ba6e97876af123f1dd61 --- /dev/null +++ b/web/src/pages/ChatPage.css @@ -0,0 +1,340 @@ +/* ─── Layout ───────────────────────────────────────────────────────────── */ +.chat-page { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--gray-50); + overflow: hidden; +} + +/* ─── Header ────────────────────────────────────────────────────────────── */ +.chat-header { + display: flex; + align-items: center; + gap: 12px; + padding: 0 16px; + height: 56px; + background: white; + border-bottom: 1px solid var(--gray-200); + flex-shrink: 0; + z-index: 10; +} + +.header-back-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + cursor: pointer; + color: var(--gray-600); + border-radius: 8px; + transition: background 0.15s; + flex-shrink: 0; +} + +.header-back-btn:hover { + background: var(--gray-100); +} + +.header-back-btn svg { + width: 20px; + height: 20px; +} + +.header-center { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.header-app-name { + font-size: 0.7rem; + font-weight: 600; + color: var(--primary); + text-transform: uppercase; + letter-spacing: 0.05em; + line-height: 1; +} + +.header-patient-name { + font-size: 1rem; + font-weight: 600; + color: var(--gray-900); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.3; +} + +.header-clear-btn { + border: 1px solid var(--gray-300); + background: transparent; + border-radius: 8px; + padding: 6px 14px; + font-size: 0.8125rem; + color: var(--gray-600); + cursor: pointer; + transition: all 0.15s; + flex-shrink: 0; +} + +.header-clear-btn:hover { + background: var(--gray-100); + border-color: var(--gray-400); +} + +/* ─── Messages ──────────────────────────────────────────────────────────── */ +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 20px 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.chat-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--gray-400); + text-align: center; + gap: 12px; + margin: auto; +} + +.chat-empty-icon svg { + width: 40px; + height: 40px; + color: var(--gray-300); +} + +.chat-empty p { + font-size: 0.9375rem; + max-width: 280px; + line-height: 1.5; +} + +.message-row { + display: flex; + max-width: 720px; + width: 100%; +} + +.message-row.user { + align-self: flex-end; + justify-content: flex-end; +} + +.message-row.assistant { + align-self: flex-start; + justify-content: flex-start; +} + +/* ─── Bubbles ────────────────────────────────────────────────────────────── */ +.bubble { + max-width: 85%; + border-radius: 16px; + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.user-bubble { + background: var(--primary); + color: white; + border-bottom-right-radius: 4px; +} + +.assistant-bubble { + background: white; + border: 1px solid var(--gray-200); + border-bottom-left-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + max-width: 90%; +} + +.bubble-text { + font-size: 0.9375rem; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + +.user-bubble .bubble-text { + color: white; +} + +.assistant-text { + color: var(--gray-800); +} + +/* Image in user bubble */ +.message-image { + width: 100%; + max-width: 260px; + border-radius: 10px; + display: block; +} + +/* ─── Thinking indicator ─────────────────────────────────────────────────── */ +.thinking { + display: flex; + gap: 4px; + padding: 4px 0; +} + +.dot { + width: 7px; + height: 7px; + background: var(--gray-400); + border-radius: 50%; + animation: bounce 1.2s infinite; +} + +.dot:nth-child(2) { animation-delay: 0.2s; } +.dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes bounce { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-6px); } +} + +/* ─── Input bar ──────────────────────────────────────────────────────────── */ +.chat-input-bar { + background: white; + border-top: 1px solid var(--gray-200); + padding: 12px 16px; + flex-shrink: 0; +} + +.image-preview-container { + position: relative; + display: inline-block; + margin-bottom: 10px; +} + +.image-preview-thumb { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: 10px; + border: 2px solid var(--gray-200); + display: block; +} + +.remove-image-btn { + position: absolute; + top: -8px; + right: -8px; + width: 22px; + height: 22px; + background: var(--gray-700); + color: white; + border: none; + border-radius: 50%; + font-size: 0.875rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.input-row { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.attach-btn { + width: 38px; + height: 38px; + border: 1px solid var(--gray-300); + background: transparent; + border-radius: 10px; + cursor: pointer; + color: var(--gray-500); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.15s; +} + +.attach-btn:hover:not(:disabled) { + background: var(--gray-100); + border-color: var(--gray-400); + color: var(--gray-700); +} + +.attach-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.attach-btn svg { + width: 18px; + height: 18px; +} + +.chat-input { + flex: 1; + border: 1px solid var(--gray-300); + border-radius: 10px; + padding: 9px 14px; + font-size: 0.9375rem; + font-family: inherit; + resize: none; + line-height: 1.5; + max-height: 160px; + overflow-y: auto; + transition: border-color 0.15s; +} + +.chat-input:focus { + outline: none; + border-color: var(--primary); +} + +.chat-input:disabled { + background: var(--gray-50); + color: var(--gray-400); +} + +.send-btn { + width: 38px; + height: 38px; + background: var(--primary); + color: white; + border: none; + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background 0.15s; +} + +.send-btn:hover:not(:disabled) { + background: var(--primary-hover); +} + +.send-btn:disabled { + background: var(--gray-300); + cursor: not-allowed; +} + +.send-btn svg { + width: 18px; + height: 18px; +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8232f6e81f74c86f5ff738412fc037f3ad364c96 --- /dev/null +++ b/web/src/pages/ChatPage.tsx @@ -0,0 +1,263 @@ +import { useEffect, useRef, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { api } from '../services/api'; +import { streamChatMessage } from '../services/streaming'; +import { ToolCallCard } from '../components/ToolCallCard'; +import { MessageContent } from '../components/MessageContent'; +import { Patient, ChatMessage, ToolCall } from '../types'; +import './ChatPage.css'; + +export function ChatPage() { + const { patientId } = useParams<{ patientId: string }>(); + const navigate = useNavigate(); + + const [patient, setPatient] = useState(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [selectedImage, setSelectedImage] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [isStreaming, setIsStreaming] = useState(false); + + const messagesEndRef = useRef(null); + const fileInputRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + if (!patientId) return; + api.getPatient(patientId).then(res => setPatient(res.patient)); + api.getChatHistory(patientId).then(res => setMessages(res.messages ?? [])); + }, [patientId]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const handleImageSelect = (file: File) => { + setSelectedImage(file); + setImagePreview(URL.createObjectURL(file)); + }; + + const handleSend = async () => { + if ((!input.trim() && !selectedImage) || !patientId || isStreaming) return; + + const userMsgId = `msg-${Date.now()}`; + const assistantMsgId = `msg-${Date.now() + 1}`; + + const userMsg: ChatMessage = { + id: userMsgId, + role: 'user', + content: input, + timestamp: new Date().toISOString(), + image_url: imagePreview ?? undefined, + }; + + const assistantMsg: ChatMessage = { + id: assistantMsgId, + role: 'assistant', + content: '', + timestamp: new Date().toISOString(), + tool_calls: [], + }; + + setMessages(prev => [...prev, userMsg, assistantMsg]); + + const imgToSend = selectedImage; + const contentToSend = input; + setInput(''); + setSelectedImage(null); + setImagePreview(null); + setIsStreaming(true); + + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + + await streamChatMessage(patientId, contentToSend, imgToSend, { + onText: (chunk) => { + setMessages(prev => + prev.map(m => + m.id === assistantMsgId ? { ...m, content: m.content + chunk } : m + ) + ); + }, + onToolStart: (tool, callId) => { + setMessages(prev => + prev.map(m => + m.id === assistantMsgId + ? { + ...m, + tool_calls: [ + ...(m.tool_calls ?? []), + { id: callId, tool, status: 'calling' as const }, + ], + } + : m + ) + ); + }, + onToolResult: (_tool, callId, result) => { + setMessages(prev => + prev.map(m => + m.id === assistantMsgId + ? { + ...m, + tool_calls: (m.tool_calls ?? []).map(tc => + tc.id === callId + ? { ...tc, status: 'complete' as const, result: result as ToolCall['result'] } + : tc + ), + } + : m + ) + ); + }, + onDone: () => setIsStreaming(false), + onError: (err) => { + setMessages(prev => + prev.map(m => + m.id === assistantMsgId ? { ...m, content: `[ERROR]${err}[/ERROR]` } : m + ) + ); + setIsStreaming(false); + }, + }); + }; + + const handleClear = async () => { + if (!patientId || !confirm('Clear chat history?')) return; + await api.clearChat(patientId); + setMessages([]); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleTextareaChange = (e: React.ChangeEvent) => { + setInput(e.target.value); + e.target.style.height = 'auto'; + e.target.style.height = `${Math.min(e.target.scrollHeight, 160)}px`; + }; + + return ( +
+ {/* Header */} +
+ +
+ SkinProAI + {patient && {patient.name}} +
+ +
+ + {/* Messages */} +
+ {messages.length === 0 && ( +
+
+ + + +
+

Send a message or attach a skin image to begin analysis.

+
+ )} + + {messages.map(msg => ( +
+ {msg.role === 'user' ? ( +
+ {msg.image_url && ( + Attached image + )} + {msg.content &&

{msg.content}

} +
+ ) : ( +
+ {msg.content ? ( + + ) : (!msg.tool_calls || msg.tool_calls.length === 0) && isStreaming ? ( +
+ + + +
+ ) : null} + {(msg.tool_calls ?? []).map(tc => ( + + ))} +
+ )} +
+ ))} + +
+
+ + {/* Input bar */} +