Build a Protein Function Prediction API
Wrap Kiharalab PFP into a FastAPI server with GO term predictions.
Introduction
30 Second Summary
Every protein in your body has a job. Figuring out what that job is from just a string of amino acids is one of the core challenges in bioinformatics.
In this project, you will build a web server that takes a protein sequence and predicts its biological function using Kiharalab's PFP prediction suite. You will create both a REST API and a web interface that display Gene Ontology predictions organized by category, complete with confidence scores.
What You'll Build
A FastAPI-powered protein function prediction server that accepts FASTA sequences through a web form or a REST API, validates them, submits them to Kiharalab's PFP backend, and displays GO term predictions with confidence scores.
By the end of this project, you'll have:
- A REST API with a /api/predict endpoint that accepts protein sequences as JSON and returns structured GO term predictions.
- A web interface where users paste a FASTA sequence and see color-coded results organized by Molecular Function, Biological Process, and Cellular Component.
- A PFP client module with FASTA validation, mock predictions for development, and integration scaffolding for the Kiharalab PFP server.
- Secret Mission: Add multi-method comparison to run the same sequence through both PFP and Phylo-PFP, displaying results side by side.
Are there any prerequisites?
You should have basic Python experience and familiarity with HTTP concepts like GET and POST requests. A basic understanding of protein sequences and Gene Ontology is helpful but not required.
Set Up Your Project
Predicting what a protein does from its amino acid sequence is one of the core challenges in bioinformatics. To make that prediction accessible through code, you need a server that can accept a sequence, validate it, send it to a prediction engine, and return structured results.
In this step, you will create a project directory, install the Python packages your server needs, and set up the starter files. By the end, you will have a running FastAPI server you can visit in your browser.
In this step, get ready to:
- Create a project directory with a Python virtual environment.
- Install FastAPI, uvicorn, httpx, Jinja2, and python-multipart.
- Set up the starter files and verify the server runs.
Create the project directory and install dependencies
Your server needs its own directory and an isolated Python environment. A virtual environment keeps this project's packages separate from your system Python so nothing conflicts.
First, confirm that Python 3.11 or higher is installed.
- Check your Python version by running this command in your terminal:
python3 --version
✔️ I see Python 3.11 or higher
You are good to go.
ⓧ Command not found or version is too old
You need Python 3.11 or higher for this project.
- Download the latest Python installer from python.org/downloads.
- Run the installer and follow the prompts.
- Close and reopen your terminal, then run python3 --version again to confirm.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir protein-function-predictor
cd protein-function-predictor
- Create a Python virtual environment by running:
python3 -m venv venv
- Activate the virtual environment by running:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
Make sure you ran the activate command for your operating system. On macOS, the command starts with source. On Windows, it uses a backslash path.
If you see a permissions error on Windows, try running your terminal as Administrator.
Help me activate my Python virtual environment
Now you need a requirements.txt file that pins the exact package versions your project uses. This file makes your project reproducible on any machine.
- Create requirements.txt and add the dependencies by running this command:
cat > requirements.txt << 'EOF'
fastapi==0.136.1
uvicorn==0.34.0
httpx==0.28.1
jinja2==3.1.6
python-multipart==0.0.20
EOF
What are these packages?
- fastapi is the web framework that handles routing, request validation, and automatic API documentation.
- uvicorn is an ASGI server that runs your FastAPI app and serves incoming requests.
- httpx is an async HTTP client you will use to talk to the Kiharalab PFP prediction server.
- jinja2 is a templating engine for rendering HTML pages with dynamic data.
- python-multipart enables FastAPI to handle form submissions from the web interface.
- Install all five packages by running:
pip install -r requirements.txt
You should see output ending with Successfully installed followed by a list of package names and versions.
Seeing a pip error?
Make sure your virtual environment is active. You should see (venv) in your terminal prompt. If not, run the activate command again.
If you see a No such file or directory error, make sure you are inside the protein-function-predictor folder where you created requirements.txt.
Help me fix a pip install error in my virtual environment
Create the project files
Your project needs three files and one directory. main.py holds the server. pfp_client.py will handle sequence validation and prediction logic. The templates/ directory will hold Jinja2 HTML templates for the web interface.
- Create the files and the templates/ directory by running these commands:
touch main.py pfp_client.py
mkdir templates
- Confirm the files exist by running:
ls
You should see main.py, pfp_client.py, requirements.txt, templates/, and venv/ in the output.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the protein-function-predictor folder, then click Open.
You should see main.py, pfp_client.py, requirements.txt, and the templates folder in the Explorer sidebar on the left.
The minimal FastAPI app needs a single route so you can confirm the server starts correctly. You will replace this starter code in later steps.
- Open main.py in the Explorer sidebar and add the following code:
from fastapi import FastAPI
# Create the FastAPI application instance
app = FastAPI(
title="Protein Function Predictor",
description="Predict protein function from sequence using Kiharalab PFP",
version="1.0.0",
)
# Temporary home route to verify the server starts
@app.get("/")
async def home():
return {"message": "Protein Function Predictor is running"}
What does this code do?
- FastAPI() creates the application object. The title and description fields appear on the auto-generated API documentation page.
- @app.get("/") registers a route at the root URL. When someone visits /, the server returns a simple JSON message confirming it is alive.
- Save main.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Now add the placeholder comment to pfp_client.py so it is ready for later steps.
- Open pfp_client.py in the Explorer sidebar and add this single line:
# PFP client module - to be implemented
- Save pfp_client.py.
✔️ Awesome, I've got everything!
Make sure you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.py at this point:
from fastapi import FastAPI
app = FastAPI(
title="Protein Function Predictor",
description="Predict protein function from sequence using Kiharalab PFP",
version="1.0.0",
)
@app.get("/")
async def home():
return {"message": "Protein Function Predictor is running"}
Here is the complete pfp_client.py at this point:
# PFP client module - to be implemented
Here is the complete requirements.txt:
fastapi==0.136.1
uvicorn==0.34.0
httpx==0.28.1
jinja2==3.1.6
python-multipart==0.0.20
Verify the server runs
FastAPI apps need an ASGI server to handle incoming requests. uvicorn is that server. The --reload flag tells uvicorn to restart automatically whenever you save a file, which speeds up development.
- Start the server by running this command in your terminal:
uvicorn main:app --reload
You should see output that includes Uvicorn running on http://127.0.0.1:8000.
What does main:app mean?
main refers to the file main.py. app refers to the FastAPI instance you created inside that file. Uvicorn uses this pattern to locate your application.
- Open your browser and go to http://localhost:8000.
You should see a JSON response: {"message": "Protein Function Predictor is running"}.
- Now visit http://localhost:8000/docs in your browser.
You should see the Swagger UI page with the title "Protein Function Predictor" and your GET / endpoint listed. FastAPI generates this interactive API documentation automatically from your code.
Page not loading?
Check your terminal for error messages. A ModuleNotFoundError means your virtual environment is not active. Run the activate command again and retry.
If the terminal shows Address already in use, another process is using port 8000. Stop it or run uvicorn with --port 8001 instead.
Help me fix my FastAPI server not starting
- Go back to your terminal and press Ctrl+C to stop the server.
Your project skeleton is ready and the server starts cleanly. Next up, you will build the FASTA sequence validator and the data models that define what a prediction request and response look like.
Build the FASTA Validator and Data Models
Your project environment is set up with FastAPI, and your server boots cleanly at http://localhost:8000. The next step is giving your server the ability to understand protein sequences.
Protein sequences arrive in FASTA format, the standard text format across bioinformatics. But raw user input can contain all sorts of errors. What if someone forgets the header line? What if they paste DNA nucleotides instead of amino acids? In this step, you'll build a validator that catches these problems before they reach your prediction engine.
In this step, get ready to:
- Define the valid amino acid character set for protein sequences.
- Build a FASTA sequence validator with error handling.
- Test the validator with valid and invalid inputs from the Python REPL.
Map out your data structures
Before writing any code, it helps to understand the two key data formats your project works with.
What is FASTA format?
A FASTA entry starts with a header line beginning with >, followed by one or more lines of amino acid sequence. The header identifies the protein (e.g. >sp|P0CG48|UBC_HUMAN Ubiquitin), and the sequence uses single-letter codes like M for methionine and K for lysine.
Your validator will check three things: the header exists, the sequence uses valid amino acid characters, and the sequence is long enough (at least 10 residues).
The predictions that come back from the PFP server use Gene Ontology (GO) terms to describe protein function.
What are GO term categories?
GO terms describe protein function in three categories. Molecular Function (MF) describes what a protein does at the molecular level. Biological Process (BP) describes the larger biological activity. Cellular Component (CC) describes where in the cell the protein operates.
Each GO term comes with a confidence score between 0 and 1. You'll build Pydantic models for these response structures when you create the API routes in a later step.
Write the FASTA validator
The validator will parse raw text, enforce FASTA formatting rules, and return a clean dictionary with the header, sequence, and length.
- Open pfp_client.py in your editor.
- Replace the placeholder comment with the following code:
VALID_AMINO_ACIDS = set("ACDEFGHIKLMNPQRSTVWXY*")
def validate_fasta(text: str) -> dict:
"""Parse and validate a FASTA formatted protein sequence."""
lines = text.strip().split("\n")
# Check that the first line is a valid FASTA header
if not lines[0].startswith(">"):
raise ValueError("FASTA sequence must start with a > header line")
header = lines[0][1:].strip()
if not header:
raise ValueError("FASTA header line cannot be empty after >")
# Join all non-header lines into a single sequence string
seq_lines = [
line.strip() for line in lines[1:]
if line.strip() and not line.startswith(">")
]
sequence = "".join(seq_lines).upper()
# Enforce a minimum length of 10 residues
if len(sequence) < 10:
raise ValueError(
f"Sequence must be at least 10 residues (got {len(sequence)})"
)
# Check for characters outside the valid amino acid set
invalid_chars = set(sequence) - VALID_AMINO_ACIDS
if invalid_chars:
raise ValueError(
f"Invalid characters found: {', '.join(sorted(invalid_chars))}"
)
return {"header": header, "sequence": sequence, "length": len(sequence)}
What does this code do?
- VALID_AMINO_ACIDS defines the 20 standard amino acids plus X (unknown residue) and * (stop codon). Using a set makes character lookups fast.
- The function splits the input into lines and checks that the first line starts with >. This is the FASTA header that identifies the protein.
- All non-header lines get joined into a single uppercase sequence string. This handles multi-line FASTA entries.
- Two final checks enforce that the sequence is at least 10 residues long and contains only valid amino acid characters.
- Save pfp_client.py.
Seeing a syntax error?
- Check that the f-string lines use the f"..." prefix. Missing the f turns the curly braces into literal characters.
- Make sure the list comprehension inside seq_lines has matching square brackets. The opening [ and closing ] span multiple lines.
- Verify you're using Python 3.11 or later. The list[dict] type hint syntax requires Python 3.9+.
Help me debug a syntax error in my validate_fasta function
✔️ Awesome, I've got everything!
Make sure you've saved pfp_client.py before moving on.
ⓧ I'd like to double check the full code
VALID_AMINO_ACIDS = set("ACDEFGHIKLMNPQRSTVWXY*")
def validate_fasta(text: str) -> dict:
"""Parse and validate a FASTA formatted protein sequence."""
lines = text.strip().split("\n")
if not lines[0].startswith(">"):
raise ValueError("FASTA sequence must start with a > header line")
header = lines[0][1:].strip()
if not header:
raise ValueError("FASTA header line cannot be empty after >")
seq_lines = [
line.strip() for line in lines[1:]
if line.strip() and not line.startswith(">")
]
sequence = "".join(seq_lines).upper()
if len(sequence) < 10:
raise ValueError(
f"Sequence must be at least 10 residues (got {len(sequence)})"
)
invalid_chars = set(sequence) - VALID_AMINO_ACIDS
if invalid_chars:
raise ValueError(
f"Invalid characters found: {', '.join(sorted(invalid_chars))}"
)
return {"header": header, "sequence": sequence, "length": len(sequence)}
Test the validator in the Python REPL
The best way to confirm your validator works is to throw real sequences at it. You'll use human ubiquitin, a well-characterized 76-residue protein, as a valid test case.
- Open a Python REPL from your project directory by running:
python3
- Test with a valid ubiquitin sequence by running this code in the REPL:
from pfp_client import validate_fasta
# Test with human ubiquitin (76 residues)
result = validate_fasta(">sp|P0CG48|UBC_HUMAN Ubiquitin\nMQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG")
print(result)
You should see a dictionary with three keys: header, sequence, and length. The length should be 76.
Now throw some bad input at it to confirm the error handling works.
- Test a sequence with a missing FASTA header by running:
# Missing > header line
validate_fasta("MQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPP")
You should see a ValueError with the message FASTA sequence must start with a > header line.
- Test a sequence that is too short by running:
# Only 4 residues (minimum is 10)
validate_fasta(">test\nMQIF")
You should see Sequence must be at least 10 residues (got 4).
- Test a sequence with invalid characters by running:
# Contains numeric characters 1, 2, 3
validate_fasta(">test\nMQIFVKTLTG123")
You should see Invalid characters found: 1, 2, 3.
- Exit the REPL by running:
exit()
Getting a ModuleNotFoundError?
- Make sure you're running python3 from inside your project directory (the folder that contains pfp_client.py).
- Check that your virtual environment is activated. You should see the environment name in parentheses at the start of your terminal prompt.
- Confirm the file is named exactly pfp_client.py (not pfp-client.py or pfpclient.py).
Help me fix a ModuleNotFoundError when importing pfp_client
Your validator catches bad input before it ever reaches the prediction server. Next up, you'll build the PFP client that submits valid sequences to the Kiharalab prediction backend and retrieves GO term results.
Create the PFP Client with Mock Predictions
Your FASTA validator can catch bad sequences before they waste compute time. Now it's time to build the client that actually talks to the prediction server.
In this step, you'll create the PFP client module with mock Gene Ontology predictions for local development and async functions that communicate with the Kiharalab PFP server. Mock mode means your app always works, even when the external server is slow or offline.
In this step, get ready to:
- Add mock GO term predictions for offline development.
- Discover the Kiharalab PFP server's API endpoints.
- Build async functions to submit predictions and poll for results.
Add imports and mock prediction data
External bioinformatics servers like Kiharalab PFP can take 40 seconds or more to return results. They can also go offline without warning. A mock mode that returns realistic data lets you develop and demo your app without waiting or worrying about server availability.
- Open pfp_client.py and add these imports and mock data at the very top of the file, above the existing VALID_AMINO_ACIDS line:
import httpx
import asyncio
import time
# Realistic mock GO term predictions for development and demos
MOCK_RESULTS = {
"pfp": [
{"go_id": "GO:0005515", "name": "protein binding", "category": "MF", "confidence": 0.95},
{"go_id": "GO:0005524", "name": "ATP binding", "category": "MF", "confidence": 0.79},
{"go_id": "GO:0004842", "name": "ubiquitin-protein transferase activity", "category": "MF", "confidence": 0.72},
{"go_id": "GO:0006468", "name": "protein phosphorylation", "category": "BP", "confidence": 0.82},
{"go_id": "GO:0007165", "name": "signal transduction", "category": "BP", "confidence": 0.71},
{"go_id": "GO:0006511", "name": "ubiquitin-dependent protein catabolic process", "category": "BP", "confidence": 0.68},
{"go_id": "GO:0005737", "name": "cytoplasm", "category": "CC", "confidence": 0.88},
{"go_id": "GO:0005634", "name": "nucleus", "category": "CC", "confidence": 0.75},
{"go_id": "GO:0005829", "name": "cytosol", "category": "CC", "confidence": 0.70},
],
"phylo-pfp": [
{"go_id": "GO:0005515", "name": "protein binding", "category": "MF", "confidence": 0.97},
{"go_id": "GO:0004842", "name": "ubiquitin-protein transferase activity", "category": "MF", "confidence": 0.85},
{"go_id": "GO:0019787", "name": "ubiquitin-like protein transferase activity", "category": "MF", "confidence": 0.63},
{"go_id": "GO:0006511", "name": "ubiquitin-dependent protein catabolic process", "category": "BP", "confidence": 0.90},
{"go_id": "GO:0016567", "name": "protein ubiquitination", "category": "BP", "confidence": 0.78},
{"go_id": "GO:0030163", "name": "protein catabolic process", "category": "BP", "confidence": 0.60},
{"go_id": "GO:0005737", "name": "cytoplasm", "category": "CC", "confidence": 0.91},
{"go_id": "GO:0005634", "name": "nucleus", "category": "CC", "confidence": 0.80},
{"go_id": "GO:0000151", "name": "ubiquitin ligase complex", "category": "CC", "confidence": 0.55},
],
}
What does this code do?
The three imports bring in the tools this module needs. httpx is an async HTTP client for calling external servers. asyncio provides the async sleep used for polling delays. time generates timestamps for mock job IDs.
MOCK_RESULTS is a dictionary with two keys: "pfp" and "phylo-pfp". Each key maps to a list of GO term dictionaries with fields for the GO ID, term name, ontology category (MF, BP, or CC), and a confidence score between 0 and 1. These mirror realistic predictions for a protein like ubiquitin.
- Save pfp_client.py.
You won't see a visible change yet. The next chunk adds the server configuration that ties this mock data into the prediction workflow.
Seeing a syntax error on save?
The MOCK_RESULTS dictionary has deeply nested braces and brackets. Check that every opening { has a matching } and every opening [ has a matching ]. Also make sure this new code sits above the existing VALID_AMINO_ACIDS line, not below it.
Help me fix a syntax error in my MOCK_RESULTS dictionary in pfp_client.py.
Discover the PFP server endpoints
The Kiharalab PFP server at dragon.bio.purdue.edu/pfp is a single-page application. Its API endpoints are not publicly documented, so you'll use your browser's DevTools to find them. This is a common technique when integrating with web services that lack formal API docs.
- Open http://dragon.bio.purdue.edu/pfp in Chrome or Firefox.
- Navigate to the predict page.
- Open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Network tab.
- Submit a test sequence in the PFP form and watch for POST/GET requests in the Network tab.
- Note any endpoint paths you find. These are the URLs the frontend uses to submit jobs and check status.
What if I can't find the endpoints?
The PFP server is a JavaScript-rendered single-page app, so the exact API paths may be hard to discover. The constants below use placeholder values based on common REST API patterns. Since USE_MOCK defaults to True, your app works perfectly with mock data regardless.
If you do discover the real endpoints, update the constants to match. If not, the placeholder paths are fine for development.
- In pfp_client.py, find the blank lines between your imports and the # Realistic mock GO term predictions comment. Add this server configuration block there:
# ============================================================
# PFP Server Configuration
#
# The Kiharalab PFP server at dragon.bio.purdue.edu/pfp is a
# single-page app. To discover the API endpoints:
# 1. Open http://dragon.bio.purdue.edu/pfp/predict in Chrome
# 2. Open DevTools > Network tab
# 3. Submit a test sequence
# 4. Look for POST/GET requests to identify endpoints
# 5. Update the constants below with the discovered URLs
# ============================================================
PFP_BASE_URL = "http://dragon.bio.purdue.edu"
SUBMIT_ENDPOINT = "/pfp/api/predict"
STATUS_ENDPOINT = "/pfp/api/jobs"
# Set to False after discovering real API endpoints
USE_MOCK = True
What does this code do?
- PFP_BASE_URL points to the Kiharalab server hosted at Purdue University.
- SUBMIT_ENDPOINT and STATUS_ENDPOINT are the paths your client will call to submit a job and check its progress. These are placeholder values you can update if you discover the real paths.
- USE_MOCK = True tells the client to return mock data instead of calling the live server. Flip this to False when you want to hit the real PFP backend.
- Save pfp_client.py.
You won't see a change yet. The next substep builds the async functions that use these constants and mock data.
Build the async prediction functions
With the configuration and mock data in place, you can now build the two async functions that drive the prediction workflow. One function submits a sequence for prediction. The other polls for results until the job completes, fails, or times out.
- In pfp_client.py, scroll to the bottom of the file (below the validate_fasta() function). Add the submit_prediction() function:
async def submit_prediction(
fasta_text: str, method: str = "pfp"
) -> dict:
"""Submit a protein sequence for function prediction."""
if USE_MOCK:
return {"job_id": f"mock-{int(time.time())}", "status": "queued"}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{PFP_BASE_URL}{SUBMIT_ENDPOINT}",
json={"sequence": fasta_text, "tool": method},
)
response.raise_for_status()
return response.json()
What does this function do?
- The function checks USE_MOCK first. In mock mode, it immediately returns a fake job ID using the current timestamp. No network call happens.
- In live mode, it creates an httpx async client with a 60-second timeout and POSTs the sequence to the PFP server. raise_for_status() throws an exception if the server returns an error code.
- Save pfp_client.py.
You won't see a change yet. The next function handles polling for results, which completes the prediction workflow.
- Below submit_prediction(), add the poll_results() function:
async def poll_results(
job_id: str, method: str = "pfp", max_wait: int = 120, interval: int = 5
) -> list[dict]:
"""Poll the PFP server until the prediction completes or times out."""
if USE_MOCK:
await asyncio.sleep(1)
return MOCK_RESULTS.get(method, MOCK_RESULTS["pfp"])
elapsed = 0
async with httpx.AsyncClient(timeout=30.0) as client:
while elapsed < max_wait:
response = await client.get(
f"{PFP_BASE_URL}{STATUS_ENDPOINT}/{job_id}"
)
response.raise_for_status()
data = response.json()
if data.get("status") == "completed":
return data.get("go_terms", [])
if data.get("status") == "failed":
raise RuntimeError(
f"Prediction failed: {data.get('error', 'Unknown error')}"
)
await asyncio.sleep(interval)
elapsed += interval
raise TimeoutError(
f"Prediction did not complete within {max_wait} seconds"
)
What does this function do?
- In mock mode, the function sleeps for 1 second (simulating server processing time) and returns the matching mock results from MOCK_RESULTS. If the method doesn't match a key, it falls back to the "pfp" results.
- In live mode, the function enters a polling loop. Every interval seconds, it GETs the job status. Three things can end the loop: a "completed" status returns the GO terms, a "failed" status raises a RuntimeError, and exceeding max_wait raises a TimeoutError.
- Save pfp_client.py.
- Test the mock prediction workflow by running this command in your terminal:
python -c "
import asyncio
from pfp_client import submit_prediction, poll_results
async def test():
job = await submit_prediction('>test\nMQIFVKTLTGKTITLEVEPS', 'pfp')
print('Job:', job)
results = await poll_results(job['job_id'], 'pfp')
for r in results[:3]:
print(f\"{r['go_id']} - {r['name']} ({r['confidence']})\")
asyncio.run(test())
"
You should see a mock job ID printed first, followed by three GO terms with their names and confidence scores (e.g. GO:0005515 - protein binding (0.95)).
Seeing a ModuleNotFoundError or ImportError?
Make sure your virtual environment is activated. You should see (venv) or your environment name in your terminal prompt.
Also confirm you're running the command from inside your project directory (the folder that contains pfp_client.py). If you see No module named 'httpx', re-run pip install -r requirements.txt.
Help me debug an import error when running my pfp_client.py test.
✔️ Awesome, I've got everything!
Great. Double check you've saved pfp_client.py.
ⓧ I'd like to double check the full code
Here is the complete pfp_client.py at this point in the project:
import httpx
import asyncio
import time
# ============================================================
# PFP Server Configuration
#
# The Kiharalab PFP server at dragon.bio.purdue.edu/pfp is a
# single-page app. To discover the API endpoints:
# 1. Open http://dragon.bio.purdue.edu/pfp/predict in Chrome
# 2. Open DevTools > Network tab
# 3. Submit a test sequence
# 4. Look for POST/GET requests to identify endpoints
# 5. Update the constants below with the discovered URLs
# ============================================================
PFP_BASE_URL = "http://dragon.bio.purdue.edu"
SUBMIT_ENDPOINT = "/pfp/api/predict"
STATUS_ENDPOINT = "/pfp/api/jobs"
# Set to False after discovering real API endpoints
USE_MOCK = True
# Realistic mock GO term predictions for development and demos
MOCK_RESULTS = {
"pfp": [
{"go_id": "GO:0005515", "name": "protein binding", "category": "MF", "confidence": 0.95},
{"go_id": "GO:0005524", "name": "ATP binding", "category": "MF", "confidence": 0.79},
{"go_id": "GO:0004842", "name": "ubiquitin-protein transferase activity", "category": "MF", "confidence": 0.72},
{"go_id": "GO:0006468", "name": "protein phosphorylation", "category": "BP", "confidence": 0.82},
{"go_id": "GO:0007165", "name": "signal transduction", "category": "BP", "confidence": 0.71},
{"go_id": "GO:0006511", "name": "ubiquitin-dependent protein catabolic process", "category": "BP", "confidence": 0.68},
{"go_id": "GO:0005737", "name": "cytoplasm", "category": "CC", "confidence": 0.88},
{"go_id": "GO:0005634", "name": "nucleus", "category": "CC", "confidence": 0.75},
{"go_id": "GO:0005829", "name": "cytosol", "category": "CC", "confidence": 0.70},
],
"phylo-pfp": [
{"go_id": "GO:0005515", "name": "protein binding", "category": "MF", "confidence": 0.97},
{"go_id": "GO:0004842", "name": "ubiquitin-protein transferase activity", "category": "MF", "confidence": 0.85},
{"go_id": "GO:0019787", "name": "ubiquitin-like protein transferase activity", "category": "MF", "confidence": 0.63},
{"go_id": "GO:0006511", "name": "ubiquitin-dependent protein catabolic process", "category": "BP", "confidence": 0.90},
{"go_id": "GO:0016567", "name": "protein ubiquitination", "category": "BP", "confidence": 0.78},
{"go_id": "GO:0030163", "name": "protein catabolic process", "category": "BP", "confidence": 0.60},
{"go_id": "GO:0005737", "name": "cytoplasm", "category": "CC", "confidence": 0.91},
{"go_id": "GO:0005634", "name": "nucleus", "category": "CC", "confidence": 0.80},
{"go_id": "GO:0000151", "name": "ubiquitin ligase complex", "category": "CC", "confidence": 0.55},
],
}
VALID_AMINO_ACIDS = set("ACDEFGHIKLMNPQRSTVWXY*")
def validate_fasta(text: str) -> dict:
"""Parse and validate a FASTA formatted protein sequence."""
lines = text.strip().split("\n")
if not lines[0].startswith(">"):
raise ValueError("FASTA sequence must start with a > header line")
header = lines[0][1:].strip()
if not header:
raise ValueError("FASTA header line cannot be empty after >")
seq_lines = [
line.strip() for line in lines[1:]
if line.strip() and not line.startswith(">")
]
sequence = "".join(seq_lines).upper()
if len(sequence) < 10:
raise ValueError(
f"Sequence must be at least 10 residues (got {len(sequence)})"
)
invalid_chars = set(sequence) - VALID_AMINO_ACIDS
if invalid_chars:
raise ValueError(
f"Invalid characters found: {', '.join(sorted(invalid_chars))}"
)
return {"header": header, "sequence": sequence, "length": len(sequence)}
async def submit_prediction(
fasta_text: str, method: str = "pfp"
) -> dict:
"""Submit a protein sequence for function prediction."""
if USE_MOCK:
return {"job_id": f"mock-{int(time.time())}", "status": "queued"}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{PFP_BASE_URL}{SUBMIT_ENDPOINT}",
json={"sequence": fasta_text, "tool": method},
)
response.raise_for_status()
return response.json()
async def poll_results(
job_id: str, method: str = "pfp", max_wait: int = 120, interval: int = 5
) -> list[dict]:
"""Poll the PFP server until the prediction completes or times out."""
if USE_MOCK:
await asyncio.sleep(1)
return MOCK_RESULTS.get(method, MOCK_RESULTS["pfp"])
elapsed = 0
async with httpx.AsyncClient(timeout=30.0) as client:
while elapsed < max_wait:
response = await client.get(
f"{PFP_BASE_URL}{STATUS_ENDPOINT}/{job_id}"
)
response.raise_for_status()
data = response.json()
if data.get("status") == "completed":
return data.get("go_terms", [])
if data.get("status") == "failed":
raise RuntimeError(
f"Prediction failed: {data.get('error', 'Unknown error')}"
)
await asyncio.sleep(interval)
elapsed += interval
raise TimeoutError(
f"Prediction did not complete within {max_wait} seconds"
)
Your PFP client module is complete. It can validate sequences, return mock predictions instantly, and switch to the live Kiharalab server when you're ready. Next up, you'll wire this client into your FastAPI server and build the web interface where users submit sequences and see results.
Build the API Server and Web Interface
Your PFP client is ready to validate sequences and fetch predictions. Now you need a way for users to actually interact with it.
In this step, you will build the server layer that ties everything together. You will create a REST API endpoint for programmatic access and a web interface where users can paste a FASTA sequence and see color-coded Gene Ontology predictions.
In this step, get ready to:
- Define Pydantic data models and a REST API endpoint for JSON-based predictions.
- Create a form-based route and home page that render a Jinja2 template.
- Build an HTML template with a FASTA input form and color-coded GO term results.
Define the data models and app configuration
FastAPI uses Pydantic models to define the exact shape of data your API accepts and returns. These models also power the auto-generated Swagger documentation at /docs.
- Open main.py and replace all of its contents with the following code:
from fastapi import FastAPI, Request, Form, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from enum import Enum
import pfp_client
app = FastAPI(
title="Protein Function Predictor",
description="Predict protein function from sequence using Kiharalab PFP",
version="1.0.0",
)
templates = Jinja2Templates(directory="templates")
# Enum listing the prediction methods the Kiharalab PFP suite offers
class PredictionMethod(str, Enum):
PFP = "pfp"
PHYLO_PFP = "phylo-pfp"
ESG = "esg"
DOMAIN_PFP = "domain-pfp"
# Validates the incoming JSON body for the REST API
class PredictionRequest(BaseModel):
sequence: str = Field(
..., min_length=10, description="FASTA formatted protein sequence"
)
method: PredictionMethod = Field(
default=PredictionMethod.PFP, description="Prediction method to use"
)
# Represents a single Gene Ontology prediction
class GOTerm(BaseModel):
go_id: str
name: str
category: str
confidence: float
# The full prediction response returned by the REST API
class PredictionResponse(BaseModel):
job_id: str
status: str
sequence_header: str
sequence_length: int
method: str
go_terms: list[GOTerm]
What does this code do?
The imports pull in everything FastAPI needs to handle JSON requests, HTML form submissions, and template rendering. The pfp_client import gives you access to the validator and prediction functions you built in the previous step.
Jinja2Templates(directory="templates") tells FastAPI where to find HTML templates on disk. The PredictionMethod enum constrains the method field to the four tools Kiharalab offers: PFP, Phylo-PFP, ESG, and Domain-PFP.
The three Pydantic models define the API contract. PredictionRequest validates incoming data. GOTerm represents one GO annotation. PredictionResponse is the full response shape returned to the caller.
- Save main.py.
You will not see a visible change yet. The next chunk adds the routes that use these models.
The API needs a helper that splits a flat list of GO terms into three ontology categories. It also needs a home page route so the server has something to render when users visit the root URL.
- In main.py, add the following code below the PredictionResponse class:
def categorize_terms(go_terms: list[dict]) -> dict:
"""Split GO terms into MF, BP, and CC lists."""
return {
"mf": [t for t in go_terms if t["category"] == "MF"],
"bp": [t for t in go_terms if t["category"] == "BP"],
"cc": [t for t in go_terms if t["category"] == "CC"],
}
# Home page renders the Jinja2 template with empty state
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
return templates.TemplateResponse(
request=request,
name="index.html",
context={"result": None, "error": None, "sequence": ""},
)
What does this code do?
categorize_terms() takes the flat list of GO term dictionaries from the PFP client and groups them by ontology category. This makes it easy for the template to render separate sections for Molecular Function, Biological Process, and Cellular Component.
The GET / route renders index.html with empty context so the page loads without results or errors on first visit.
- Save main.py.
You will not see a change yet. The template file does not exist, so the server would error if you started it now. The next chunk adds the REST API endpoint.
The REST API endpoint is what external scripts and bioinformatics pipelines will call. It accepts a JSON payload, validates the sequence, runs the prediction, and returns structured results.
- In main.py, add the following code below the home() route:
@app.post("/api/predict")
async def api_predict(req: PredictionRequest) -> PredictionResponse:
# Validate the FASTA sequence using the pfp_client
try:
parsed = pfp_client.validate_fasta(req.sequence)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Submit the sequence and poll for results
submission = await pfp_client.submit_prediction(
req.sequence, req.method.value
)
go_terms_raw = await pfp_client.poll_results(
submission["job_id"], req.method.value
)
# Build and return the structured response
return PredictionResponse(
job_id=submission["job_id"],
status="completed",
sequence_header=parsed["header"],
sequence_length=parsed["length"],
method=req.method.value,
go_terms=[GOTerm(**term) for term in go_terms_raw],
)
What does this code do?
This is the POST /api/predict endpoint. FastAPI automatically parses the JSON body into a PredictionRequest object and validates it against the Pydantic schema before your code even runs.
If the FASTA validation fails, the route raises an HTTPException with a 400 status code. If validation passes, it submits the sequence to the PFP client and polls for results. The raw dictionaries are converted into GOTerm models so the response matches the documented schema.
- Save main.py.
The REST endpoint is ready, but you still cannot start the server because the template file is missing. You will test this endpoint in the next step.
Seeing a red underline on pfp_client?
Your editor may show a warning because it cannot resolve the pfp_client import. Make sure pfp_client.py is in the same folder as main.py. Also confirm your virtual environment is selected as the Python interpreter in your editor.
Why can't my editor find the pfp_client module?
Create the form-based prediction route
The REST endpoint returns JSON for programmatic consumers. The form route does the same prediction but renders the results as HTML for humans visiting the site in a browser.
- In main.py, add the following code below the api_predict() route:
@app.post("/predict", response_class=HTMLResponse)
async def predict_form(
request: Request,
sequence: str = Form(...),
method: str = Form("pfp"),
):
error = None
result = None
try:
# Validate, submit, and poll just like the API route
parsed = pfp_client.validate_fasta(sequence)
submission = await pfp_client.submit_prediction(sequence, method)
go_terms_raw = await pfp_client.poll_results(
submission["job_id"], method
)
categorized = categorize_terms(go_terms_raw)
# Build a dict the Jinja2 template can loop over
result = {
"header": parsed["header"],
"length": parsed["length"],
"method": method.upper(),
"mf_terms": categorized["mf"],
"bp_terms": categorized["bp"],
"cc_terms": categorized["cc"],
"total_terms": len(go_terms_raw),
}
except ValueError as e:
error = str(e)
except Exception as e:
error = f"Prediction failed: {str(e)}"
# Re-render the same template with results or an error
return templates.TemplateResponse(
request=request,
name="index.html",
context={"result": result, "error": error, "sequence": sequence},
)
What does this code do?
This is the POST /predict route that handles the HTML form submission. Form(...) tells FastAPI to read values from the submitted form data instead of a JSON body.
The prediction logic is the same as the API route. The difference is what happens with the results. Instead of returning JSON, it passes a result dictionary (or an error string) to the Jinja2 template for rendering.
The sequence is passed back in the context so the textarea stays populated after submission. Users do not lose their input if an error occurs.
- Save main.py.
The server code is now complete. The template is still missing, so do not start the server yet.
Syntax error on the Form import?
Make sure your first import line reads from fastapi import FastAPI, Request, Form, HTTPException. Form is part of the fastapi package. Also verify that python-multipart is listed in your requirements.txt and installed. FastAPI needs it to parse form data.
Why is FastAPI giving me an error about Form or multipart?
Double check that your complete main.py matches the full code below.
✔️ Awesome, I've got everything!
Make sure you have saved main.py before moving on.
ⓧ I'd like to double check the full code
from fastapi import FastAPI, Request, Form, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from enum import Enum
import pfp_client
app = FastAPI(
title="Protein Function Predictor",
description="Predict protein function from sequence using Kiharalab PFP",
version="1.0.0",
)
templates = Jinja2Templates(directory="templates")
class PredictionMethod(str, Enum):
PFP = "pfp"
PHYLO_PFP = "phylo-pfp"
ESG = "esg"
DOMAIN_PFP = "domain-pfp"
class PredictionRequest(BaseModel):
sequence: str = Field(
..., min_length=10, description="FASTA formatted protein sequence"
)
method: PredictionMethod = Field(
default=PredictionMethod.PFP, description="Prediction method to use"
)
class GOTerm(BaseModel):
go_id: str
name: str
category: str
confidence: float
class PredictionResponse(BaseModel):
job_id: str
status: str
sequence_header: str
sequence_length: int
method: str
go_terms: list[GOTerm]
def categorize_terms(go_terms: list[dict]) -> dict:
"""Split GO terms into MF, BP, and CC lists."""
return {
"mf": [t for t in go_terms if t["category"] == "MF"],
"bp": [t for t in go_terms if t["category"] == "BP"],
"cc": [t for t in go_terms if t["category"] == "CC"],
}
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
return templates.TemplateResponse(
request=request,
name="index.html",
context={"result": None, "error": None, "sequence": ""},
)
@app.post("/api/predict")
async def api_predict(req: PredictionRequest) -> PredictionResponse:
try:
parsed = pfp_client.validate_fasta(req.sequence)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
submission = await pfp_client.submit_prediction(
req.sequence, req.method.value
)
go_terms_raw = await pfp_client.poll_results(
submission["job_id"], req.method.value
)
return PredictionResponse(
job_id=submission["job_id"],
status="completed",
sequence_header=parsed["header"],
sequence_length=parsed["length"],
method=req.method.value,
go_terms=[GOTerm(**term) for term in go_terms_raw],
)
@app.post("/predict", response_class=HTMLResponse)
async def predict_form(
request: Request,
sequence: str = Form(...),
method: str = Form("pfp"),
):
error = None
result = None
try:
parsed = pfp_client.validate_fasta(sequence)
submission = await pfp_client.submit_prediction(sequence, method)
go_terms_raw = await pfp_client.poll_results(
submission["job_id"], method
)
categorized = categorize_terms(go_terms_raw)
result = {
"header": parsed["header"],
"length": parsed["length"],
"method": method.upper(),
"mf_terms": categorized["mf"],
"bp_terms": categorized["bp"],
"cc_terms": categorized["cc"],
"total_terms": len(go_terms_raw),
}
except ValueError as e:
error = str(e)
except Exception as e:
error = f"Prediction failed: {str(e)}"
return templates.TemplateResponse(
request=request,
name="index.html",
context={"result": result, "error": error, "sequence": sequence},
)
Build the HTML template with results display
The server routes pass data to a Jinja2 template. This template handles everything the user sees: the input form, validation errors, and the prediction results organized by ontology category.
- Create a new file called index.html inside your templates/ directory.
- Paste the following code into templates/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Protein Function Predictor</title>
<style>
/* Reset and base styles */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f7fa; color: #1a1a2e; line-height: 1.6; }
.container { max-width: 900px; margin: 0 auto; padding: 2rem; }
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
.subtitle { color: #6b7280; margin-bottom: 2rem; }
/* Card container for form and results */
.card { background: white; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
/* FASTA input textarea */
textarea { width: 100%; height: 140px; font-family: 'Courier New', monospace; font-size: 0.9rem; padding: 0.75rem; border: 2px solid #e5e7eb; border-radius: 8px; resize: vertical; }
textarea:focus { outline: none; border-color: #6366f1; }
/* Form layout */
.form-row { display: flex; gap: 1rem; align-items: end; margin-top: 1rem; }
select { padding: 0.6rem 1rem; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 0.9rem; background: white; }
button { padding: 0.6rem 1.5rem; background: #6366f1; color: white; border: none; border-radius: 8px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
button:hover { background: #4f46e5; }
/* Error message area */
.error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; padding: 1rem; border-radius: 8px; margin-bottom: 1.5rem; }
/* Results header with sequence info */
.result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid #e5e7eb; }
.result-header h2 { font-size: 1.2rem; }
/* Color-coded category badges: blue for MF, green for BP, amber for CC */
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.75rem; font-weight: 600; }
.badge-mf { background: #dbeafe; color: #1e40af; }
.badge-bp { background: #dcfce7; color: #166534; }
.badge-cc { background: #fef3c7; color: #92400e; }
/* GO term result rows */
.category { margin-bottom: 1.5rem; }
.category h3 { font-size: 1rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem; }
.term-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; border-bottom: 1px solid #f3f4f6; }
.term-id { font-family: 'Courier New', monospace; font-size: 0.8rem; color: #6b7280; min-width: 100px; }
.term-name { flex: 1; font-size: 0.9rem; }
/* Confidence score bar */
.confidence-bar { width: 120px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
.confidence-fill { height: 100%; border-radius: 4px; }
.fill-mf { background: #3b82f6; }
.fill-bp { background: #22c55e; }
.fill-cc { background: #f59e0b; }
.confidence-val { font-size: 0.8rem; color: #6b7280; min-width: 40px; text-align: right; }
/* Utility styles */
.meta { font-size: 0.85rem; color: #6b7280; }
.sample-btn { background: none; border: 1px solid #d1d5db; color: #6b7280; padding: 0.4rem 0.8rem; font-size: 0.8rem; border-radius: 6px; cursor: pointer; margin-top: 0.5rem; }
.sample-btn:hover { background: #f9fafb; color: #374151; }
.api-note { margin-top: 2rem; text-align: center; font-size: 0.85rem; color: #9ca3af; }
.api-note a { color: #6366f1; }
</style>
</head>
<body>
<div class="container">
<h1>Protein Function Predictor</h1>
<p class="subtitle">Predict protein function from sequence using Kiharalab PFP</p>
<!-- Sequence input form -->
<div class="card">
<form method="post" action="/predict">
<label for="sequence"><strong>FASTA Sequence</strong></label>
<textarea id="sequence" name="sequence" placeholder=">sp|P0CG48|UBC_HUMAN Ubiquitin MQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG">{{ sequence }}</textarea>
<button type="button" class="sample-btn" onclick="document.getElementById('sequence').value = '>sp|P0CG48|UBC_HUMAN Ubiquitin\nMQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG'">Load sample (Ubiquitin)</button>
<div class="form-row">
<div>
<label for="method"><strong>Method</strong></label><br>
<select id="method" name="method">
<option value="pfp">PFP</option>
<option value="phylo-pfp">Phylo-PFP</option>
<option value="esg">ESG</option>
<option value="domain-pfp">Domain-PFP</option>
</select>
</div>
<button type="submit">Predict Function</button>
</div>
</form>
</div>
<!-- Error display -->
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<!-- Prediction results organized by GO category -->
{% if result %}
<div class="card">
<div class="result-header">
<h2>Prediction Results</h2>
<span class="meta">{{ result.header }} ({{ result.length }} residues) | {{ result.method }}</span>
</div>
<!-- Molecular Function terms (blue) -->
{% if result.mf_terms %}
<div class="category">
<h3><span class="badge badge-mf">MF</span> Molecular Function</h3>
{% for term in result.mf_terms %}
<div class="term-row">
<span class="term-id">{{ term.go_id }}</span>
<span class="term-name">{{ term.name }}</span>
<div class="confidence-bar"><div class="confidence-fill fill-mf" style="width: {{ (term.confidence * 100)|int }}%"></div></div>
<span class="confidence-val">{{ "%.0f"|format(term.confidence * 100) }}%</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Biological Process terms (green) -->
{% if result.bp_terms %}
<div class="category">
<h3><span class="badge badge-bp">BP</span> Biological Process</h3>
{% for term in result.bp_terms %}
<div class="term-row">
<span class="term-id">{{ term.go_id }}</span>
<span class="term-name">{{ term.name }}</span>
<div class="confidence-bar"><div class="confidence-fill fill-bp" style="width: {{ (term.confidence * 100)|int }}%"></div></div>
<span class="confidence-val">{{ "%.0f"|format(term.confidence * 100) }}%</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Cellular Component terms (amber) -->
{% if result.cc_terms %}
<div class="category">
<h3><span class="badge badge-cc">CC</span> Cellular Component</h3>
{% for term in result.cc_terms %}
<div class="term-row">
<span class="term-id">{{ term.go_id }}</span>
<span class="term-name">{{ term.name }}</span>
<div class="confidence-bar"><div class="confidence-fill fill-cc" style="width: {{ (term.confidence * 100)|int }}%"></div></div>
<span class="confidence-val">{{ "%.0f"|format(term.confidence * 100) }}%</span>
</div>
{% endfor %}
</div>
{% endif %}
<p class="meta" style="margin-top: 1rem;">{{ result.total_terms }} GO terms predicted</p>
</div>
{% endif %}
<p class="api-note">REST API available at <a href="/docs">/docs</a> | Powered by <a href="https://kiharalab.org/pfp" target="_blank">Kiharalab PFP</a></p>
</div>
</body>
</html>
What does this template do?
The template has three main sections. The form card contains a textarea for FASTA input, a method dropdown with all four PFP tools, a Load sample button that fills in human ubiquitin for quick testing, and a Predict Function submit button.
The error section uses Jinja2's {% if error %} block to conditionally display validation errors in a red box.
The results section renders GO terms grouped into three color-coded categories. Molecular Function terms appear in blue, Biological Process in green, and Cellular Component in amber. Each term row shows the GO ID, the term name, and a confidence bar scaled to the score.
- Save templates/index.html.
- Start the server by running this command:
uvicorn main:app --reload
- Open http://localhost:8000 in your browser.
You should see a clean page with the title "Protein Function Predictor", a FASTA input textarea, a method dropdown, and a purple Predict Function button.
Seeing a TemplateNotFound error?
Make sure index.html is inside the templates/ folder, not in the project root. The path should be templates/index.html relative to main.py.
Also check that the filename is exactly index.html (all lowercase). A mismatch in casing will cause this error on case-sensitive file systems.
Why is FastAPI saying TemplateNotFound for index.html?
Double check that your template matches the full code below.
✔️ Awesome, I've got everything!
Make sure you have saved templates/index.html and that the server is running.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Protein Function Predictor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f7fa; color: #1a1a2e; line-height: 1.6; }
.container { max-width: 900px; margin: 0 auto; padding: 2rem; }
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
.subtitle { color: #6b7280; margin-bottom: 2rem; }
.card { background: white; border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
textarea { width: 100%; height: 140px; font-family: 'Courier New', monospace; font-size: 0.9rem; padding: 0.75rem; border: 2px solid #e5e7eb; border-radius: 8px; resize: vertical; }
textarea:focus { outline: none; border-color: #6366f1; }
.form-row { display: flex; gap: 1rem; align-items: end; margin-top: 1rem; }
select { padding: 0.6rem 1rem; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 0.9rem; background: white; }
button { padding: 0.6rem 1.5rem; background: #6366f1; color: white; border: none; border-radius: 8px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
button:hover { background: #4f46e5; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; padding: 1rem; border-radius: 8px; margin-bottom: 1.5rem; }
.result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 0.75rem; border-bottom: 1px solid #e5e7eb; }
.result-header h2 { font-size: 1.2rem; }
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 20px; font-size: 0.75rem; font-weight: 600; }
.badge-mf { background: #dbeafe; color: #1e40af; }
.badge-bp { background: #dcfce7; color: #166534; }
.badge-cc { background: #fef3c7; color: #92400e; }
.category { margin-bottom: 1.5rem; }
.category h3 { font-size: 1rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 0.5rem; }
.term-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; border-bottom: 1px solid #f3f4f6; }
.term-id { font-family: 'Courier New', monospace; font-size: 0.8rem; color: #6b7280; min-width: 100px; }
.term-name { flex: 1; font-size: 0.9rem; }
.confidence-bar { width: 120px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
.confidence-fill { height: 100%; border-radius: 4px; }
.fill-mf { background: #3b82f6; }
.fill-bp { background: #22c55e; }
.fill-cc { background: #f59e0b; }
.confidence-val { font-size: 0.8rem; color: #6b7280; min-width: 40px; text-align: right; }
.meta { font-size: 0.85rem; color: #6b7280; }
.sample-btn { background: none; border: 1px solid #d1d5db; color: #6b7280; padding: 0.4rem 0.8rem; font-size: 0.8rem; border-radius: 6px; cursor: pointer; margin-top: 0.5rem; }
.sample-btn:hover { background: #f9fafb; color: #374151; }
.api-note { margin-top: 2rem; text-align: center; font-size: 0.85rem; color: #9ca3af; }
.api-note a { color: #6366f1; }
</style>
</head>
<body>
<div class="container">
<h1>Protein Function Predictor</h1>
<p class="subtitle">Predict protein function from sequence using Kiharalab PFP</p>
<div class="card">
<form method="post" action="/predict">
<label for="sequence"><strong>FASTA Sequence</strong></label>
<textarea id="sequence" name="sequence" placeholder=">sp|P0CG48|UBC_HUMAN Ubiquitin MQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG">{{ sequence }}</textarea>
<button type="button" class="sample-btn" onclick="document.getElementById('sequence').value = '>sp|P0CG48|UBC_HUMAN Ubiquitin\nMQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG'">Load sample (Ubiquitin)</button>
<div class="form-row">
<div>
<label for="method"><strong>Method</strong></label><br>
<select id="method" name="method">
<option value="pfp">PFP</option>
<option value="phylo-pfp">Phylo-PFP</option>
<option value="esg">ESG</option>
<option value="domain-pfp">Domain-PFP</option>
</select>
</div>
<button type="submit">Predict Function</button>
</div>
</form>
</div>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
{% if result %}
<div class="card">
<div class="result-header">
<h2>Prediction Results</h2>
<span class="meta">{{ result.header }} ({{ result.length }} residues) | {{ result.method }}</span>
</div>
{% if result.mf_terms %}
<div class="category">
<h3><span class="badge badge-mf">MF</span> Molecular Function</h3>
{% for term in result.mf_terms %}
<div class="term-row">
<span class="term-id">{{ term.go_id }}</span>
<span class="term-name">{{ term.name }}</span>
<div class="confidence-bar"><div class="confidence-fill fill-mf" style="width: {{ (term.confidence * 100)|int }}%"></div></div>
<span class="confidence-val">{{ "%.0f"|format(term.confidence * 100) }}%</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if result.bp_terms %}
<div class="category">
<h3><span class="badge badge-bp">BP</span> Biological Process</h3>
{% for term in result.bp_terms %}
<div class="term-row">
<span class="term-id">{{ term.go_id }}</span>
<span class="term-name">{{ term.name }}</span>
<div class="confidence-bar"><div class="confidence-fill fill-bp" style="width: {{ (term.confidence * 100)|int }}%"></div></div>
<span class="confidence-val">{{ "%.0f"|format(term.confidence * 100) }}%</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if result.cc_terms %}
<div class="category">
<h3><span class="badge badge-cc">CC</span> Cellular Component</h3>
{% for term in result.cc_terms %}
<div class="term-row">
<span class="term-id">{{ term.go_id }}</span>
<span class="term-name">{{ term.name }}</span>
<div class="confidence-bar"><div class="confidence-fill fill-cc" style="width: {{ (term.confidence * 100)|int }}%"></div></div>
<span class="confidence-val">{{ "%.0f"|format(term.confidence * 100) }}%</span>
</div>
{% endfor %}
</div>
{% endif %}
<p class="meta" style="margin-top: 1rem;">{{ result.total_terms }} GO terms predicted</p>
</div>
{% endif %}
<p class="api-note">REST API available at <a href="/docs">/docs</a> | Powered by <a href="https://kiharalab.org/pfp" target="_blank">Kiharalab PFP</a></p>
</div>
</body>
</html>
Your server is live with both a REST API and a web interface. Next up, you will test it with real protein sequences and edge cases to make sure everything handles both valid and invalid input correctly.
Test with Real Protein Sequences
Your API server, prediction client, and web interface are all wired together. Every route, model, and template is ready to handle protein sequences.
Now it's time to put the whole system through its paces with a real protein. You'll submit a well-characterized sequence through the web form and the REST API, then deliberately feed it bad input to confirm your validation catches every edge case.
In this step, get ready to:
- Test the web UI with a human ubiquitin sequence and verify GO term results.
- Test the REST API through Swagger UI with a JSON payload.
- Confirm that invalid sequences return clear error messages.
Start the server and test the web UI
Human ubiquitin is a small, well-characterized 76-residue protein. Its functions are thoroughly annotated in Gene Ontology, making it a perfect test case for your prediction server.
- Make sure your terminal is in your project folder (the one containing main.py).
- Start the development server by running this command:
uvicorn main:app --reload
What does this command do?
uvicorn is an ASGI server that runs your FastAPI application. main:app tells it to look for the app object inside main.py.
The --reload flag tells uvicorn to restart automatically whenever you save a file change.
- Confirm your terminal shows a line that includes Uvicorn running on http://127.0.0.1:8000.
Server not starting?
- Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- If you see ModuleNotFoundError, re-run pip install -r requirements.txt to install all dependencies.
- Check that you're running the command from the folder that contains main.py.
Why won't my uvicorn server start?
With the server running, you can now test the web interface with a real protein sequence.
- Open your browser and navigate to http://localhost:8000.
- Click the Load sample (Ubiquitin) button below the textarea. This fills in the human ubiquitin FASTA sequence automatically.
- Leave the Method dropdown set to PFP.
- Click Predict Function.
After about one second, results appear in a new card below the form. You should see three color-coded sections: blue for Molecular Function, green for Biological Process, and amber for Cellular Component.
What should I see in the results?
- Under Molecular Function (MF), you should see protein binding at 95% and ATP binding at 79%.
- Under Biological Process (BP), look for protein phosphorylation at 82% and signal transduction at 71%.
- Under Cellular Component (CC), cytoplasm should show at 88% and nucleus at 75%.
- Each term displays a GO ID (like GO:0005515), a name, a colored confidence bar, and a percentage score.
Why am I seeing mock data?
The server runs in mock mode because USE_MOCK = True is set in pfp_client.py. The Kiharalab PFP server's internal API endpoints are not publicly documented.
Mock mode returns realistic GO term data so your app always works for development and demos, regardless of the external server's availability.
Test the REST API through Swagger UI
FastAPI auto-generates interactive OpenAPI documentation at /docs. This is where other developers or your own scripts interact with the prediction endpoint programmatically.
- In your browser, navigate to http://localhost:8000/docs.
- You should see the Swagger UI with Protein Function Predictor as the title at the top.
- Click on the POST /api/predict endpoint to expand it.
- Click Try it out.
- Replace the example request body with this JSON payload:
{
"sequence": ">sp|P0CG48|UBC_HUMAN Ubiquitin\nMQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG",
"method": "pfp"
}
- Click Execute.
The response body appears below the button. You should see a JSON object with a 200 status code.
What should the response look like?
The response JSON matches the Pydantic PredictionResponse model you defined in main.py.
- job_id starts with mock- followed by a timestamp.
- sequence_length is 76 (ubiquitin's residue count).
- go_terms contains an array of 9 objects, each with go_id, name, category, and confidence fields.
Seeing a 422 Validation Error?
- Make sure the JSON is valid. A missing comma, extra trailing comma, or unmatched brace causes a parse error.
- The sequence field must include the FASTA header line starting with > and a \n newline before the amino acid sequence.
- The method field must be one of pfp, phylo-pfp, esg, or domain-pfp.
Why am I getting a 422 error from my FastAPI endpoint?
Test edge cases with invalid sequences
Your validate_fasta() function guards against three types of bad input. Let's trigger each one to make sure the error messages are clear and specific.
- Go back to http://localhost:8000 in your browser.
- Clear the textarea and paste this sequence that is missing its FASTA header:
MQIFVKTLTGKTITLEVEPSDTIENVK
- Click Predict Function.
A red error box appears with the message FASTA sequence must start with a > header line.
- Clear the textarea and paste this sequence that is too short (only 5 residues):
>too-short
ACDEF
- Click Predict Function.
The error box now reads Sequence must be at least 10 residues (got 5).
- Clear the textarea and paste this sequence that contains invalid characters:
>invalid-chars
ACDEFGHIKL123MNPQR
- Click Predict Function.
The error box shows Invalid characters found: 1, 2, 3. The validator identified exactly which characters don't belong in an amino acid sequence.
Why does input validation matter here?
In a bioinformatics pipeline, bad input that silently passes through can produce misleading GO term predictions. Catching errors before the sequence reaches the prediction engine saves processing time and gives users actionable feedback.
Your protein function prediction server handles both the happy path and the error path cleanly. Next up, you can take on the Secret Mission and add multi-method comparison to see how PFP and Phylo-PFP predictions stack up side by side.
Secret mission
Add Multi-Method Comparison
What if you could run the same protein sequence through two different prediction methods and compare the results side by side? In this extension, you'll add a comparison feature that submits to both PFP and Phylo-PFP simultaneously using async parallelism and displays the results in a two-column view.
Before you go
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Python virtual environment (venv folder inside your project directory).
- Installed Python packages: fastapi, uvicorn, httpx, jinja2, python-multipart.
- Project files: main.py, pfp_client.py, requirements.txt, and the templates/ directory.
- The uvicorn development server (if still running in your terminal).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.
- Your uvicorn server keeps running at http://localhost:8000.
- Your virtual environment stays activated in the terminal.
- All project files remain on your Desktop.
Pause - I'll come back to this later
Shut down the running server to free up your terminal and the port. Your files and virtual environment stay intact for when you return.
- In the terminal where uvicorn is running, press Ctrl+C to stop the server.
- Deactivate your virtual environment by running:
deactivate
- When you come back, navigate to your project folder, reactivate the virtual environment, and start the server again.
Delete - I don't want to use this again
Remove all project files and the virtual environment completely. This frees up disk space and removes all installed packages.
- In the terminal where uvicorn is running, press Ctrl+C to stop the server.
- Deactivate the virtual environment by running:
deactivate
- Navigate to your Desktop and delete the entire project folder by running:
cd ~/Desktop
rm -rf pfp-api
- This removes all project files, the virtual environment, and all installed packages.
Mission accomplished
Nice Work!
You just built a protein function prediction server from scratch. Users can submit a FASTA sequence through a web form or a REST API endpoint and get back Gene Ontology function predictions with confidence scores.
You've learned how to:
- Build a FastAPI REST API with Pydantic data models that accepts protein sequences and returns structured GO term predictions.
- Implement FASTA sequence validation with amino acid character checking and an async HTTP client for external service integration with a mock mode for reliable development.
- Design a Jinja2 web interface that organizes Gene Ontology predictions by Molecular Function, Biological Process, and Cellular Component with color-coded confidence bars.
- Added multi-method comparison to evaluate PFP vs Phylo-PFP predictions side by side.
Ready to quiz yourself?