joduor's picture
Fix pLDDT: fetch per-residue scores from AlphaFold confidence JSON endpoint (app.py)
53bbc56 verified
Raw
History Blame Contribute Delete
25.3 kB
"""
AlphaFold Adaptive API
FastAPI server for the AlphaFold custom GPT HuggingFace Space.
Endpoints consumed by GPT Actions + interactive HTML UI at /.
"""
from __future__ import annotations
import base64, uuid, os, traceback
from typing import Any
from fastapi import FastAPI, Request, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from adaptive import get_session, build_adaptive_prompt, update_session_protein, generate_followups
from alphafold_client import (
fetch_alphafold_summary, fetch_uniprot_entry,
extract_protein_metadata, extract_plddt_scores, fetch_plddt_scores,
)
from morphic import generate_morphic_image, generate_confidence_heatmap
from models import embed_sequence, predict_disorder, classify_secondary_structure_heuristic
# ── App setup ─────────────────────────────────────────────────────────────────
app = FastAPI(
title="AlphaFold Adaptive API",
description="Adaptive protein intelligence with morphic simulations and ESM-2 embeddings.",
version="1.0.0",
docs_url="/docs",
openapi_url="/openapi.json",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ── Request / Response models ─────────────────────────────────────────────────
class AnalyzeRequest(BaseModel):
uniprot_id: str = Field(..., description="UniProt accession number (e.g. P04637)")
session_id: str = Field(default="", description="Session ID for adaptive context tracking")
query: str = Field(default="", description="User's current question for expertise calibration")
class ContextRequest(BaseModel):
session_id: str = Field(default="", description="Session ID")
query: str = Field(default="", description="Current user query")
class SimulateRequest(BaseModel):
uniprot_id: str = Field(..., description="UniProt accession number")
plddt: float = Field(default=75.0, ge=0, le=100)
length: int = Field(default=300, ge=1)
function: str = Field(default="unknown")
protein_name: str = Field(default="")
# ── Health ────────────────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok", "service": "alphafold-adaptive-api"}
# ── Structure endpoint ────────────────────────────────────────────────────────
@app.get("/structure/{uniprot_id}")
async def get_structure(uniprot_id: str, session_id: str = Query(default="")):
"""Fetch AlphaFold structure summary and UniProt metadata."""
uid = uniprot_id.strip().upper()
try:
af_data, up_data = await _fetch_both(uid)
except Exception as e:
raise HTTPException(status_code=404, detail=f"Could not fetch data for {uid}: {e}")
meta = extract_protein_metadata(up_data)
plddt_scores = await fetch_plddt_scores(af_data)
avg_plddt = sum(plddt_scores) / len(plddt_scores) if plddt_scores else 75.0
# Update session
if session_id:
sess = get_session(session_id)
update_session_protein(sess, uid, meta.get("keywords", []))
return {
"uniprot_id": uid,
"protein_name": meta["protein_name"],
"gene_name": meta["gene_name"],
"organism": meta["organism"],
"length": meta["length"],
"function": meta["function_text"],
"function_category": meta["function_category"],
"keywords": meta["keywords"],
"subcellular_location": meta["subcellular_location"],
"disease_associations": meta["disease_associations"],
"avg_plddt": round(avg_plddt, 2),
"plddt_scores": plddt_scores[:500], # cap for payload size
"model_url": af_data.get("pdbUrl") or af_data.get("cifUrl", ""),
"pae_image_url": af_data.get("paeImageUrl", ""),
"alphafold_model_id": af_data.get("entryId", ""),
}
@app.post("/analyze")
async def analyze_protein(req: AnalyzeRequest):
"""
Full protein analysis: structure + ESM-2 embeddings + disorder prediction
+ adaptive context injection for the custom GPT.
"""
uid = req.uniprot_id.strip().upper()
sid = req.session_id or str(uuid.uuid4())
try:
af_data, up_data = await _fetch_both(uid)
except Exception as e:
raise HTTPException(status_code=404, detail=f"Could not fetch data for {uid}: {e}")
meta = extract_protein_metadata(up_data)
plddt_scores = await fetch_plddt_scores(af_data)
avg_plddt = sum(plddt_scores) / len(plddt_scores) if plddt_scores else 75.0
# ESM-2 embedding + heuristics (run synchronously — CPU only)
seq = meta.get("sequence", "")
embedding = embed_sequence(seq) if seq else []
ss = classify_secondary_structure_heuristic(seq) if seq else {}
disorder = predict_disorder(seq)[:50] if seq else [] # first 50 residues
# Adaptive context
sess = get_session(sid)
update_session_protein(sess, uid, meta.get("keywords", []))
adaptive_context = build_adaptive_prompt(sess, req.query)
followups = generate_followups(sess)
return {
"session_id": sid,
"uniprot_id": uid,
"protein_name": meta["protein_name"],
"gene_name": meta["gene_name"],
"organism": meta["organism"],
"length": meta["length"],
"function": meta["function_text"],
"function_category": meta["function_category"],
"keywords": meta["keywords"],
"subcellular_location": meta["subcellular_location"],
"disease_associations": meta["disease_associations"],
"avg_plddt": round(avg_plddt, 2),
"secondary_structure": ss,
"disorder_n_term": [round(d, 3) for d in disorder],
"embedding_dim": len(embedding),
"model_url": af_data.get("pdbUrl") or af_data.get("cifUrl", ""),
"pae_image_url": af_data.get("paeImageUrl", ""),
"adaptive_context": adaptive_context,
"suggested_followups": followups,
"simulation_url": f"/simulate/image/{uid}?plddt={avg_plddt:.1f}&length={meta['length']}&function={meta['function_category']}&name={meta['protein_name'][:40]}",
"heatmap_url": f"/simulate/heatmap/{uid}",
}
@app.post("/context")
async def get_adaptive_context(req: ContextRequest):
"""Return the current adaptive system prompt context for a session."""
sid = req.session_id or str(uuid.uuid4())
sess = get_session(sid)
prompt = build_adaptive_prompt(sess, req.query)
followups = generate_followups(sess)
return {
"session_id": sid,
"adaptive_context": prompt,
"expertise_score": sess.expertise_score,
"preferred_depth": sess.preferred_depth,
"proteins_visited": sess.proteins_visited,
"interaction_count": sess.interaction_count,
"suggested_followups": followups,
}
# ── Simulation endpoints ──────────────────────────────────────────────────────
@app.post("/simulate")
async def simulate_json(req: SimulateRequest):
"""Return morphic simulation as base64-encoded PNG in JSON."""
png = generate_morphic_image(
uniprot_id=req.uniprot_id,
plddt=req.plddt,
length=req.length,
function=req.function,
protein_name=req.protein_name,
)
return {
"uniprot_id": req.uniprot_id,
"image_b64": base64.b64encode(png).decode(),
"content_type": "image/png",
"description": (
f"Gray-Scott reaction-diffusion morphic simulation for {req.uniprot_id}. "
f"Pattern self-assembled from: pLDDT={req.plddt:.1f}, "
f"length={req.length}aa, function={req.function}."
),
}
@app.get("/simulate/image/{uniprot_id}")
async def simulate_image(
uniprot_id: str,
plddt: float = Query(default=75.0),
length: int = Query(default=300),
function: str = Query(default="unknown"),
name: str = Query(default=""),
):
"""Return morphic simulation PNG directly (for img src use)."""
png = generate_morphic_image(
uniprot_id=uniprot_id,
plddt=plddt,
length=length,
function=function,
protein_name=name,
)
return Response(content=png, media_type="image/png")
@app.get("/simulate/heatmap/{uniprot_id}")
async def simulate_heatmap(uniprot_id: str):
"""Fetch live pLDDT scores and return per-residue heatmap PNG."""
uid = uniprot_id.strip().upper()
try:
af_data, up_data = await _fetch_both(uid)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
meta = extract_protein_metadata(up_data)
scores = await fetch_plddt_scores(af_data)
if len(scores) <= 1:
scores = [scores[0]] * meta["length"]
png = generate_confidence_heatmap(
residue_scores=scores,
uniprot_id=uid,
protein_name=meta["protein_name"],
)
return Response(content=png, media_type="image/png")
# ── Interactive UI ────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def ui():
return HTMLResponse(content=_HTML_UI)
# ── Helper ────────────────────────────────────────────────────────────────────
async def _fetch_both(uid: str):
import asyncio
af_task = fetch_alphafold_summary(uid)
up_task = fetch_uniprot_entry(uid)
af_data, up_data = await asyncio.gather(af_task, up_task)
return af_data, up_data
# ── Embedded HTML UI ─────────────────────────────────────────────────────────
_HTML_UI = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AlphaFold Adaptive API</title>
<style>
:root {
--bg: #020817;
--surface: #0f1729;
--border: #1e3a5f;
--accent: #00f5ff;
--accent2: #c084fc;
--text: #e2e8f0;
--muted: #64748b;
--green: #22c55e;
--amber: #fbbf24;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: 'Inter', system-ui, sans-serif; min-height: 100vh; }
#morphic-canvas { position: fixed; inset: 0; z-index: 0; opacity: 0.18; pointer-events: none; }
.wrapper { position: relative; z-index: 1; max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
h1 { font-size: 2.2rem; font-weight: 900; letter-spacing: -0.03em;
background: linear-gradient(135deg, var(--accent), var(--accent2)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.subtitle { color: var(--muted); margin-top: .4rem; font-size: .95rem; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; margin-top: 1.5rem; }
label { font-size: .8rem; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; display: block; margin-bottom: .4rem; }
.input-row { display: flex; gap: .75rem; }
input[type=text] { flex: 1; background: #0a1220; border: 1px solid var(--border); border-radius: 8px;
color: var(--text); padding: .75rem 1rem; font-size: 1rem; font-family: monospace; outline: none; }
input[type=text]:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(0,245,255,.12); }
button { background: var(--accent); color: #020817; border: none; border-radius: 8px; padding: .75rem 1.5rem;
font-weight: 700; cursor: pointer; font-size: .95rem; transition: opacity .15s; }
button:hover { opacity: .85; }
button:disabled { opacity: .4; cursor: not-allowed; }
.badge { display: inline-block; border-radius: 6px; padding: .2rem .65rem; font-size: .78rem; font-weight: 600; }
.badge-cyan { background: rgba(0,245,255,.12); color: var(--accent); border: 1px solid rgba(0,245,255,.25); }
.badge-green { background: rgba(34,197,94,.12); color: var(--green); border: 1px solid rgba(34,197,94,.25); }
.badge-amber { background: rgba(251,191,36,.12); color: var(--amber); border: 1px solid rgba(251,191,36,.25); }
.badge-purple { background: rgba(192,132,252,.12); color: var(--accent2); border: 1px solid rgba(192,132,252,.25); }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; margin-top: 1rem; }
.meta-item { background: #0a1220; border: 1px solid var(--border); border-radius: 8px; padding: .75rem 1rem; }
.meta-label { font-size: .72rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
.meta-value { font-size: .95rem; margin-top: .2rem; color: var(--text); }
.plddt-bar { height: 8px; border-radius: 4px; margin-top: .5rem; background: linear-gradient(90deg, #f97316, #eab308, #22c55e, #1d4ed8); }
.morphic-img { width: 100%; border-radius: 10px; margin-top: 1rem; border: 1px solid var(--border); }
.heatmap-img { width: 100%; border-radius: 8px; margin-top: .75rem; }
.followups { list-style: none; }
.followups li { padding: .5rem .75rem; border-radius: 8px; cursor: pointer; color: var(--accent);
font-size: .88rem; transition: background .12s; border: 1px solid transparent; margin-bottom: .3rem; }
.followups li:hover { background: rgba(0,245,255,.06); border-color: rgba(0,245,255,.2); }
.function-text { font-size: .88rem; color: #94a3b8; line-height: 1.6; margin-top: .5rem; }
#status { color: var(--muted); font-size: .85rem; margin-top: .5rem; min-height: 1.2em; }
.kw-list { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .5rem; }
.section-title { font-size: .78rem; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; margin-bottom: .5rem; }
.expertise-bar-wrap { display: flex; align-items: center; gap: .75rem; margin-top: .3rem; }
.expertise-bar { flex: 1; height: 6px; background: #1e3a5f; border-radius: 3px; overflow: hidden; }
.expertise-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); border-radius: 3px; transition: width .4s ease; }
.link-btn { display: inline-block; background: transparent; border: 1px solid var(--border); color: var(--text);
padding: .45rem 1rem; border-radius: 7px; font-size: .82rem; text-decoration: none; margin-right: .4rem; margin-top: .4rem; }
.link-btn:hover { border-color: var(--accent); color: var(--accent); }
</style>
</head>
<body>
<canvas id="morphic-canvas"></canvas>
<div class="wrapper">
<h1>AlphaFold Adaptive API</h1>
<p class="subtitle">Adaptive intelligence · Morphic simulations · ESM-2 embeddings · Custom GPT integration</p>
<div class="card">
<label>UniProt Accession ID</label>
<div class="input-row">
<input id="uid-input" type="text" placeholder="e.g. P04637 or Q9Y5M8" value="P04637" />
<button id="analyze-btn" onclick="analyze()">Analyze</button>
</div>
<div id="status">Enter a UniProt ID and click Analyze.</div>
</div>
<div id="results" style="display:none">
<!-- Protein header -->
<div class="card" id="protein-header"></div>
<!-- Morphic simulation -->
<div class="card">
<div class="section-title">Morphic Simulation — Gray-Scott Self-Assembly</div>
<img id="morphic-img" class="morphic-img" />
<p style="font-size:.78rem;color:var(--muted);margin-top:.5rem">
Pattern uniquely self-assembled from this protein's pLDDT confidence, chain length, and functional class.
</p>
</div>
<!-- Confidence heatmap -->
<div class="card">
<div class="section-title">Per-Residue pLDDT Confidence Heatmap</div>
<img id="heatmap-img" class="heatmap-img" />
</div>
<!-- Adaptive context -->
<div class="card" id="adaptive-card"></div>
<!-- Suggested follow-ups -->
<div class="card" id="followup-card"></div>
</div>
<div class="card" style="margin-top:1.5rem">
<div class="section-title">API Endpoints</div>
<div style="margin-top:.5rem">
<a class="link-btn" href="/docs" target="_blank">Swagger UI</a>
<a class="link-btn" href="/openapi.json" target="_blank">OpenAPI JSON</a>
<a class="link-btn" href="/health" target="_blank">Health</a>
</div>
<p style="font-size:.78rem;color:var(--muted);margin-top:.75rem">
POST /analyze · GET /structure/{id} · POST /simulate · GET /simulate/image/{id} · GET /simulate/heatmap/{id} · POST /context
</p>
</div>
</div>
<script>
// ── Background morphic animation ──────────────────────────────────────────────
const canvas = document.getElementById('morphic-canvas');
const ctx = canvas.getContext('2d');
let W, H, blobs = [];
function resize() { W = canvas.width = innerWidth; H = canvas.height = innerHeight; }
resize(); window.addEventListener('resize', resize);
function mkBlob() {
return { x: Math.random()*W, y: Math.random()*H, r: 60+Math.random()*120,
vx: (Math.random()-.5)*.4, vy: (Math.random()-.5)*.4,
color: Math.random() > .5 ? '0,245,255' : '192,132,252', phase: Math.random()*Math.PI*2 };
}
for (let i=0; i<18; i++) blobs.push(mkBlob());
let t=0;
function frame() {
ctx.clearRect(0,0,W,H);
t++;
for (const b of blobs) {
b.x += b.vx + Math.sin(t*0.009+b.phase)*0.3;
b.y += b.vy + Math.cos(t*0.007+b.phase)*0.3;
if (b.x < -b.r) b.x = W+b.r;
if (b.x > W+b.r) b.x = -b.r;
if (b.y < -b.r) b.y = H+b.r;
if (b.y > H+b.r) b.y = -b.r;
const g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r);
g.addColorStop(0, `rgba(${b.color},0.22)`);
g.addColorStop(1, `rgba(${b.color},0)`);
ctx.fillStyle = g; ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI*2); ctx.fill();
}
requestAnimationFrame(frame);
}
frame();
// ── Analyze ───────────────────────────────────────────────────────────────────
let sessionId = '';
async function analyze() {
const uid = document.getElementById('uid-input').value.trim().toUpperCase();
if (!uid) return;
const btn = document.getElementById('analyze-btn');
btn.disabled = true;
setStatus('Fetching protein data…');
try {
const r = await fetch('/analyze', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ uniprot_id: uid, session_id: sessionId, query: '' })
});
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
sessionId = d.session_id;
renderResults(d, uid);
setStatus('');
} catch(e) {
setStatus('Error: ' + e.message);
console.error(e);
} finally {
btn.disabled = false;
}
}
function renderResults(d, uid) {
document.getElementById('results').style.display = 'block';
// Header card
const plddt = d.avg_plddt;
const plddtColor = plddt >= 90 ? '#1d4ed8' : plddt >= 70 ? '#22c55e' : plddt >= 50 ? '#eab308' : '#f97316';
const plddtLabel = plddt >= 90 ? 'Very High' : plddt >= 70 ? 'Confident' : plddt >= 50 ? 'Low' : 'Very Low';
const ss = d.secondary_structure || {};
document.getElementById('protein-header').innerHTML = `
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap">
<div>
<div style="font-size:1.4rem;font-weight:800;color:#e2e8f0">${d.protein_name || uid}</div>
<div style="color:var(--muted);font-size:.9rem;margin-top:.25rem">
${d.gene_name ? d.gene_name + ' · ' : ''}${d.organism || ''}
</div>
</div>
<span class="badge badge-cyan">${uid}</span>
</div>
<div class="meta-grid" style="margin-top:1rem">
<div class="meta-item">
<div class="meta-label">pLDDT Confidence</div>
<div class="meta-value" style="color:${plddtColor};font-size:1.3rem;font-weight:700">${plddt.toFixed(1)} <span style="font-size:.75rem;font-weight:400">${plddtLabel}</span></div>
<div class="plddt-bar" style="width:${plddt}%"></div>
</div>
<div class="meta-item">
<div class="meta-label">Chain Length</div>
<div class="meta-value" style="font-size:1.3rem;font-weight:700">${d.length} <span style="font-size:.75rem;font-weight:400">residues</span></div>
</div>
<div class="meta-item">
<div class="meta-label">Function Class</div>
<div class="meta-value"><span class="badge badge-purple">${d.function_category}</span></div>
</div>
<div class="meta-item">
<div class="meta-label">Location</div>
<div class="meta-value" style="font-size:.9rem">${d.subcellular_location || '—'}</div>
</div>
${ss.helix_fraction !== undefined ? `
<div class="meta-item" style="grid-column:1/-1">
<div class="meta-label">Secondary Structure (heuristic)</div>
<div style="display:flex;gap:1rem;margin-top:.35rem">
<span class="badge badge-cyan">α-Helix ${(ss.helix_fraction*100).toFixed(0)}%</span>
<span class="badge badge-amber">β-Sheet ${(ss.sheet_fraction*100).toFixed(0)}%</span>
<span class="badge" style="background:rgba(148,163,184,.1);color:#94a3b8;border:1px solid rgba(148,163,184,.2)">Coil ${(ss.coil_fraction*100).toFixed(0)}%</span>
</div>
</div>` : ''}
</div>
${d.function ? `<div class="function-text">${d.function.slice(0, 350)}${d.function.length > 350 ? '…' : ''}</div>` : ''}
${d.keywords.length ? `<div class="kw-list">${d.keywords.map(k=>`<span class="badge badge-cyan" style="font-size:.72rem">${k}</span>`).join('')}</div>` : ''}
${d.disease_associations.length ? `<div style="margin-top:.75rem"><span class="badge badge-amber">⚠ Disease associations:</span> <span style="font-size:.85rem;color:#94a3b8;margin-left:.5rem">${d.disease_associations.join(', ')}</span></div>` : ''}
${d.model_url ? `<div style="margin-top:.75rem"><a class="link-btn" href="${d.model_url}" target="_blank">Download PDB/CIF</a>${d.pae_image_url ? `<a class="link-btn" href="${d.pae_image_url}" target="_blank">PAE Image</a>` : ''}</div>` : ''}
`;
// Morphic simulation
document.getElementById('morphic-img').src =
`/simulate/image/${uid}?plddt=${plddt}&length=${d.length}&function=${d.function_category}&name=${encodeURIComponent((d.protein_name||'').slice(0,40))}`;
// Heatmap
document.getElementById('heatmap-img').src = `/simulate/heatmap/${uid}`;
// Adaptive context
document.getElementById('adaptive-card').innerHTML = `
<div class="section-title">Adaptive Intelligence Context</div>
<div class="expertise-bar-wrap">
<span style="font-size:.8rem;color:var(--muted)">Expertise</span>
<div class="expertise-bar"><div class="expertise-fill" id="exp-fill"></div></div>
<span id="exp-label" style="font-size:.82rem;color:var(--accent)"></span>
</div>
<pre style="background:#0a1220;border:1px solid var(--border);border-radius:8px;padding:.9rem;font-size:.75rem;color:#94a3b8;white-space:pre-wrap;margin-top:.75rem;overflow-x:auto">${d.adaptive_context}</pre>
<p style="font-size:.78rem;color:var(--muted);margin-top:.5rem">Embedding dimension: ${d.embedding_dim} (ESM-2 protein language model)</p>
`;
// Will be filled after context fetch
// Follow-ups
document.getElementById('followup-card').innerHTML = `
<div class="section-title">Suggested Follow-up Questions</div>
<ul class="followups">${d.suggested_followups.map(q=>`<li onclick="setQuery('${q.replace(/'/g,"\\'")}')">${q}</li>`).join('')}</ul>
`;
// Fetch adaptive context for expertise bar
fetch('/context', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ session_id: sessionId, query: '' })
}).then(r=>r.json()).then(c => {
const pct = Math.round(c.expertise_score * 100);
const fill = document.getElementById('exp-fill');
const label = document.getElementById('exp-label');
if (fill) fill.style.width = pct + '%';
if (label) label.textContent = c.preferred_depth.toUpperCase() + ' (' + pct + '%)';
});
}
function setQuery(q) {
document.getElementById('uid-input').value = document.getElementById('uid-input').value;
// Show query in status
setStatus('Selected: ' + q);
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
document.getElementById('uid-input').addEventListener('keydown', e => {
if (e.key === 'Enter') analyze();
});
</script>
</body>
</html>
"""