WINTER4000 commited on
Commit
e64c086
·
verified ·
1 Parent(s): feeac92

CRISPR MVP: Cas9 knockout gRNA designer (sign-in gated, free for accounts)

Browse files
Files changed (1) hide show
  1. 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.