Spaces:
Running
Running
CRISPR MVP: Cas9 knockout gRNA designer (sign-in gated, free for accounts)
Browse files- dee/server.py +141 -0
dee/server.py
CHANGED
|
@@ -37,6 +37,7 @@ from typing import Any, Dict, List, Optional
|
|
| 37 |
from flask import Flask, Response, jsonify, request, send_file, send_from_directory
|
| 38 |
|
| 39 |
from dee import auth as _auth
|
|
|
|
| 40 |
from dee.core.codon import (
|
| 41 |
DEFAULT_FORBIDDEN_SITES,
|
| 42 |
pcr_metrics,
|
|
@@ -761,6 +762,146 @@ def create_app() -> Flask:
|
|
| 761 |
return jsonify({"error": "unknown job"}), 404
|
| 762 |
return jsonify(job.public())
|
| 763 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 764 |
# NOTE: /api/library, /api/library/<filename> (GET + DELETE), and
|
| 765 |
# /api/shutdown were removed on 2026-05-26 per SECURITY_AUDIT findings
|
| 766 |
# C2 + C3.
|
|
|
|
| 37 |
from flask import Flask, Response, jsonify, request, send_file, send_from_directory
|
| 38 |
|
| 39 |
from dee import auth as _auth
|
| 40 |
+
from dee.core.crispr import find_guides as _find_crispr_guides, guides_to_csv_rows as _crispr_csv_rows
|
| 41 |
from dee.core.codon import (
|
| 42 |
DEFAULT_FORBIDDEN_SITES,
|
| 43 |
pcr_metrics,
|
|
|
|
| 762 |
return jsonify({"error": "unknown job"}), 404
|
| 763 |
return jsonify(job.public())
|
| 764 |
|
| 765 |
+
# ================================================================= CRISPR
|
| 766 |
+
# Cas9 gRNA design for knockout applications. MVP scope: pasted DNA
|
| 767 |
+
# → ranked SpCas9 guides with Doench-style heuristic on-target
|
| 768 |
+
# scoring. Sign-in gated (free for any account); anonymous users
|
| 769 |
+
# get a 403 with a sign-in CTA so the frontend can route them to
|
| 770 |
+
# /signin instead of the engine's trial gate. Whole-genome off-target,
|
| 771 |
+
# Cas12a, base editors, and HDR donor design are deferred — see
|
| 772 |
+
# dee/core/crispr.py module docstring for the full scope ladder.
|
| 773 |
+
|
| 774 |
+
@app.post("/api/crispr/design")
|
| 775 |
+
def crispr_design() -> Response:
|
| 776 |
+
# Sign-in gate. Distinct from the trial timer (which is per-anon-
|
| 777 |
+
# session): CRISPR requires an account, no anonymous use. The
|
| 778 |
+
# 403 carries kind="signin_required" so static auth.js can route
|
| 779 |
+
# the parent window to /signin instead of showing the trial modal.
|
| 780 |
+
auth = _auth.get_auth()
|
| 781 |
+
if auth.anonymous:
|
| 782 |
+
return jsonify({
|
| 783 |
+
"error": (
|
| 784 |
+
"CRISPR design requires a free account. "
|
| 785 |
+
"Sign in or create one to keep going."
|
| 786 |
+
),
|
| 787 |
+
"kind": "signin_required",
|
| 788 |
+
"signup_url": "https://turingdna.com/signin/?from=crispr",
|
| 789 |
+
}), 403
|
| 790 |
+
|
| 791 |
+
body = request.get_json(force=True, silent=True) or {}
|
| 792 |
+
seq = (body.get("sequence") or "").strip()
|
| 793 |
+
if not seq:
|
| 794 |
+
return jsonify({"error": "missing 'sequence'"}), 400
|
| 795 |
+
|
| 796 |
+
# Bound the input so a 10 MB paste doesn't tie up the worker.
|
| 797 |
+
# 1 Mbp covers any reasonable cloning-scale region (largest
|
| 798 |
+
# human genes are ~2.4 Mbp, but at that scale you'd use a
|
| 799 |
+
# whole-genome tool, not a paste box).
|
| 800 |
+
if len(seq) > 1_000_000:
|
| 801 |
+
return jsonify({
|
| 802 |
+
"error": (
|
| 803 |
+
"Sequence too long. Cap is 1 Mbp — paste just the "
|
| 804 |
+
"gene / region you're editing, not a whole "
|
| 805 |
+
"chromosome."
|
| 806 |
+
),
|
| 807 |
+
"kind": "input_too_large",
|
| 808 |
+
}), 400
|
| 809 |
+
|
| 810 |
+
try:
|
| 811 |
+
max_results = int(body.get("max_results", 50))
|
| 812 |
+
except (TypeError, ValueError):
|
| 813 |
+
max_results = 50
|
| 814 |
+
max_results = max(1, min(500, max_results)) # bound
|
| 815 |
+
|
| 816 |
+
try:
|
| 817 |
+
min_score = float(body.get("min_score", 0.0))
|
| 818 |
+
except (TypeError, ValueError):
|
| 819 |
+
min_score = 0.0
|
| 820 |
+
min_score = max(0.0, min(1.0, min_score))
|
| 821 |
+
|
| 822 |
+
try:
|
| 823 |
+
guides = _find_crispr_guides(
|
| 824 |
+
seq, max_results=max_results, min_score=min_score,
|
| 825 |
+
)
|
| 826 |
+
except ValueError as exc:
|
| 827 |
+
return jsonify({"error": str(exc), "kind": "validation"}), 400
|
| 828 |
+
except Exception as exc: # noqa: BLE001
|
| 829 |
+
logger.exception("CRISPR design failed.")
|
| 830 |
+
return jsonify({
|
| 831 |
+
"error": f"{type(exc).__name__}: {exc}",
|
| 832 |
+
"kind": "internal",
|
| 833 |
+
}), 500
|
| 834 |
+
|
| 835 |
+
# Flatten dataclasses into plain dicts for JSON.
|
| 836 |
+
return jsonify({
|
| 837 |
+
"input_length": len(seq.strip()),
|
| 838 |
+
"n_guides": len(guides),
|
| 839 |
+
"guides": [
|
| 840 |
+
{
|
| 841 |
+
"rank": g.rank,
|
| 842 |
+
"strand": g.strand,
|
| 843 |
+
"position": g.position,
|
| 844 |
+
"spacer": g.spacer,
|
| 845 |
+
"pam": g.pam,
|
| 846 |
+
"target_context": g.target_context,
|
| 847 |
+
"on_target_score": g.on_target_score,
|
| 848 |
+
"gc_pct": g.gc_pct,
|
| 849 |
+
"flag_high_gc": g.flag_high_gc,
|
| 850 |
+
"flag_low_gc": g.flag_low_gc,
|
| 851 |
+
"flag_polyT": g.flag_polyT,
|
| 852 |
+
"notes": g.notes,
|
| 853 |
+
}
|
| 854 |
+
for g in guides
|
| 855 |
+
],
|
| 856 |
+
})
|
| 857 |
+
|
| 858 |
+
@app.post("/api/crispr/download")
|
| 859 |
+
def crispr_download() -> Response:
|
| 860 |
+
"""Return the CSV body for a fresh design. We re-design from the
|
| 861 |
+
sequence on download rather than caching per-job to avoid a
|
| 862 |
+
whole job-state layer for what's essentially a pure function
|
| 863 |
+
of the input. Same sign-in gate as /api/crispr/design."""
|
| 864 |
+
auth = _auth.get_auth()
|
| 865 |
+
if auth.anonymous:
|
| 866 |
+
return jsonify({"error": "signin_required"}), 403
|
| 867 |
+
|
| 868 |
+
body = request.get_json(force=True, silent=True) or {}
|
| 869 |
+
seq = (body.get("sequence") or "").strip()
|
| 870 |
+
if not seq:
|
| 871 |
+
return jsonify({"error": "missing 'sequence'"}), 400
|
| 872 |
+
if len(seq) > 1_000_000:
|
| 873 |
+
return jsonify({"error": "input_too_large"}), 400
|
| 874 |
+
|
| 875 |
+
try:
|
| 876 |
+
max_results = int(body.get("max_results", 50))
|
| 877 |
+
except (TypeError, ValueError):
|
| 878 |
+
max_results = 50
|
| 879 |
+
max_results = max(1, min(500, max_results))
|
| 880 |
+
|
| 881 |
+
try:
|
| 882 |
+
guides = _find_crispr_guides(seq, max_results=max_results)
|
| 883 |
+
except ValueError as exc:
|
| 884 |
+
return jsonify({"error": str(exc)}), 400
|
| 885 |
+
|
| 886 |
+
import csv as _csv
|
| 887 |
+
from io import StringIO as _SIO
|
| 888 |
+
sio = _SIO()
|
| 889 |
+
# Excel-friendly: UTF-8 BOM + comma separator (same convention
|
| 890 |
+
# as the directed-evolution library exports — see codon.py).
|
| 891 |
+
sio.write("")
|
| 892 |
+
writer = _csv.writer(sio)
|
| 893 |
+
for row in _crispr_csv_rows(guides):
|
| 894 |
+
writer.writerow(row)
|
| 895 |
+
body_bytes = sio.getvalue().encode("utf-8")
|
| 896 |
+
|
| 897 |
+
from flask import make_response as _mr
|
| 898 |
+
resp = _mr(body_bytes)
|
| 899 |
+
resp.headers["Content-Type"] = "text/csv; charset=utf-8"
|
| 900 |
+
resp.headers["Content-Disposition"] = (
|
| 901 |
+
f'attachment; filename="turingdna_crispr_guides.csv"'
|
| 902 |
+
)
|
| 903 |
+
return resp
|
| 904 |
+
|
| 905 |
# NOTE: /api/library, /api/library/<filename> (GET + DELETE), and
|
| 906 |
# /api/shutdown were removed on 2026-05-26 per SECURITY_AUDIT findings
|
| 907 |
# C2 + C3.
|