Samad14's picture
feat(primers): add NCBI gene search + sequence retrieval, oligo QC and in-silico PCR verification with recharts graphs
5d5e978
Raw History Blame Contribute Delete
9.59 kB
import re
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
from app.services.ncbi_service import NCBIService
from app.tools.oligo_qc import (
clean,
is_dna,
oligo_report,
dimer_analysis,
in_silico_pcr,
)
router = APIRouter(prefix="/api/primers", tags=["primers"])
ncbi_service = NCBIService()
try:
import primer3
HAS_PRIMER3 = True
except ImportError:
HAS_PRIMER3 = False
class PrimerRequest(BaseModel):
sequence: str
product_size_min: int = Field(default=100, ge=50)
product_size_max: int = Field(default=500, le=2000)
opt_tm: float = Field(default=60.0)
num_return: int = Field(default=5, ge=1, le=10)
gc_min: float = Field(default=40.0)
gc_max: float = Field(default=65.0)
class PrimerPair(BaseModel):
pair_index: int
left_seq: str; left_tm: float; left_gc: float; left_pos: int; left_len: int
right_seq: str; right_tm: float; right_gc: float; right_pos: int; right_len: int
product_size: int
penalty: float
@router.post("/design", response_model=list[PrimerPair])
async def design_primers(req: PrimerRequest):
if not HAS_PRIMER3:
raise HTTPException(503, "Primer3 is not installed on this server")
seq = req.sequence.upper().replace(" ", "").replace("\n", "")
if len(seq) < 100:
raise HTTPException(400, "Sequence must be at least 100 bases for primer design")
if not all(c in "ATGCN" for c in seq):
raise HTTPException(400, "Sequence must be DNA (A/T/G/C/N only). Convert protein to CDS first.")
seq_args = {
"SEQUENCE_ID": "target",
"SEQUENCE_TEMPLATE": seq,
}
global_args = {
"PRIMER_OPT_SIZE": 20,
"PRIMER_MIN_SIZE": 18,
"PRIMER_MAX_SIZE": 25,
"PRIMER_OPT_TM": req.opt_tm,
"PRIMER_MIN_TM": req.opt_tm - 3,
"PRIMER_MAX_TM": req.opt_tm + 3,
"PRIMER_MIN_GC": req.gc_min,
"PRIMER_MAX_GC": req.gc_max,
"PRIMER_PRODUCT_SIZE_RANGE": [[req.product_size_min, req.product_size_max]],
"PRIMER_NUM_RETURN": req.num_return,
"PRIMER_EXPLAIN_FLAG": 1,
}
try:
result = primer3.bindings.design_primers(seq_args, global_args)
except Exception as e:
raise HTTPException(500, f"Primer3 error: {e}")
pairs: list[PrimerPair] = []
n = result.get("PRIMER_PAIR_NUM_RETURNED", 0)
for i in range(n):
lp = result.get(f"PRIMER_LEFT_{i}")
rp = result.get(f"PRIMER_RIGHT_{i}")
if not lp or not rp:
continue
pairs.append(PrimerPair(
pair_index = i,
left_seq = result.get(f"PRIMER_LEFT_{i}_SEQUENCE", ""),
left_tm = result.get(f"PRIMER_LEFT_{i}_TM", 0),
left_gc = result.get(f"PRIMER_LEFT_{i}_GC_PERCENT", 0),
left_pos = lp[0],
left_len = lp[1],
right_seq = result.get(f"PRIMER_RIGHT_{i}_SEQUENCE", ""),
right_tm = result.get(f"PRIMER_RIGHT_{i}_TM", 0),
right_gc = result.get(f"PRIMER_RIGHT_{i}_GC_PERCENT", 0),
right_pos = rp[0],
right_len = rp[1],
product_size = result.get(f"PRIMER_PAIR_{i}_PRODUCT_SIZE", 0),
penalty = result.get(f"PRIMER_PAIR_{i}_PENALTY", 0),
))
if not pairs:
raise HTTPException(404, "No primer pairs found. Try relaxing GC%, Tm, or product size constraints.")
return pairs
def _record_type(accession: str) -> str:
acc = (accession or "").upper()
if acc.startswith(("NM_", "XM_")):
return "mRNA"
if acc.startswith(("NR_", "XR_")):
return "non-coding RNA"
if acc.startswith("NG_"):
return "genomic DNA"
if acc.startswith("NC_"):
return "chromosome"
if acc.startswith(("NT_", "NW_", "AC_", "AE_")):
return "genomic (assembly)"
return "nucleotide"
class PrimerSearchRequest(BaseModel):
query: str = Field(..., min_length=2, description="Gene symbol or nucleotide search term (e.g. TP53, BRCA1 human)")
max_results: int = Field(12, ge=1, le=50)
# Organism hints a user might append to a plain gene query, mapped to the
# canonical NCBI [Organism] taxonomy string.
_ORGANISM_HINTS = {
"human": "Homo sapiens",
"homo sapiens": "Homo sapiens",
"mouse": "Mus musculus",
"mus musculus": "Mus musculus",
"rat": "Rattus norvegicus",
"rattus norvegicus": "Rattus norvegicus",
"zebrafish": "Danio rerio",
"danio rerio": "Danio rerio",
"fly": "Drosophila melanogaster",
"drosophila": "Drosophila melanogaster",
"yeast": "Saccharomyces cerevisiae",
"saccharomyces cerevisiae": "Saccharomyces cerevisiae",
"worm": "Caenorhabditis elegans",
"c elegans": "Caenorhabditis elegans",
"e coli": "Escherichia coli",
"escherichia coli": "Escherichia coli",
"arabidopsis": "Arabidopsis thaliana",
"plant": "Arabidopsis thaliana",
"bovine": "Bos taurus",
"cow": "Bos taurus",
"dog": "Canis lupus familiaris",
"pig": "Sus scrofa",
}
def _build_nucleotide_query(term: str) -> str:
"""Turn a plain gene query like 'TP53 human' into a proper NCBI query.
'TP53 human' handled verbatim by NCBI's phrase search returns irrelevant
taxa; translating to 'TP53[Gene Name] AND Homo sapiens[Organism]' returns
the actual reference mRNAs. Queries that already contain field tags
(square brackets) are passed through untouched, and accession-style
queries are turned into exact [ACCN] lookups.
"""
term = term.strip()
if not term or "[" in term:
return term
first = term.split()[0].upper()
if re.match(r"^(NM_|XM_|NR_|XR_|NG_|NC_|NT_|NW_|AC_|AE_|AF_|AY_)", first):
return f"{term.strip()}[ACCN]"
tokens = [t for t in term.split() if t]
gene = tokens[0]
for i, tok in enumerate(tokens[1:], start=1):
hint = _ORGANISM_HINTS.get(tok.lower())
if hint:
organism = hint
extra = " ".join(t for t in tokens[1:i] + tokens[i + 1:] if t)
break
else:
organism = None
extra = " ".join(tokens[1:])
parts = [f"{gene}[Gene Name]"]
if extra:
parts.append(extra)
if organism:
parts.append(f"{organism}[Organism]")
return " AND ".join(parts)
def _result_priority(r: dict) -> int:
"""Rank search hits so the most useful primer templates come first."""
rt = r.get("record_type", "")
title = r.get("title", "").lower()
if "complete cds" in title and rt == "mRNA":
return 0
if "complete cds" in title:
return 1
if rt == "mRNA":
return 2
if rt == "genomic DNA":
return 3
if rt == "non-coding RNA":
return 4
return 5
@router.post("/search")
async def search_primer_targets(req: PrimerSearchRequest):
"""Search NCBI Nucleotide for a gene/sequence to design primers against.
Mirrors the 'NCBI Gene / NCBI Nucleotide' step of the primer design
workflow: find the gene of interest, then retrieve its mRNA/DNA sequence.
"""
query = _build_nucleotide_query(req.query)
result = await ncbi_service.search_by_name(query, db="nucleotide", max_results=req.max_results)
if "error" in result:
raise HTTPException(status_code=404, detail=result["error"])
for r in result["results"]:
r["record_type"] = _record_type(r.get("accession", ""))
r["suggested_use"] = "ideal" if r["record_type"] == "mRNA" else "ok"
result["results"].sort(key=_result_priority)
result["query"] = req.query
result["ncbi_query"] = query
return result
class PrimerAnalyzeRequest(BaseModel):
left_seq: str = Field(..., min_length=5, description="Forward primer 5'->3'")
right_seq: str = Field(..., min_length=5, description="Reverse primer 5'->3'")
template: Optional[str] = Field(None, description="Template sequence for in-silico PCR")
left_pos: Optional[int] = Field(None, description="Primer3 0-based left primer start")
right_pos: Optional[int] = Field(None, description="Primer3 0-based right primer start")
expected_product: Optional[int] = Field(None, ge=1, description="Primer3-reported product size")
@router.post("/analyze")
async def analyze_primer(req: PrimerAnalyzeRequest):
"""Run oligo QC (hairpin, self-/hetero-dimer, Tm, GC) and in-silico PCR.
Mirrors the IDT OligoAnalyzer + Primer-BLAST + in-silico PCR steps of the
primer design workflow for a single candidate pair.
"""
left = clean(req.left_seq)
right = clean(req.right_seq)
if not is_dna(left) or not is_dna(right):
raise HTTPException(400, "Primers must be DNA (A/T/G/C/N only).")
if len(left) < 5 or len(right) < 5:
raise HTTPException(400, "Primers must be at least 5 bases.")
qc = {
"left": oligo_report(left),
"right": oligo_report(right),
"hetero_dimer": dimer_analysis(left, right),
}
response = {"qc": qc}
if req.template:
template = clean(req.template)
if not template:
raise HTTPException(400, "Template sequence is required for in-silico PCR.")
response["pcr"] = in_silico_pcr(
template,
left,
right,
expected_product=req.expected_product,
left_expected=req.left_pos,
right_expected=req.right_pos,
)
return response