File size: 8,984 Bytes
5ad56ab
4b7dfdf
5ad56ab
 
5cf89a3
5ad56ab
674b3ef
5ad56ab
 
 
 
 
 
 
 
4b7dfdf
5ad56ab
25758e2
5ad56ab
 
 
5cf89a3
5ad56ab
 
 
 
5cf89a3
 
d08d597
674b3ef
 
5cf89a3
5ad56ab
 
d08d597
 
 
4b7dfdf
d08d597
674b3ef
5ad56ab
674b3ef
5ad56ab
 
6a059ac
5cf89a3
4b7dfdf
674b3ef
 
cae8c1d
 
 
 
674b3ef
 
 
 
 
 
 
5cf89a3
674b3ef
5cf89a3
 
 
674b3ef
 
 
5ad56ab
674b3ef
 
 
4b7dfdf
674b3ef
4b7dfdf
674b3ef
 
 
5ad56ab
2f2eb08
b62e12f
02d3c7e
 
 
2f2eb08
b62e12f
674b3ef
2f2eb08
 
b62e12f
 
674b3ef
 
fc85e52
674b3ef
fc85e52
2f2eb08
674b3ef
2f2eb08
674b3ef
2f2eb08
 
 
 
 
fc85e52
2f2eb08
674b3ef
 
 
 
 
 
8f35bfa
 
 
674b3ef
5b8f2b6
b62e12f
d06ee78
 
 
 
5b8f2b6
 
 
 
 
 
 
 
 
d06ee78
 
 
5b8f2b6
d06ee78
 
 
5b8f2b6
d06ee78
 
 
 
 
 
b62e12f
674b3ef
b62e12f
674b3ef
 
 
 
 
 
8f35bfa
b62e12f
21292e2
cf9223c
21292e2
 
 
cf9223c
 
 
 
 
 
 
21292e2
 
 
cf9223c
5ad56ab
674b3ef
 
 
 
 
5ad56ab
674b3ef
 
 
5ad56ab
 
4b7dfdf
674b3ef
d08d597
4b7dfdf
 
674b3ef
 
5ad56ab
4b7dfdf
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
from __future__ import annotations
import json, os, re, time, asyncio
from pathlib import Path
from typing import Any, Dict, List, Optional
from datetime import datetime

# Load .env
_env_file = Path(__file__).resolve().parent.parent / ".env"
if _env_file.exists():
    for _line in _env_file.read_text().splitlines():
        _line = _line.strip()
        if _line and not _line.startswith("#") and "=" in _line:
            _k, _, _v = _line.partition("=")
            os.environ.setdefault(_k.strip(), _v.strip())

from fastapi import FastAPI, HTTPException, UploadFile, File, Body, Header, Request, Response, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
import requests as _requests

_KEY = os.getenv("gemini_api_key", "") or os.getenv("GEMINI_API_KEY", "")
BASE_DIR = Path(__file__).resolve().parent

def _load_patients() -> List[Dict[str, Any]]:
    p = BASE_DIR / "sample_patients.json"
    if p.exists():
        try:
            data = json.loads(p.read_text())
            return data["patients"] if isinstance(data, dict) else data
        except: return []
    return []

PATIENTS: List[Dict[str, Any]] = _load_patients()
app = FastAPI(title="CodeBlue Context API", version="3.1.2")

@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    import traceback
    return JSONResponse(status_code=500, content={"detail": str(exc), "traceback": traceback.format_exc()})

app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])

# ── Gemini helper ─────────────────────────────────────────────────────────────
def _gemini(prompt: str, retries: int = 4, override_key: str = None) -> Optional[Dict]:
    key = (override_key or _KEY).strip()
    if not key: return None
    payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"temperature": 0.1, "maxOutputTokens": 2048}}
    for model in ["gemini-2.5-flash-lite"]:
        for attempt in range(retries):
            try:
                url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={key}"
                resp = _requests.post(url, json=payload, timeout=25)
                if resp.status_code == 200:
                    text = resp.json()["candidates"][0]["content"]["parts"][0]["text"].strip()
                    text = re.sub(r"^```(?:json)?\s*", "", text); text = re.sub(r"\s*```$", "", text)
                    try: return json.loads(text)
                    except:
                        m = re.search(r"\{.*\}", text, re.DOTALL)
                        if m: return json.loads(m.group(0))
                time.sleep(1)
            except: time.sleep(1)
    return None

# ── Agents ────────────────────────────────────────────────────────────────────
def agent_triage(r, k=None):
    p = f"Triage PATIENT: {json.dumps(r)}\nReturn JSON: {{'severity_label':'string','critical_risks':[]}}"
    return _gemini(p, override_key=k) or {"severity_label":"URGENT","critical_risks":[]}

def agent_action(r, t, k=None):
    p = f"Attending. PATIENT: {json.dumps(r)}\nTRIAGE: {json.dumps(t)}\nReturn JSON: {{'primary_directive':'string','clinical_summary':'string'}}"
    return _gemini(p, override_key=k) or {"primary_directive":"Stabilize.","clinical_summary":"Offline."}

async def process_synthesis(text: str, key: str):
    record = {"raw_text": text}
    t = agent_triage(record, k=key)
    a = agent_action(record, t, k=key)
    return {"triage": t, "action": a}

# ── MCP Standard (Optimized Discovery & Handshake) ──────────────────────────

@app.api_route("/mcp", methods=["GET", "POST", "OPTIONS"])
async def mcp_discovery(req: Request):
    if req.method == "OPTIONS": return Response(status_code=204)
    # Discovery should point to a relative path for the SSE stream
    return {
        "transport": "sse",
        "endpoint": "/mcp/sse", 
        "tools": [{"name": "analyze_clinical_record", "description": "Synthesize clinical records."}]
    }

@app.get("/mcp/sse")
async def mcp_sse_stream(req: Request):
    """Handshake with manual CORS and Proxy Fixes"""
    async def event_generator():
        # Immediate endpoint event
        yield f"event: endpoint\ndata: /mcp/invoke\n\n"
        while True:
            await asyncio.sleep(20)
            yield ": keep-alive\n\n"
    
    response = StreamingResponse(event_generator(), media_type="text/event-stream")
    response.headers["Access-Control-Allow-Origin"] = "*"
    response.headers["Cache-Control"] = "no-cache"
    response.headers["Connection"] = "keep-alive"
    response.headers["X-Accel-Buffering"] = "no" # Fix for Hugging Face/Nginx buffering
    return response

@app.post("/mcp/invoke")
async def mcp_invoke(req: Request, x_gemini_key: str = Header(None)):
    try: body = await req.json()
    except: return {"error": "Invalid JSON"}
    
    method = body.get("method")
    params = body.get("params", {})
    msg_id = body.get("id")

    if method == "initialize" or method == "list_tools" or method == "notifications/initialized":
        return {
            "jsonrpc": "2.0", 
            "id": msg_id,
            "result": {
                "protocolVersion": "2024-11-05",
                "capabilities": {
                    "tools": {"listChanged": False},
                    "resources": {"subscribe": False, "listChanged": False},
                    "prompts": {"listChanged": False}
                },
                "serverInfo": {
                    "name": "CodeBlue-Context",
                    "version": "1.0.0"
                },
                "tools": [
                    {
                        "name": "analyze_clinical_record",
                        "description": "Performs a 5-agent clinical synthesis of fragmented patient records to identify life-threatening risks.",
                        "input_schema": {
                            "type": "object",
                            "properties": {
                                "text": {"type": "string", "description": "Raw clinical data or patient history."}
                            },
                            "required": ["text"]
                        }
                    }
                ]
            }
        }
    
    if method == "call_tool":
        args = params.get("arguments", {})
        res = await process_synthesis(args.get("text", ""), x_gemini_key)
        return {
            "jsonrpc": "2.0", "id": msg_id,
            "result": {"content": [{"type": "text", "text": f"DIRECTIVE: {res['action']['primary_directive']}"}]}
        }
    return {"jsonrpc": "2.0", "result": {}, "id": msg_id}

# ── A2A Standard (Strict Prompt Opinion Compliance) ──────────────────

@app.get("/.well-known/agent-card.json")
def get_agent_card():
    """Official Prompt Opinion Discovery Card"""
    return {
        "name": "CodeBlue Context", 
        "version": "1.0.0",
        "description": "ER Clinical Intelligence Synthesis Agent.",
        "capabilities": ["clinical_synthesis"], 
        "endpoints": {
            "invoke": "https://anil988-codeblue-context.hf.space/a2a/invoke"
        },
        "protocols": ["A2A", "MCP"],
        "data_standards": ["FHIR-R4"]
    }

@app.post("/a2a/invoke")
async def a2a_invoke(request: Dict[str, Any], x_gemini_key: str = Header(None)):
    p = request.get("params", {})
    res = await process_synthesis(p.get("text", ""), x_gemini_key)
    return {"jsonrpc": "2.0", "result": {"directive": res["action"]["primary_directive"]}, "id": request.get("id")}

# ── App Endpoints ─────────────────────────────────────────────────────────────
@app.get("/patients")
def get_patients(): return PATIENTS

@app.post("/patients/upload")
async def upload_patient(file: UploadFile = File(...)):
    return {"id": "UP-" + str(int(time.time()) % 1000), "raw_text": (await file.read()).decode()}

@app.post("/analyze")
async def analyze_patient(data: Dict[str, Any], x_gemini_key: str = Header(None)):
    res = await process_synthesis(data.get("raw_text", ""), x_gemini_key)
    return {"triage": res["triage"], "action": res["action"], "contraindication": {"treatment_verdict":"SAFE"}, "labs": {"critical_labs":[]}, "differentials": {"differentials":[]}}

if os.path.exists("frontend/dist"):
    app.mount("/", StaticFiles(directory="frontend/dist", html=True), name="frontend")