Spaces:
Running
Running
Download dee/server.py from WINTER4000/syntheogenesis: direct link, hf CLI and curl.
- Browser
- Download file 366 kB
-
https://huggingface.co/spaces/WINTER4000/syntheogenesis/resolve/main/dee/server.py
- Command line
-
hf download hf://spaces/WINTER4000/syntheogenesis/dee/server.py
-
curl -L -o server.py https://huggingface.co/spaces/WINTER4000/syntheogenesis/resolve/main/dee/server.py
366 kB
| """Local web UI for the Digital Evolution Engine. | |
| Single-user Flask server intended to be launched by the desktop ``.app`` and | |
| opened in the user's browser. Stateful by design (long-running jobs are kept | |
| in memory, results held until a new job replaces them) — this is *not* a | |
| multi-tenant service. | |
| API surface: | |
| POST /api/preview parse a sequence (file upload OR JSON body) and | |
| return its metadata + any CDS features. | |
| POST /api/run enqueue a pipeline job. Returns immediately with | |
| a job_id; the work runs in a background thread. | |
| GET /api/status/:id poll job progress. | |
| GET /api/result/:id fetch the variant table once a job has finished. | |
| GET /api/download/:id?format=csv|fasta download outputs. | |
| POST /api/shutdown cleanly stop the server (used by the desktop app | |
| when the user explicitly quits). | |
| """ | |
| from __future__ import annotations | |
| import gzip | |
| import hashlib | |
| import io | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import tempfile | |
| import threading | |
| import time | |
| import uuid | |
| from collections import deque | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional | |
| from flask import Flask, Response, g, jsonify, request, send_file, send_from_directory | |
| from dee import auth as _auth | |
| from dee.core import orchestrator as _orch | |
| from dee.core.crispr import find_guides as _find_crispr_guides, guides_to_csv_rows as _crispr_csv_rows | |
| from dee.core.codon import ( | |
| DEFAULT_FORBIDDEN_SITES, | |
| pcr_metrics, | |
| reverse_translate, | |
| scrub_restriction_sites, | |
| variants_to_dataframe, | |
| write_library_csv, | |
| ) | |
| from dee.core.sequence import ( | |
| SequenceValidationError, | |
| find_orfs_in_dna, | |
| list_cds_features, | |
| parse_input, | |
| ) | |
| from dee.models.scorer import top_percentile_pool | |
| from dee.optimizer.search import SearchConfig, evolve | |
| logger = logging.getLogger("dee.server") | |
| STATIC_DIR = Path(__file__).resolve().parent / "static" | |
| # Production assets are minified on the fly (cached by mtime) so the served JS/CSS | |
| # is compact + comment-free — source files stay the single source of truth. | |
| # Conservative minifiers (rjsmin/rcssmin); any failure falls back to the raw file. | |
| _MIN_CACHE: Dict[str, tuple] = {} | |
| # Gzipped bodies for immutable assets, keyed by a hash OF THE BYTES rather | |
| # than by path: content-addressed means it can never go stale against an | |
| # edited file, which a path/?v= key does during local development. | |
| _GZIP_CACHE: Dict[bytes, bytes] = {} | |
| def _minified_asset(filename: str) -> Optional[str]: | |
| path = STATIC_DIR / filename | |
| try: | |
| mtime = path.stat().st_mtime | |
| except OSError: | |
| return None | |
| ent = _MIN_CACHE.get(filename) | |
| if ent and ent[0] == mtime: | |
| return ent[1] | |
| try: | |
| src = path.read_text(encoding="utf-8") | |
| if filename.endswith(".js"): | |
| import rjsmin | |
| out = rjsmin.jsmin(src) | |
| else: | |
| import rcssmin | |
| out = rcssmin.cssmin(src) | |
| _MIN_CACHE[filename] = (mtime, out) | |
| return out | |
| except Exception: # noqa: BLE001 — never let minification break asset serving | |
| logger.exception("minify failed for %s; serving raw", filename) | |
| return None | |
| def _clean_html(html: str) -> str: | |
| """Strip developer HTML comments + minify inline <script>/<style> so the | |
| SERVED page carries no internal engineering notes (source stays commented). | |
| Protects script/style regions before removing comments; keeps conditional | |
| comments; minifies inline JS/CSS with rjsmin/rcssmin.""" | |
| import re as _re | |
| blocks: list = [] | |
| def _stash(m): | |
| blocks.append(m.group(0)) | |
| return f"\x00{len(blocks) - 1}\x00" | |
| tmp = _re.sub(r'<script\b[^>]*>.*?</script>|<style\b[^>]*>.*?</style>', | |
| _stash, html, flags=_re.S | _re.I) | |
| tmp = _re.sub(r'<!--(?!\[if).*?-->', '', tmp, flags=_re.S) # drop HTML comments (keep conditional) | |
| def _restore(m): | |
| blk = blocks[int(m.group(1))] | |
| sm = _re.match(r'(<script\b[^>]*>)(.*)(</script>)', blk, flags=_re.S | _re.I) | |
| if sm: | |
| if _re.search(r'\bsrc\s*=', sm.group(1), _re.I): | |
| return blk | |
| try: | |
| import rjsmin | |
| return sm.group(1) + rjsmin.jsmin(sm.group(2)) + sm.group(3) | |
| except Exception: # noqa: BLE001 | |
| return blk | |
| cm = _re.match(r'(<style\b[^>]*>)(.*)(</style>)', blk, flags=_re.S | _re.I) | |
| if cm: | |
| try: | |
| import rcssmin | |
| return cm.group(1) + rcssmin.cssmin(cm.group(2)) + cm.group(3) | |
| except Exception: # noqa: BLE001 | |
| return blk | |
| return blk | |
| return _re.sub(r'\x00(\d+)\x00', _restore, tmp) | |
| def _no_cache(resp: Response) -> Response: | |
| """Make an HTML shell revalidate on every load. | |
| Assets are cache-busted by the `?v=` key in index.html, so a hard | |
| max-age on them is safe — but only if the browser actually re-reads the | |
| HTML that carries the new key. With no Cache-Control at all a browser is | |
| free to heuristically reuse the shell, which pins a visitor to the old | |
| key set and hides a deploy for as long as that guess lasts. "no-cache" | |
| still lets the copy be stored; it just requires revalidation first. | |
| """ | |
| resp.headers["Cache-Control"] = "no-cache, must-revalidate" | |
| return resp | |
| def _cleaned_html(filename: str) -> Optional[str]: | |
| """Cached comment-stripped HTML (mtime-keyed). None on failure → serve raw.""" | |
| path = STATIC_DIR / filename | |
| try: | |
| mtime = path.stat().st_mtime | |
| except OSError: | |
| return None | |
| ent = _MIN_CACHE.get("html::" + filename) | |
| if ent and ent[0] == mtime: | |
| return ent[1] | |
| try: | |
| out = _clean_html(path.read_text(encoding="utf-8")) | |
| _MIN_CACHE["html::" + filename] = (mtime, out) | |
| return out | |
| except Exception: # noqa: BLE001 | |
| logger.exception("html clean failed for %s; serving raw", filename) | |
| return None | |
| STATE_DIR = Path.home() / ".dee" / "state" | |
| OUTPUT_DIR = Path.home() / ".dee" / "output" | |
| DESKTOP_DIR = Path.home() / "Desktop" | |
| STATE_DIR.mkdir(parents=True, exist_ok=True) | |
| OUTPUT_DIR.mkdir(parents=True, exist_ok=True) | |
| def _patch_ssl_for_macos() -> None: | |
| """Some macOS Python builds ship without a system CA bundle, so | |
| ``urllib.request.urlopen`` (which Biopython's NCBIWWW.qblast uses | |
| internally) blows up with ``CERTIFICATE_VERIFY_FAILED: self-signed | |
| certificate in certificate chain`` when it tries to reach | |
| https://blast.ncbi.nlm.nih.gov. | |
| Fix it by installing a default urllib opener whose HTTPS handler uses | |
| certifi's bundled CA roots (certifi is already a transitive dep of | |
| huggingface-hub). Idempotent and safe to call multiple times. | |
| """ | |
| import ssl | |
| import urllib.request | |
| try: | |
| import certifi | |
| except ImportError: # pragma: no cover — certifi is a dep of HF hub | |
| logger.warning("certifi not installed; BLAST may fail on macOS Pythons.") | |
| return | |
| try: | |
| ctx = ssl.create_default_context(cafile=certifi.where()) | |
| https_handler = urllib.request.HTTPSHandler(context=ctx) | |
| opener = urllib.request.build_opener(https_handler) | |
| urllib.request.install_opener(opener) | |
| # Also override the module-level default context so callers that go | |
| # around urllib (e.g. http.client direct) still get the certifi roots. | |
| ssl._create_default_https_context = lambda: ssl.create_default_context( | |
| cafile=certifi.where() | |
| ) | |
| logger.info("Patched urllib SSL context with certifi CA bundle.") | |
| except Exception as exc: # noqa: BLE001 — best-effort patch | |
| logger.warning("Could not patch SSL CA bundle: %s", exc) | |
| _patch_ssl_for_macos() | |
| # ----------------------------------------------------------------- job state | |
| class JobState: | |
| job_id: str | |
| status: str = "pending" | |
| progress: float = 0.0 | |
| message: str = "Queued." | |
| started_at: float = field(default_factory=time.time) | |
| finished_at: Optional[float] = None | |
| wt_identifier: str = "" | |
| wt_protein: str = "" | |
| csv_path: Optional[str] = None | |
| desktop_path: Optional[str] = None | |
| variants: Optional[List[Dict[str, Any]]] = None | |
| library_id: Optional[str] = None # set on save → lets the client log outcomes | |
| # The project (construct) the run was started in. The finished library is | |
| # filed under it, so reopening the project can bring the library back on | |
| # any machine. Only ever an id the client sent; ownership is checked when | |
| # the library is attached (auth.attach_library), not trusted here. | |
| construct_id: Optional[str] = None | |
| error: Optional[str] = None | |
| # Frozen copy of the user's settings + the parameters the engine actually | |
| # used. Surfaces in the UI's metadata pills and the auto-generated | |
| # methods paragraph; also a paper-trail for reproducibility. | |
| settings_used: Dict[str, Any] = field(default_factory=dict) | |
| # Supabase user_id captured at request time, used to attribute the | |
| # library to the user when the pipeline finishes. None for anonymous | |
| # runs; the save-to-Storage path no-ops in that case. | |
| user_id: Optional[str] = None | |
| # Whether/how much the cross-user field-wide aggregate (dee.core.aggregate) | |
| # contributed to this run's scores — was only ever recorded transiently in | |
| # `message` (overwritten by the next status update, so gone by "done"). | |
| # Structured here so /api/result can surface it after the job finishes. | |
| global_prior_info: Optional[Dict[str, Any]] = None | |
| def elapsed(self) -> float: | |
| end = self.finished_at if self.finished_at else time.time() | |
| return round(end - self.started_at, 1) | |
| def public(self) -> Dict[str, Any]: | |
| # csv_path / desktop_path intentionally omitted (audit H2): they | |
| # leak the server's filesystem layout. They remain on the dataclass | |
| # for internal use by /api/download and the library-save worker. | |
| return { | |
| "job_id": self.job_id, | |
| "status": self.status, | |
| "progress": round(self.progress, 3), | |
| "message": self.message, | |
| "started_at": self.started_at, | |
| "elapsed_seconds": self.elapsed(), | |
| "wt_identifier": self.wt_identifier, | |
| "wt_protein_length": len(self.wt_protein), | |
| "error": self.error, | |
| } | |
| _JOBS: Dict[str, JobState] = {} | |
| _JOBS_LOCK = threading.Lock() | |
| # Per-job mutex for editing variant rows. The edit handler reads | |
| # job.variants, mutates one row, and rewrites the CSV on disk; without | |
| # this lock two near-simultaneous edits could interleave and write a | |
| # torn library file. _EDIT_LOCKS is itself guarded by _JOBS_LOCK on | |
| # create. Holds one lock per job for the lifetime of the process — | |
| # small enough not to bother evicting. | |
| _EDIT_LOCKS: Dict[str, threading.Lock] = {} | |
| def _edit_lock_for(job_id: str) -> threading.Lock: | |
| with _JOBS_LOCK: | |
| lock = _EDIT_LOCKS.get(job_id) | |
| if lock is None: | |
| lock = threading.Lock() | |
| _EDIT_LOCKS[job_id] = lock | |
| return lock | |
| def _get_job(job_id: str) -> Optional[JobState]: | |
| with _JOBS_LOCK: | |
| return _JOBS.get(job_id) | |
| _JOBS_MAX = 2000 # cap retained jobs so a long-lived process can't grow forever | |
| def _put_job(job: JobState) -> None: | |
| with _JOBS_LOCK: | |
| _JOBS[job.job_id] = job | |
| # Bound memory: previously every job + its edit lock was kept for the | |
| # process lifetime. Dicts preserve insertion order, so evict the oldest | |
| # (long-finished) jobs once we're over the cap. | |
| while len(_JOBS) > _JOBS_MAX: | |
| old_id = next(iter(_JOBS)) | |
| _JOBS.pop(old_id, None) | |
| _EDIT_LOCKS.pop(old_id, None) | |
| # ----------------------------------------------------------------- preview cache | |
| # Uploaded files / pasted strings are cached briefly so /api/preview and the | |
| # subsequent /api/run don't have to re-receive the same payload. Keyed by a | |
| # session_id we hand back to the browser. | |
| _SESSIONS: Dict[str, Dict[str, Any]] = {} | |
| _SESSIONS_LOCK = threading.Lock() | |
| # ----------------------------------------------------------------- BLAST jobs | |
| class BlastJob: | |
| """One asynchronous NCBI-BLAST submission. Keyed by sequence hash so | |
| repeat lookups on the same WT protein return instantly from cache.""" | |
| job_id: str | |
| seq_hash: str | |
| status: str = "pending" | |
| started_at: float = field(default_factory=time.time) | |
| finished_at: Optional[float] = None | |
| hits: List[Dict[str, Any]] = field(default_factory=list) | |
| error: Optional[str] = None | |
| def elapsed(self) -> float: | |
| end = self.finished_at if self.finished_at else time.time() | |
| return round(end - self.started_at, 1) | |
| def public(self) -> Dict[str, Any]: | |
| return { | |
| "job_id": self.job_id, | |
| "status": self.status, | |
| "elapsed_seconds": self.elapsed(), | |
| "hits": self.hits, | |
| "error": self.error, | |
| } | |
| _BLAST_CACHE: Dict[str, BlastJob] = {} # seq_hash -> BlastJob | |
| _BLAST_BY_ID: Dict[str, BlastJob] = {} # job_id -> BlastJob | |
| _BLAST_LOCK = threading.Lock() | |
| def _hash_sequence(protein: str) -> str: | |
| return hashlib.sha256(protein.encode("utf-8")).hexdigest()[:16] | |
| def _extract_organism(hit_def: str) -> str: | |
| """NCBI hit descriptions look like 'GFP [Aequorea victoria]'. Pull out | |
| the bracketed organism if present; fall back to empty string.""" | |
| m = re.search(r"\[([^\]]+)\]", hit_def or "") | |
| return m.group(1) if m else "" | |
| def _trim_description(hit_def: str) -> str: | |
| """Strip the trailing '[organism]' from the description for cleaner | |
| display — the organism is shown separately.""" | |
| return re.sub(r"\s*\[[^\]]+\]\s*$", "", hit_def or "").strip() | |
| def _gene_token(raw) -> str: | |
| """Best-effort gene symbol from a construct's wt_identifier ('>MC1R', 'MSTN', | |
| or a FASTA header). Returns a clean symbol resolve_uniprot will accept, or '' | |
| — anything that isn't a real symbol becomes 'no structure', never a wrong | |
| one. Same shape the CRISPR structure route validates against.""" | |
| s = re.sub(r"^[>\s]+", "", str(raw or "")).strip() | |
| s = s.split()[0] if s else "" | |
| return s if re.match(r"^[A-Za-z0-9._-]{1,32}$", s) else "" | |
| # gene+organism -> UniProt accession (or None), cached so a grid of construct | |
| # thumbnails resolves each protein once, not once per card per catalog open. | |
| _CONSTRUCT_ACC_CACHE: Dict[tuple, Optional[str]] = {} | |
| def _extract_uniprot(alignment) -> Optional[str]: | |
| """Pull a UniProt accession out of an NCBI BLAST alignment if there is one. | |
| NCBI hit_id strings look like: | |
| ``sp|P12345|HUMAN_GENE`` (SwissProt — has UniProt accession) | |
| ``ref|NP_001234.1|`` (RefSeq — no direct UniProt mapping) | |
| ``pdb|1ABC|A`` (PDB) | |
| ``gb|AAA12345.1|`` (GenBank) | |
| AlphaFold-DB only indexes UniProt entries, so we only return something | |
| when we see ``sp|`` (SwissProt) or ``tr|`` (TrEMBL) prefixes. | |
| Audit M4: the previous regex ``^[A-Z0-9]+(?:-\\d+)?$`` matched the | |
| modern accession formats only — the canonical ``P12345`` / | |
| ``Q9NPB9`` 6-char form, the 10-char form (``A0A1B2C3D4``), and the | |
| isoform suffix (``-2``). It missed two legitimate formats still | |
| occasionally seen in NCBI hits: | |
| * Lowercase-letter accessions returned by some legacy mirrors | |
| (``p12345``) — UniProt's own API is case-insensitive on lookup. | |
| * Underscore-prefixed entry names ("``HUMAN_INSR``" without the | |
| accession token) where the upstream RefSeq pipe is missing. | |
| Now the regex accepts case-insensitive accessions and validates the | |
| structure rather than just the alphabet, and we additionally try the | |
| third pipe-segment as an entry name in case the second segment | |
| didn't match. | |
| """ | |
| # UniProt accession structure (UniProt knowledgebase, kept in sync with | |
| # https://www.uniprot.org/help/accession_numbers): 6-char or 10-char, | |
| # alternating letter / digit groups. The detail is too fiddly to | |
| # encode strictly in a regex without rejecting legitimate hits, so | |
| # we use the documented "anything matching this character class of | |
| # length 6 or 10" form and an optional isoform suffix. | |
| _ACCESSION_RE = re.compile(r"^([A-Za-z0-9]{6}|[A-Za-z0-9]{10})(?:-\d+)?$") | |
| hit_id = getattr(alignment, "hit_id", "") or "" | |
| for token in hit_id.split(";"): | |
| token = token.strip() | |
| parts = token.split("|") | |
| # Format: sp|<accession>|<entry_name> | |
| if len(parts) >= 2 and parts[0].lower() in ("sp", "tr"): | |
| candidate = parts[1].upper() | |
| m = _ACCESSION_RE.match(candidate) | |
| if m: | |
| # Normalize to the canonical upper-case form. | |
| return m.group(0).upper() | |
| return None | |
| def _swissprot_uniprot(protein: str) -> Optional[Dict[str, Any]]: | |
| """Resolve the nearest REVIEWED (Swiss-Prot) protein for ``protein``. | |
| Why this exists: the primary search hits ``nr``, which is excellent for | |
| *identifying* a protein but is crowded with redundant GenBank / TPA records | |
| (e.g. thousands of near-identical Cas9 orthologs deposited as ``tpg|HER…``). | |
| Those have no UniProt accession, so a heavily-sequenced protein can come | |
| back from ``nr`` with **zero** UniProt hits in the top results even though a | |
| structure plainly exists — the one curated UniProt entry is simply buried. | |
| Every UniProtKB/Swiss-Prot entry has an AlphaFold-DB model, so a homology | |
| search against the small, curated ``swissprot`` database reliably surfaces | |
| the closest *known* structure (SpCas9 → Q99ZW2). Returns the accession plus | |
| identity / organism for honest labelling, or ``None`` if nothing close. | |
| """ | |
| try: | |
| from Bio.Blast import NCBIWWW, NCBIXML | |
| handle = NCBIWWW.qblast( | |
| program="blastp", | |
| database="swissprot", | |
| sequence=protein, | |
| hitlist_size=1, | |
| expect=1e-3, | |
| ) | |
| for record in NCBIXML.parse(handle): | |
| for alignment in record.alignments[:1]: | |
| if not alignment.hsps: | |
| continue | |
| acc = _extract_uniprot(alignment) | |
| if not acc: | |
| continue | |
| hsp = alignment.hsps[0] | |
| return { | |
| "uniprot": acc, | |
| "identity_pct": round( | |
| 100.0 * hsp.identities / max(1, hsp.align_length), 1 | |
| ), | |
| "organism": _extract_organism(alignment.hit_def), | |
| "description": _trim_description(alignment.hit_def), | |
| } | |
| break # only one query record | |
| except Exception: # noqa: BLE001 — best-effort; absence is handled honestly | |
| logger.exception("Swiss-Prot structure-resolution BLAST failed.") | |
| return None | |
| def _attach_structure_homolog(job: BlastJob, protein: str) -> None: | |
| """If no parsed hit carries a UniProt accession, fall back to a Swiss-Prot | |
| homology search and pin the resolved accession onto the top hit so the | |
| frontend AlphaFold embed lights up. Records the homolog's organism + | |
| identity separately so the UI can label it honestly as the *closest known* | |
| structure rather than the exact query.""" | |
| if not job.hits or any(h.get("uniprot") for h in job.hits): | |
| return | |
| sp = _swissprot_uniprot(protein) | |
| if not sp: | |
| return | |
| acc = sp["uniprot"] | |
| top = job.hits[0] | |
| top["uniprot"] = acc | |
| top["alphafold_url"] = f"https://alphafold.ebi.ac.uk/files/AF-{acc}-F1-model_v6.pdb" | |
| top["alphafold_page"] = f"https://alphafold.ebi.ac.uk/entry/{acc}" | |
| # Provenance: this UniProt entry is the nearest reviewed homolog, NOT the | |
| # nr top hit itself — keep its organism / identity distinct for the label. | |
| top["uniprot_via"] = "swissprot_homolog" | |
| top["uniprot_organism"] = sp.get("organism") or "" | |
| top["uniprot_identity_pct"] = sp.get("identity_pct") | |
| top["uniprot_description"] = sp.get("description") or "" | |
| def _run_blast(job: BlastJob, protein: str) -> None: | |
| """Submit to NCBI BLAST and parse the top hits. | |
| Uses Biopython's NCBIWWW wrapper (the public web service). Polite delays | |
| and rate limits are imposed by NCBI server-side; we keep hitlist small | |
| and expect strict to minimize bandwidth. | |
| """ | |
| try: | |
| # Biopython is heavy; import lazily so the rest of the server boots | |
| # without a hard Bio dependency at startup. | |
| from Bio.Blast import NCBIWWW, NCBIXML | |
| job.status = "submitting" | |
| result_handle = NCBIWWW.qblast( | |
| program="blastp", | |
| database="nr", | |
| sequence=protein, | |
| hitlist_size=8, | |
| expect=1e-5, | |
| ) | |
| job.status = "parsing" | |
| for record in NCBIXML.parse(result_handle): | |
| for alignment in record.alignments[:8]: | |
| if not alignment.hsps: | |
| continue | |
| # Use the best (lowest-evalue) HSP from each alignment. | |
| hsp = alignment.hsps[0] | |
| identity_pct = round( | |
| 100.0 * hsp.identities / max(1, hsp.align_length), 1 | |
| ) | |
| coverage_pct = round( | |
| 100.0 * hsp.align_length / max(1, len(protein)), 1 | |
| ) | |
| uniprot = _extract_uniprot(alignment) | |
| job.hits.append( | |
| { | |
| "accession": getattr(alignment, "accession", "") or "", | |
| "hit_id": getattr(alignment, "hit_id", "") or "", | |
| "description": _trim_description(alignment.hit_def), | |
| "organism": _extract_organism(alignment.hit_def), | |
| "length": alignment.length, | |
| "identity_pct": identity_pct, | |
| "coverage_pct": coverage_pct, | |
| "evalue": float(hsp.expect), | |
| "bit_score": float(hsp.bits), | |
| # UniProt accession when available — gates the | |
| # AlphaFold-DB structure embed on the frontend. | |
| "uniprot": uniprot, | |
| # Fallback URL only — the client re-resolves the current | |
| # model version via the AlphaFold prediction API | |
| # (the DB bumps versions over time: v4 → v6 → …). | |
| "alphafold_url": ( | |
| f"https://alphafold.ebi.ac.uk/files/AF-{uniprot}-F1-model_v6.pdb" | |
| if uniprot else None | |
| ), | |
| "alphafold_page": ( | |
| f"https://alphafold.ebi.ac.uk/entry/{uniprot}" | |
| if uniprot else None | |
| ), | |
| } | |
| ) | |
| break # only one query record | |
| # nr is great for ID but buries the curated UniProt entry under | |
| # redundant GenBank/TPA records — so if nothing came back with a | |
| # UniProt accession, resolve the closest reviewed homolog (which always | |
| # has an AlphaFold model) before finishing. | |
| _attach_structure_homolog(job, protein) | |
| job.status = "done" | |
| except Exception as exc: # noqa: BLE001 — surface anything as an error | |
| logger.exception("BLAST failed.") | |
| job.status = "error" | |
| job.error = f"{type(exc).__name__}: {exc}" | |
| finally: | |
| job.finished_at = time.time() | |
| # --------------------------------------------------- Primer Picker (multi-BLAST) | |
| # Batch primer analysis: the deterministic scoring lives in core/primers.py; | |
| # here we own the async NCBI specificity scan. All primers go to NCBI in ONE | |
| # blastn-short submission (a multi-FASTA query) — literally "blast them all | |
| # together" — and NCBIXML yields one record per primer, in query order. | |
| # Organism targeting for the specificity scan — mirrors Primer-BLAST's | |
| # "Organism" field. Restricts the BLAST via an Entrez query so off-target = | |
| # "would mis-prime in THIS genome", not "matches something, somewhere, in nt". | |
| _PRIMER_ORGANISMS = { | |
| "human": '"Homo sapiens"[Organism]', | |
| "mouse": '"Mus musculus"[Organism]', | |
| "ecoli": '"Escherichia coli"[Organism]', | |
| "yeast": '"Saccharomyces cerevisiae"[Organism]', | |
| "rat": '"Rattus norvegicus"[Organism]', | |
| "zebrafish": '"Danio rerio"[Organism]', | |
| "all": "", # whole nt — broad, noisier | |
| } | |
| class PrimerJob: | |
| job_id: str | |
| seq_hash: str | |
| user_id: Optional[str] = None # owner — every job has one; /api/primers/analyze | |
| # is sign-in gated, unlike the anonymous-eligible | |
| # /api/identify and /api/run jobs | |
| status: str = "pending" # pending|scanning|done|error | |
| started_at: float = field(default_factory=time.time) | |
| finished_at: Optional[float] = None | |
| primers: List[Dict[str, Any]] = field(default_factory=list) # scored, pre-spec | |
| template: str = "" | |
| organism: str = "human" | |
| three_prime_strict: bool = False | |
| warnings: List[str] = field(default_factory=list) | |
| specificity: Optional[Dict[str, Dict[str, Any]]] = None # id -> {...} | |
| products: List[Dict[str, Any]] = field(default_factory=list) # in-silico PCR | |
| error: Optional[str] = None | |
| def elapsed(self) -> float: | |
| end = self.finished_at if self.finished_at else time.time() | |
| return round(end - self.started_at, 1) | |
| def result(self) -> Dict[str, Any]: | |
| from .core import primers as _P | |
| out = _P.rank(self.primers, self.template, self.specificity, self.products) | |
| out["organism"] = self.organism | |
| return out | |
| def public(self) -> Dict[str, Any]: | |
| return { | |
| "job_id": self.job_id, | |
| "status": self.status, | |
| "elapsed_seconds": self.elapsed(), | |
| "warnings": self.warnings, | |
| "specificity_done": self.specificity is not None, | |
| "result": self.result(), | |
| "error": self.error, | |
| } | |
| _PRIMER_CACHE: Dict[str, PrimerJob] = {} | |
| _PRIMER_BY_ID: Dict[str, PrimerJob] = {} | |
| _PRIMER_LOCK = threading.Lock() | |
| _3P_STRICT_LEN = 5 # strict mode: the 3'-terminal window that must match exactly | |
| def _three_prime_clean(hsp, plen: int, k: int = _3P_STRICT_LEN) -> bool: | |
| """True if the primer's 3'-terminal ``k`` bases match the subject perfectly | |
| (no mismatch, no gap). The 3' end is what licenses extension, so this is the | |
| high-confidence priming test. Reads the tail of the BLAST alignment strings; | |
| falls back to True if they're unavailable (degrade to lenient).""" | |
| if hsp.query_end < plen: # the very 3' base wasn't even aligned | |
| return False | |
| match = getattr(hsp, "match", None) | |
| if not match or len(match) < k: | |
| return True # can't tell → don't over-reject | |
| if " " in match[-k:]: # a mismatch column in the last k bases | |
| return False | |
| q = getattr(hsp, "query", "") or "" | |
| s = getattr(hsp, "sbjct", "") or "" | |
| if "-" in q[-k:] or "-" in s[-k:]: # a gap in the last k bases | |
| return False | |
| return True | |
| def _hsp_to_event(alignment, hsp, primer: Dict[str, Any], | |
| strict_3p: bool = False) -> Optional[Dict[str, Any]]: | |
| """Turn one BLAST HSP into a 'priming event' for in-silico PCR — but only if | |
| it's 3'-anchored enough to actually extend. Returns None otherwise. | |
| Direction: the primer's 3' end maps to ``hsp.sbjct_end`` in both strand | |
| cases; the polymerase runs toward higher coords when sbjct_end > sbjct_start | |
| ('+') and toward lower coords otherwise ('-').""" | |
| plen = len(primer["seq"]) | |
| ident = hsp.identities / max(1, hsp.align_length) | |
| # Must cover the primer's 3' end and be a near-full, high-identity match. | |
| if ident < 0.90 or hsp.align_length < plen - 3 or hsp.query_end < plen - 1: | |
| return None | |
| # Strict mode: also require a perfect 3'-terminal match (cuts borderline | |
| # sites that share a body but mismatch at the 3' end and wouldn't extend). | |
| if strict_3p and not _three_prime_clean(hsp, plen): | |
| return None | |
| ss, se = hsp.sbjct_start, hsp.sbjct_end | |
| direction = "+" if se >= ss else "-" | |
| return { | |
| "primer_id": primer["id"], | |
| "name": primer.get("name", primer["id"]), | |
| "pos5": ss, # 5' end of the primer on the subject | |
| "pos3": se, # 3' end (where extension starts) | |
| "dir": direction, | |
| "title": _trim_description(getattr(alignment, "hit_def", ""))[:80], | |
| "organism": _extract_organism(getattr(alignment, "hit_def", "")), | |
| "identity_pct": round(100.0 * ident, 1), | |
| } | |
| def _run_primer_blast(job: PrimerJob) -> None: | |
| """One blastn-short submission for ALL primers, then in-silico PCR. | |
| Pairs every forward-oriented priming site with every downstream reverse | |
| site on the same subject (within MAX_PRODUCT bp) to predict the actual PCR | |
| products — the same specificity check Primer-BLAST runs, done here so the | |
| user never leaves the site.""" | |
| from .core import primers as _P | |
| try: | |
| from Bio.Blast import NCBIWWW, NCBIXML | |
| job.status = "scanning" | |
| fasta = "\n".join(f">{p['id']}\n{p['seq']}" for p in job.primers) | |
| qblast_kw = dict( | |
| program="blastn", | |
| database="nt", | |
| sequence=fasta, | |
| megablast=False, # short, sensitive search | |
| word_size=7, | |
| expect=1000, | |
| nucl_reward=1, | |
| nucl_penalty=-3, | |
| hitlist_size=50, | |
| ) | |
| entrez = _PRIMER_ORGANISMS.get(job.organism, "") | |
| if entrez: | |
| qblast_kw["entrez_query"] = entrez | |
| handle = NCBIWWW.qblast(**qblast_kw) | |
| spec: Dict[str, Dict[str, Any]] = {} | |
| events_by_subject: Dict[str, List[Dict[str, Any]]] = {} | |
| records = list(NCBIXML.parse(handle)) | |
| # Records come back in query order; align them to job.primers. | |
| for i, record in enumerate(records): | |
| if i >= len(job.primers): | |
| break | |
| p = job.primers[i] | |
| strong, top = 0, [] | |
| for alignment in record.alignments: | |
| acc = getattr(alignment, "accession", "") or getattr(alignment, "hit_id", "") | |
| hit_strong = False | |
| for hsp in alignment.hsps: | |
| ev = _hsp_to_event(alignment, hsp, p, job.three_prime_strict) | |
| if ev: | |
| events_by_subject.setdefault(acc, []).append(ev) | |
| hit_strong = True | |
| if hit_strong: | |
| strong += 1 | |
| if len(top) < 5: | |
| top.append({ | |
| "title": _trim_description(getattr(alignment, "hit_def", ""))[:80], | |
| "organism": _extract_organism(getattr(alignment, "hit_def", "")), | |
| }) | |
| spec[p["id"]] = {"strong_offtargets": strong, "top_hits": top} | |
| # In-silico PCR across ALL primers. rank() collapses these redundant | |
| # hits into DISTINCT amplicons and splits intended vs off-target, so we | |
| # just hand it the raw product list here. | |
| job.products = _P.insilico_pcr(events_by_subject) | |
| for p in job.primers: | |
| spec.setdefault(p["id"], {"strong_offtargets": 0, "top_hits": []}) | |
| job.specificity = spec | |
| job.status = "done" | |
| except Exception as exc: # noqa: BLE001 | |
| logger.exception("Primer BLAST failed.") | |
| job.status = "error" | |
| job.error = f"{type(exc).__name__}: {exc}" | |
| finally: | |
| job.finished_at = time.time() | |
| _SESSION_TTL_SECONDS = 6 * 60 * 60 # 6 h — long enough to cover lunch, calls, etc. | |
| def _new_session(payload: Dict[str, Any]) -> str: | |
| """Cache an uploaded sequence under a fresh session id and prune stale entries. | |
| Audit M3: previously the TTL was 30 minutes, which kicked the user out | |
| if they paused to refine settings, take a call, or step away for lunch. | |
| Bumping to 6 hours covers a typical workday's "designed a library | |
| before lunch, came back to download it" pattern without unbounded | |
| memory growth (sessions only hold the uploaded path + ORF map). | |
| """ | |
| sid = uuid.uuid4().hex[:12] | |
| payload["created_at"] = time.time() | |
| with _SESSIONS_LOCK: | |
| _SESSIONS[sid] = payload | |
| cutoff = time.time() - _SESSION_TTL_SECONDS | |
| stale = [k for k, v in _SESSIONS.items() if v.get("created_at", 0) < cutoff] | |
| for k in stale: | |
| _SESSIONS.pop(k, None) | |
| return sid | |
| def _get_session(sid: str) -> Optional[Dict[str, Any]]: | |
| with _SESSIONS_LOCK: | |
| return _SESSIONS.get(sid) | |
| # ----------------------------------------------------------------- Flask app | |
| # ─── Rate limiting (in-process, per-IP sliding window) ──────────────── | |
| # A single HF Space container serves everyone, and the heavy endpoints | |
| # (run, identify/BLAST, primer design) are CPU-bound and reachable on the | |
| # anonymous trial — so an unthrottled client can starve every other user or | |
| # run up compute cost. This is a lightweight in-memory limiter: no Redis, | |
| # bounded memory, and fail-open on its own errors (a limiter hiccup must never | |
| # block a legitimate request). Tune via env without a code change. | |
| _RL_LOCK = threading.Lock() | |
| _RL_HITS: "Dict[str, deque]" = {} # client-key -> deque[monotonic timestamps] | |
| _RL_MAX_KEYS = int(os.environ.get("TURINGDNA_RL_MAX_KEYS", "20000")) # evict if exceeded | |
| _RL_DISABLED = os.environ.get("TURINGDNA_RL_DISABLED", "") == "1" # escape hatch | |
| # (max_requests, window_seconds) per path prefix. Longest matching prefix wins; | |
| # the "/api" entry is the catch-all default. Heavy endpoints are tighter. | |
| _RL_RULES = [ | |
| ("/api/run", (20, 60)), # full pipeline — expensive | |
| ("/api/de/epistasis", (8, 60)), # up to 12 ESM forward passes — heavy | |
| ("/api/de/round2", (8, 60)), # one ESM pass + an evolutionary search | |
| # (attacker-tunable up to steps=5000, | |
| # restarts=24) + up to 8 more ESM passes | |
| # for epistasis re-ranking — same class as | |
| # /api/de/epistasis above, and it was | |
| # missing here, so it fell through to the | |
| # 120/60 default. That matters more than a | |
| # normal rate-limit gap: score_guarded() | |
| # blocks on ONE process-wide semaphore with | |
| # no timeout on this path, and the deploy is | |
| # 1 gunicorn worker / 8 threads total — a | |
| # burst here can exhaust every thread and | |
| # stall the whole app, not just this route. | |
| # Every reopen pulls the library's CSV out of Storage server-side, so this | |
| # is an outbound fetch per call rather than a table read. Covers the list | |
| # and download routes too, which is fine: opening the drawer is one call. | |
| ("/api/library", (30, 60)), | |
| ("/api/identify", (20, 60)), # BLAST — expensive | |
| ("/api/preview", (40, 60)), | |
| ("/api/primers/local-specificity", (20, 60)), # genome scan — expensive | |
| ("/api/primers/design", (30, 60)), | |
| ("/api/primers/multiplex", (30, 60)), | |
| ("/api/primers/analyze", (40, 60)), | |
| ("/api/crispr", (40, 60)), | |
| # Evo 2 (7B) on a rented GPU — the most expensive call in the product by a | |
| # wide margin, and unlike ESM-2's Achilles tier there is no free local path | |
| # to fall back to. Tighter than every other tool bucket on purpose: this | |
| # limit is about the GPU bill, not about protecting a worker thread. | |
| ("/api/dna/generate", (6, 60)), # autoregressive — priciest | |
| ("/api/compiler", (30, 60)), # pure logic; probes DNA reach | |
| ("/api/dna", (12, 60)), | |
| ("/api/assays", (60, 60)), | |
| ("/api/de/uplift", (20, 60)), # a few DB reads, no model | |
| ("/api/de/import", (10, 60)), # one ESM pass over the WT, same | |
| # score_guarded semaphore as round 2 | |
| ("/api/certificate", (60, 60)), # pure logic, but | |
| # unauthenticated | |
| ("/api/workspaces", (60, 60)), | |
| ("/api/workspace-results", (30, 60)), | |
| ("/api/constructs", (90, 60)), # cheap CRUD; the | |
| # switcher polls it | |
| ("/api/plasmid", (60, 60)), | |
| ("/api/ping", (60, 60)), # dwell heartbeat — its own | |
| # bucket so ~2/min never eats | |
| # the shared default budget | |
| ("/api/collect", (120, 60)), # UX-behaviour collector — its | |
| # OWN generous bucket (batched | |
| # sendBeacon flushes, up to | |
| # ~1/5s + on hide) so it never | |
| # eats the shared /api budget | |
| ("/api/orchestrator/poll", (240, 60)), # event poll — spends NOTHING (reads an | |
| # in-memory event log). Fires ~1/s while | |
| # the agent works, so it needs its own | |
| # generous bucket ABOVE the billed rule | |
| # below, or a normal run throttles itself. | |
| ("/api/orchestrator/runs", (60, 60)), # history list — one cheap DB read, no | |
| # LLM. Must sit ABOVE the billed rule | |
| # below (longest prefix wins) or opening | |
| # the run menu would burn the agent's | |
| # own 12/min budget. | |
| ("/api/orchestrator", (12, 60)), # start / reply / stop — each spawns a | |
| # real billed OpenRouter loop. Tight, like | |
| # a billed LLM loop; per-run cost cap in | |
| # orchestrator.py is the second guard. | |
| ("/api/structure", (30, 60)), # public gene→AlphaFold resolve. One | |
| # outbound UniProt lookup, no LLM, no | |
| # user data; anonymous by design (see | |
| # the route). 30/min blocks scraping. | |
| ("/api/project", (30, 60)), # project CRUD (client-side v1) — cheap | |
| ("/api", (120, 60)), # default for any other API call | |
| ] | |
| # Engagement events: path → kind, logged once per SUCCESSFUL request from the | |
| # after_request hook (one place instead of touching every handler). Only these | |
| # "meaningful action" endpoints count; /api/run is excluded (public.runs is | |
| # authoritative for it, so logging it here too would double-count engagement). | |
| _EVENT_KINDS = { | |
| "/api/plasmid/import": "plasmid_map", | |
| "/api/plasmid/clone": "plasmid_clone", | |
| "/api/plasmid/save": "plasmid_save", | |
| "/api/crispr/design": "crispr_design", | |
| "/api/crispr/save": "crispr_save", | |
| "/api/primers/analyze": "primer_analyze", | |
| "/api/primers/save": "primer_save", | |
| "/api/align": "sequence_align", | |
| "/api/de/round2": "de_round2", | |
| "/api/de/import": "de_import", # the "I already have data" front door | |
| "/api/dna/score": "dna_score", | |
| "/api/dna/generate": "dna_generate", | |
| "/api/compiler/compile": "therapeutic_compile", | |
| } | |
| # Sort longest-prefix-first so the most specific rule matches. | |
| _RL_RULES.sort(key=lambda r: len(r[0]), reverse=True) | |
| def _is_private_ip(ip: str) -> bool: | |
| """True for loopback / private / link-local addresses (internal proxy hops).""" | |
| try: | |
| import ipaddress | |
| a = ipaddress.ip_address(ip) | |
| return a.is_private or a.is_loopback or a.is_link_local or a.is_reserved | |
| except ValueError: | |
| return True # not an IP → don't trust it as a client key | |
| def _client_key() -> str: | |
| """Client identifier for rate limiting. | |
| We sit behind a trusted proxy (HF ingress) that appends the real peer IP to | |
| X-Forwarded-For. The LEFTMOST hop is client-supplied and therefore spoofable | |
| — keying on it lets an attacker mint a fresh bucket per request and bypass | |
| the limiter. So we take the RIGHTMOST *public* hop instead (the one the | |
| proxy added), skipping private/loopback entries so we don't collapse every | |
| user onto a shared internal proxy IP. Falls back to remote_addr.""" | |
| fwd = request.headers.get("X-Forwarded-For", "") | |
| if fwd: | |
| parts = [p.strip() for p in fwd.split(",") if p.strip()] | |
| for ip in reversed(parts): | |
| if not _is_private_ip(ip): | |
| return ip | |
| if parts: | |
| return parts[-1] # all private — rightmost is closest to us | |
| return request.remote_addr or "unknown" | |
| def _get_nonce() -> str: | |
| """Per-request CSP nonce, memoized on flask.g. Used to allow our own inline | |
| <script> blocks under a strict script-src while blocking any injected inline | |
| script / event-handler (the app uses external JS + addEventListener only).""" | |
| import secrets | |
| from flask import g | |
| n = getattr(g, "csp_nonce", None) | |
| if not n: | |
| n = secrets.token_urlsafe(16) | |
| g.csp_nonce = n | |
| return n | |
| _CORS_ORIGIN_RE = re.compile(r"^https://([a-z0-9-]+\.)?turingdna\.com$") | |
| def _public_cors(resp: Response) -> Response: | |
| """Opt a response into cross-origin reads from the marketing site, and | |
| ONLY the marketing site — turingdna.com wants a live Field Atlas widget | |
| on the landing page, which means fetching from a different origin | |
| (winter4000-syntheogenesis.hf.space) than the page itself. Every other | |
| endpoint stays same-origin-only (the default), including everything | |
| behind auth — this is applied by hand to exactly the two routes that are | |
| already public, unauthenticated, de-identified reads (/api/atlas, | |
| /api/benchmarks), never as a blanket policy. Origin is echoed rather than | |
| '*' so the allowlist actually restricts who can read the response, and | |
| Vary: Origin keeps any intermediate cache from serving one origin's | |
| CORS grant to another.""" | |
| origin = request.headers.get("Origin", "") | |
| if origin and _CORS_ORIGIN_RE.match(origin): | |
| resp.headers["Access-Control-Allow-Origin"] = origin | |
| resp.headers["Vary"] = "Origin" | |
| return resp | |
| def _authed_cors(resp: Response) -> Response: | |
| """Like _public_cors, but for the account endpoints turingdna.com calls | |
| WITH a Bearer token — the personal dashboard's billing panel and its | |
| delete-account button. An authenticated cross-origin request is never a | |
| "simple" one (the Authorization header alone disqualifies it, GET included), | |
| so the browser gates every one of them behind a preflight; this adds the | |
| method and header allowances that preflight checks. | |
| GET is listed as well as POST because reading the billing panel is a GET | |
| and would otherwise fail at the preflight — with a CORS console error | |
| rather than an HTTP status, which looks like the endpoint not existing. | |
| Still origin-locked to turingdna.com (same regex), and deliberately NOT in | |
| credentials mode: authorisation rides the Authorization header, never a | |
| cookie, so there is no ambient session for a hostile origin to borrow — the | |
| grant only lets turingdna.com's own script reuse the token it already holds. | |
| """ | |
| origin = request.headers.get("Origin", "") | |
| if origin and _CORS_ORIGIN_RE.match(origin): | |
| resp.headers["Access-Control-Allow-Origin"] = origin | |
| resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" | |
| resp.headers["Access-Control-Allow-Headers"] = "authorization, content-type" | |
| resp.headers["Access-Control-Max-Age"] = "600" | |
| resp.headers.setdefault("Vary", "Origin") | |
| return resp | |
| def _rate_limit_rule(path: str): | |
| """Most-specific matching rule as (prefix, max_requests, window_s), or | |
| None if the path isn't rate-limited.""" | |
| for prefix, (limit, window) in _RL_RULES: | |
| if path.startswith(prefix): | |
| return (prefix, limit, window) | |
| return None | |
| def _check_rate_limit(path: str, key: str): | |
| """Returns (limited: bool, retry_after_s: int) over a sliding window. | |
| The window is per (client, rule) so heavy and cheap endpoints have | |
| independent budgets — bursting the plasmid editor never locks a user out | |
| of running a pipeline. Bounded memory; fail-open on any internal error.""" | |
| if _RL_DISABLED: | |
| return (False, 0) | |
| matched = _rate_limit_rule(path) | |
| if matched is None: | |
| return (False, 0) | |
| prefix, limit, window = matched | |
| bucket = f"{key}|{prefix}" | |
| now = time.monotonic() | |
| try: | |
| with _RL_LOCK: | |
| # Protect against an IP-rotating flood bloating the table — but evict | |
| # the STALEST buckets (oldest last-hit), not the whole table. A blunt | |
| # clear() would reset every honest user's window, and a unique-IP | |
| # flood could exploit that to wipe limits on demand. | |
| if len(_RL_HITS) > _RL_MAX_KEYS: | |
| try: | |
| victims = sorted(_RL_HITS.items(), | |
| key=lambda kv: (kv[1][-1] if kv[1] else 0.0)) | |
| for vk, _dq in victims[: max(1, len(_RL_HITS) // 5)]: | |
| _RL_HITS.pop(vk, None) | |
| except Exception: # noqa: BLE001 | |
| _RL_HITS.clear() # last-ditch fallback | |
| dq = _RL_HITS.get(bucket) | |
| if dq is None: | |
| dq = deque() | |
| _RL_HITS[bucket] = dq | |
| cutoff = now - window | |
| while dq and dq[0] < cutoff: | |
| dq.popleft() | |
| if len(dq) >= limit: | |
| retry = int(dq[0] + window - now) + 1 | |
| return (True, max(1, retry)) | |
| dq.append(now) | |
| return (False, 0) | |
| except Exception: # noqa: BLE001 — never block a request on limiter failure | |
| return (False, 0) | |
| # How long a library sits with no logged results before Mission Control stops | |
| # saying "ready to build" and starts asking for the numbers. Three weeks is | |
| # past a synthesis-and-assay turnaround for most benches, so by then "you have | |
| # not started" is a less likely explanation than "you have results and nowhere | |
| # obvious to put them". A Space variable, because the right number is an | |
| # empirical question nobody has data for yet. | |
| MISSION_AWAITING_RESULTS_DAYS = int( | |
| os.environ.get("TURINGDNA_MISSION_AWAITING_RESULTS_DAYS", "21")) | |
| def _days_since(iso: Optional[str]) -> int: | |
| """Whole days since an ISO timestamp. 0 when it cannot be read — an | |
| unparseable date must never promote a card to 'waiting on you'.""" | |
| if not iso: | |
| return 0 | |
| import datetime as _dt | |
| try: | |
| txt = str(iso).replace("Z", "+00:00") | |
| then = _dt.datetime.fromisoformat(txt) | |
| if then.tzinfo is None: | |
| then = then.replace(tzinfo=_dt.timezone.utc) | |
| return max(0, (_dt.datetime.now(_dt.timezone.utc) - then).days) | |
| except Exception: # noqa: BLE001 | |
| return 0 | |
| def create_app() -> Flask: | |
| app = Flask(__name__, static_folder=None) | |
| # Global request-body cap (defense-in-depth against memory-exhaustion POSTs). | |
| # 25 MB comfortably covers the largest legitimate payload (CRISPR's 1 Mbp | |
| # paste ≈ 1 MB, sequence-file uploads) while blocking 100 MB+ floods; | |
| # oversized requests get a 413 before any handler/parser runs. | |
| app.config["MAX_CONTENT_LENGTH"] = 25 * 1024 * 1024 | |
| # Wire up auth middleware: before_request hook that parses the | |
| # Authorization header + signed anon cookie into flask.g.auth. | |
| # See dee/auth.py for the full design + env-var requirements. | |
| _auth.init_app(app) | |
| def _healthz() -> Response: | |
| """Liveness probe for uptime monitors / the platform supervisor. No auth, | |
| no rate limit (not under /api/), fast + side-effect-free.""" | |
| return jsonify({"status": "ok"}), 200 | |
| def api_ping() -> Response: | |
| """Dwell-time heartbeat. The client posts {session_id, visible_ms} every | |
| ~30 s while the tab is VISIBLE, and once more on hide via sendBeacon. | |
| Upserts one public.app_sessions row per session (atomic record_ping RPC). | |
| Anonymous visitors are tracked too. Auth optional, best-effort — returns | |
| 204 fast and never does work on the request thread.""" | |
| try: | |
| data = request.get_json(silent=True) or {} | |
| sid = str(data.get("session_id") or "")[:64] | |
| try: | |
| vis = int(data.get("visible_ms", 0)) | |
| except (TypeError, ValueError): | |
| vis = 0 | |
| if sid: | |
| _auth.record_ping_async(session_id=sid, visible_ms=vis) | |
| except Exception: # noqa: BLE001 — telemetry never affects the response | |
| pass | |
| # 204: no body; sendBeacon ignores the response anyway. | |
| return ("", 204) | |
| def api_collect() -> Response: | |
| """First-party UX-behaviour ingest (sibling of /api/ping). The | |
| collector.js batch POSTs {session_id, events:[…]} via sendBeacon — | |
| dead/rage clicks, scroll depth, funnel steps, feature clicks, client | |
| errors. STRUCTURE ONLY: the client sends element ids / data-analytics | |
| labels / tag paths, never sequence content, and never identity — the | |
| server stamps user_id / anon_fingerprint from g.auth in | |
| log_client_events_async, which also whitelists `kind`, caps string | |
| lengths, and drops unexpected keys (never trusts client shape). | |
| Auth optional, best-effort — returns 204 fast and never does work on | |
| the request thread (the writer fires off a daemon thread).""" | |
| try: | |
| data = request.get_json(silent=True) or {} | |
| sid = str(data.get("session_id") or "")[:64] | |
| events = data.get("events") | |
| # Cap events per request so a crafted payload can't fan out into a | |
| # huge batch insert; the writer re-caps + sanitizes each row. | |
| if sid and isinstance(events, list) and events: | |
| _auth.log_client_events_async(sid, events[:100]) | |
| except Exception: # noqa: BLE001 — telemetry never affects the response | |
| pass | |
| return ("", 204) | |
| def _enforce_rate_limit() -> Optional[Response]: | |
| # Throttle only API calls — the SPA shell + static assets are cheap and | |
| # heavily cached. Returning a response here short-circuits the handler. | |
| path = request.path or "" | |
| if not path.startswith("/api/"): | |
| return None | |
| # Start the latency clock BEFORE the rate-limit check, so a 429 is timed | |
| # like any other response. Short-circuiting here still runs | |
| # after_request, which would otherwise find no start time and record 0 ms | |
| # for exactly the requests worth investigating. | |
| g._req_started_at = time.perf_counter() | |
| limited, retry = _check_rate_limit(path, _client_key()) | |
| if not limited: | |
| return None | |
| resp = jsonify({ | |
| "error": "Too many requests — please slow down and try again in a moment.", | |
| "kind": "rate_limited", | |
| }) | |
| resp.status_code = 429 | |
| resp.headers["Retry-After"] = str(retry) | |
| return resp | |
| # ── Compression ────────────────────────────────────────────────────── | |
| # Registered BEFORE _security_headers so it runs AFTER it: Flask calls | |
| # after_request hooks in reverse registration order, and nothing may touch | |
| # the body once it has been compressed. | |
| # | |
| # There is no nginx in front of this. gunicorn serves these bytes straight | |
| # out, so if we do not compress, nothing does. Measured on the live Space | |
| # before this: the document, app.js and app.css left the server at 858 KB | |
| # against 204 KB gzipped, and first contentful paint was 4.0s. 654 KB of | |
| # every cold load was pure waste. | |
| _COMPRESSIBLE = frozenset(( | |
| "text/html", "text/css", "text/plain", "text/csv", "text/markdown", | |
| "application/javascript", "application/json", "application/xml", | |
| "image/svg+xml", | |
| )) | |
| # Below this, gzip's own framing costs more than it saves. | |
| _GZIP_MIN_BYTES = 1024 | |
| def _compress(resp: Response) -> Response: | |
| try: | |
| if (resp.direct_passthrough # send_file streams — leave it alone | |
| or resp.status_code in (204, 304) | |
| or "Content-Encoding" in resp.headers | |
| or "Content-Range" in resp.headers # a range is not the whole body | |
| or (resp.mimetype or "") not in _COMPRESSIBLE): | |
| return resp | |
| # Announce the variance whether or not we compress THIS response. | |
| # Without it a shared cache can hand gzipped bytes to a client that | |
| # never asked for them. .vary is a HeaderSet, so this appends to the | |
| # CORS Vary already present rather than replacing it. | |
| resp.vary.add("Accept-Encoding") | |
| if "gzip" not in request.headers.get("Accept-Encoding", ""): | |
| return resp | |
| data = resp.get_data() | |
| if len(data) < _GZIP_MIN_BYTES: | |
| return resp | |
| # Immutable assets (?v=-busted, max-age=31536000) are worth level 9 | |
| # once and caching; the index cannot be cached at all because it | |
| # carries a per-request CSP nonce, so it takes the cheap level. | |
| if "max-age=31536000" in resp.headers.get("Cache-Control", ""): | |
| key = hashlib.blake2b(data, digest_size=16).digest() | |
| out = _GZIP_CACHE.get(key) | |
| if out is None: | |
| out = gzip.compress(data, 9) | |
| if len(_GZIP_CACHE) > 64: # bounded — the asset set is small | |
| _GZIP_CACHE.clear() | |
| _GZIP_CACHE[key] = out | |
| else: | |
| out = gzip.compress(data, 6) | |
| if len(out) >= len(data): # already-dense payload; don't pay for nothing | |
| return resp | |
| resp.set_data(out) | |
| resp.headers["Content-Encoding"] = "gzip" | |
| resp.headers["Content-Length"] = str(len(out)) | |
| return resp | |
| except Exception: # noqa: BLE001 — compression must never break a response | |
| logger.exception("compression failed; serving uncompressed") | |
| return resp | |
| def _security_headers(resp: Response) -> Response: | |
| # Safe, iframe-compatible hardening. setdefault so we never clobber a | |
| # header a handler set deliberately (e.g. Cache-Control on assets). | |
| resp.headers.setdefault("X-Content-Type-Options", "nosniff") | |
| resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") | |
| resp.headers.setdefault( | |
| "Permissions-Policy", | |
| "geolocation=(), microphone=(), camera=(), payment=(), usb=()", | |
| ) | |
| # HSTS — the engine is always HTTPS on hf.space. No includeSubDomains / | |
| # preload: we don't want to make claims about sibling *.hf.space hosts. | |
| resp.headers.setdefault("Strict-Transport-Security", "max-age=31536000") | |
| # Clickjacking defense that RESPECTS the embed: only turingdna.com and | |
| # huggingface.co may frame the engine (the /app wrapper iframes it, and | |
| # so does the Space's own "App" preview tab on huggingface.co — without | |
| # this the preview shows as a blank/broken frame even though the engine | |
| # is running fine). An allowlist, not a blanket deny — direct | |
| # (un-framed) access to the engine is unaffected. | |
| # Strict-ish CSP. script-src is nonce-based (no 'unsafe-inline'), so an | |
| # injected inline <script> or on*= handler — the realistic XSS vector, | |
| # and the one that would steal the Supabase token from localStorage — is | |
| # refused; our own inline scripts carry the nonce. 'unsafe-eval' + blob: | |
| # are allowed because the bundled 3-D / spreadsheet libraries need them. | |
| # We deliberately DON'T set default-src/connect-src/style-src/img-src, so | |
| # API calls, external structure fetches, fonts and inline styles are | |
| # unaffected — script execution is the only thing locked down here. | |
| resp.headers.setdefault( | |
| "Content-Security-Policy", | |
| "script-src 'self' 'nonce-" + _get_nonce() + "' 'unsafe-eval' blob: " | |
| "https://cdn.jsdelivr.net https://unpkg.com https://cdn.paddle.com https://us-assets.i.posthog.com; " | |
| # No connect-src/frame-src are set (see the note below), so Paddle.js' | |
| # API calls and its checkout overlay iframe (buy.paddle.com / | |
| # sandbox-buy.paddle.com) are already allowed; only its CDN script | |
| # needs naming here. | |
| "object-src 'none'; base-uri 'self'; " | |
| "frame-ancestors 'self' https://turingdna.com https://*.turingdna.com " | |
| "https://huggingface.co", | |
| ) | |
| # Engagement telemetry: one event per successful "meaningful action". | |
| # Centralized here so no individual handler needs to know about analytics. | |
| try: | |
| if resp.status_code < 400: | |
| _kind = _EVENT_KINDS.get(request.path) | |
| if _kind: | |
| _auth.log_event(_kind) | |
| except Exception: # noqa: BLE001 — never break a response over analytics | |
| pass | |
| # Request-level telemetry for the analytics console's endpoint panel. | |
| # Separate from the block above on purpose: _EVENT_KINDS means "a user | |
| # did something meaningful", this means "an HTTP request happened". They | |
| # answer different questions and merging them would ruin both. | |
| # | |
| # /api/collect and /api/ping are excluded — logging the telemetry | |
| # endpoints as telemetry is a feedback loop that would make them the two | |
| # busiest routes on the dashboard while telling us nothing. | |
| try: | |
| _p = request.path | |
| if (_p.startswith("/api/") | |
| and _p not in ("/api/collect", "/api/ping")): | |
| _started = g.get("_req_started_at") | |
| _elapsed = (time.perf_counter() - _started) * 1000.0 if _started else 0.0 | |
| _auth.log_api_request_async(_p, resp.status_code, _elapsed) | |
| except Exception: # noqa: BLE001 — never break a response over analytics | |
| pass | |
| return resp | |
| def index() -> Response: | |
| body = _cleaned_html("index.html") # strip internal comments; source stays commented | |
| if body is not None: | |
| # Stamp the per-request nonce onto our two first-party inline scripts | |
| # so they run under the strict script-src CSP set in _security_headers. | |
| # Only bare "<script>" matches — external "<script src=…>" tags are | |
| # untouched (allowed by origin) — so this hits exactly the inline pair. | |
| body = body.replace("<script>", f'<script nonce="{_get_nonce()}">') | |
| return _no_cache(Response(body, mimetype="text/html")) | |
| return _no_cache(send_from_directory(STATIC_DIR, "index.html")) | |
| def static_file(filename: str) -> Response: | |
| # Serve minified JS/CSS (cached) for a clean, compact production surface. | |
| if "/" not in filename and filename.endswith((".js", ".css")): | |
| body = _minified_asset(filename) | |
| if body is not None: | |
| mime = "application/javascript" if filename.endswith(".js") else "text/css" | |
| resp = Response(body, mimetype=mime) | |
| # safe to cache hard: every reference is query-string cache-busted (?v=) | |
| resp.headers["Cache-Control"] = "public, max-age=31536000" | |
| return resp | |
| # Strip internal comments from static HTML pages (e.g. assistant.html) so | |
| # view-source / inspect-element shows no engineering notes — same clean | |
| # treatment as the "/" index route. These pages reference external scripts | |
| # only (no inline <script>), so no CSP-nonce stamping is required. | |
| if "/" not in filename and filename.endswith(".html"): | |
| body = _cleaned_html(filename) | |
| if body is not None: | |
| return _no_cache(Response(body, mimetype="text/html")) | |
| return send_from_directory(STATIC_DIR, filename) | |
| def preview() -> Response: | |
| """Accept a sequence and report what we found (no scoring yet). | |
| Accepts EITHER: | |
| * multipart/form-data with ``file`` field, or | |
| * application/json with ``{"text": "..."}``. | |
| If the input parses cleanly as a single CDS or protein, returns its | |
| metadata. If it's a structured plasmid file or a raw DNA blob with | |
| multiple ORFs, returns a list of choices in ``cds_options`` so the | |
| UI can show a picker before kicking off a run. | |
| """ | |
| try: | |
| tmp_path, original_name = _materialize_input() | |
| payload, session_extras = _summarize(tmp_path, original_name) | |
| sid = _new_session( | |
| {"path": str(tmp_path), "name": original_name, **session_extras} | |
| ) | |
| payload["session_id"] = sid | |
| return jsonify(payload) | |
| except SequenceValidationError as exc: | |
| return jsonify( | |
| { | |
| "error": str(exc), | |
| "kind": "validation", | |
| "nt_position": exc.nt_position, | |
| "code": exc.code, | |
| } | |
| ), 400 | |
| except Exception: # noqa: BLE001 — surface anything else as 500. | |
| logger.exception("Preview failed.") | |
| # Detail is logged; don't leak the raw exception text to the client | |
| # (confusing for users, and avoids exposing internals). | |
| return jsonify({"error": "Couldn't read that input — check it's a valid " | |
| "sequence or file and try again.", | |
| "kind": "internal"}), 500 | |
| def start_run() -> Response: | |
| # Auth-or-quota gate. Anonymous visitors get ANON_FREE_RUNS of | |
| # them — runs, not minutes; see dee/auth.py for why the clock was | |
| # the wrong unit. After that this returns a 402 with | |
| # kind="auth_required" and the frontend pops the sign-up gate. | |
| # Signed-in users (valid Supabase JWT in Authorization header) | |
| # bypass entirely. | |
| gate = _auth.require_auth_or_quota() | |
| if gate is not None: | |
| return gate | |
| body = request.get_json(force=True, silent=True) or {} | |
| sid = body.get("session_id") | |
| if not sid: | |
| return jsonify({"error": "missing session_id"}), 400 | |
| session = _get_session(sid) | |
| if not session: | |
| return jsonify({"error": "session expired; re-upload the sequence"}), 410 | |
| try: | |
| settings = _validate_run_settings(body.get("settings") or {}) | |
| except ValueError as exc: | |
| return jsonify({"error": str(exc), "kind": "validation"}), 400 | |
| cds_feature = body.get("cds_feature") | |
| # Audit M7: require explicit CDS selection for multi-CDS uploads. | |
| # Previously, when the user uploaded an annotated file with >1 CDS | |
| # feature, the pipeline defaulted to the LONGEST CDS if the user | |
| # hadn't picked one — sometimes silently swapping in a 5'-flanking | |
| # ORF instead of the intended target gene. Now: if the session | |
| # offers a chooser (cds_options) and the user didn't pick one, | |
| # 400 instead of guessing. | |
| cds_options = session.get("cds_options") or [] | |
| if cds_options and len(cds_options) > 1 and not cds_feature: | |
| return jsonify({ | |
| "error": "This input has multiple CDS features. Pick which one " | |
| "to design against — auto-selection is disabled to " | |
| "avoid silently designing the wrong gene.", | |
| "kind": "needs_cds_choice", | |
| "cds_options": cds_options, | |
| }), 400 | |
| # Capture the signed-in user_id at request time (while flask.g.auth | |
| # is still populated) so the background pipeline thread can | |
| # attribute the library to them on completion. None for anon users. | |
| _auth_ctx = _auth.get_auth() | |
| job = JobState( | |
| job_id=uuid.uuid4().hex[:12], | |
| user_id=_auth_ctx.user_id if not _auth_ctx.anonymous else None, | |
| ) | |
| _cid = str(body.get("construct_id") or "").strip() | |
| if job.user_id and re.match(r"^[0-9a-fA-F-]{36}$", _cid): | |
| job.construct_id = _cid | |
| _put_job(job) | |
| # If the session has an ORF map and the user picked an ORF, materialize | |
| # that ORF's DNA into a fresh temp file and use it as the pipeline input. | |
| run_path = Path(session["path"]) | |
| run_name = session["name"] | |
| orf_map = session.get("orfs") or {} | |
| if orf_map and cds_feature and cds_feature in orf_map: | |
| tmp = tempfile.NamedTemporaryFile( | |
| delete=False, suffix=".fasta", mode="w", encoding="utf-8", | |
| dir=str(STATE_DIR), | |
| ) | |
| tmp.write(f">{cds_feature}\n{orf_map[cds_feature]}\n") | |
| tmp.close() | |
| run_path = Path(tmp.name) | |
| run_name = f"{Path(run_name).stem}_{cds_feature}" | |
| # Already extracted a clean ORF — don't re-pass the label downstream. | |
| cds_feature = None | |
| thread = threading.Thread( | |
| target=_run_pipeline, | |
| args=(job, run_path, run_name, cds_feature, settings), | |
| daemon=True, | |
| ) | |
| thread.start() | |
| # Best-effort: log the run to Supabase public.runs for analytics. | |
| # Never blocks the response. Stores only hash + length of the | |
| # sequence (per the privacy policy — no sequence content leaves | |
| # the engine memory for analytics purposes). | |
| try: | |
| with open(run_path, "rb") as _f: | |
| _sequence_hash = hashlib.sha256(_f.read()).hexdigest()[:32] | |
| _sequence_length = run_path.stat().st_size | |
| except OSError: | |
| _sequence_hash = "unknown" | |
| _sequence_length = 0 | |
| _auth.log_run_async( | |
| job_id=job.job_id, | |
| sequence_hash=_sequence_hash, | |
| sequence_length=_sequence_length, | |
| model=settings.get("model"), | |
| host_organism=settings.get("host"), | |
| ) | |
| # Lazy cleanup of expired libraries (free-tier TTL, 90d by default). | |
| # Piggybacks on the user's own /api/run so we don't need pg_cron | |
| # or an Edge Function — active users keep their storage tidy. | |
| # Anonymous users skipped (no saved libraries to clean). | |
| _auth.cleanup_expired_libraries_async(_auth.get_auth().user_id) | |
| # If the requester is anonymous, bump their signed-cookie counter | |
| # so subsequent /api/run calls hit the 402 above. Signed-in users | |
| # are no-ops here. | |
| resp = jsonify({"job_id": job.job_id}) | |
| return _auth.increment_anon_runs_on_response(resp) | |
| # ─── Ownership guard (audit C1) ──────────────────────────────────── | |
| # Engine endpoints that take a ``<job_id>`` historically had no | |
| # ownership check — anyone with the id could read or modify any | |
| # other user's job. Closes that gap: signed-in users can only touch | |
| # their own jobs; anonymous jobs (created during a trial) remain | |
| # accessible to anyone with the id (acceptable for the 5-min trial | |
| # scope and required so anon polling still works without a JWT). | |
| def _require_owner_or_403(job: "JobState") -> Optional[Response]: | |
| if job.user_id is None: | |
| return None # anonymous-created job; id-based access only | |
| auth = _auth.get_auth() | |
| if auth.anonymous or auth.user_id != job.user_id: | |
| return jsonify({"error": "not authorized", "kind": "forbidden"}), 403 | |
| return None | |
| _AGENT_MESSAGE_MAX_CHARS = 4000 | |
| # ─── The Orchestrator (2026-07-26) ────────────────────────────────── | |
| # A long-running agent RUN. The client starts one, then polls for | |
| # events while the model | |
| # works — so a workflow that takes six tool calls shows six tool calls | |
| # landing live instead of resolving into one silent HTTP response. | |
| # | |
| # Every one of these spawns real billed OpenRouter calls, so each takes | |
| # the require_auth_or_quota() gate and its own tight _RL_RULES entry. | |
| # The poll route is deliberately NOT gated the same way — it spends | |
| # nothing, and rate-limiting it would break the very loop it serves. | |
| def _orch_owner_or_403(run: "_orch.Run") -> Optional[Response]: | |
| """Same rule as _require_owner_or_403 for jobs: a signed-in user's run | |
| is theirs alone; an anonymous run stays reachable by id so trial | |
| polling works without a JWT.""" | |
| if not run.owner: | |
| return None | |
| auth = _auth.get_auth() | |
| if auth.anonymous or auth.user_id != run.owner: | |
| return jsonify({"error": "not authorized", "kind": "forbidden"}), 403 | |
| return None | |
| def orchestrator_start() -> Response: | |
| gate = _auth.require_auth_or_quota() | |
| if gate is not None: | |
| return gate | |
| # Two different questions, in order: has the ANONYMOUS trial run out | |
| # (above), and has this SIGNED-IN account spent its month's credits | |
| # (here). /reply asks the second question too — a run is charged for | |
| # what it actually spends, every resumed turn included — while | |
| # stop/poll spend nothing and are never gated. | |
| gate = _auth.require_monthly_quota() | |
| if gate is not None: | |
| return gate | |
| body = request.get_json(force=True, silent=True) or {} | |
| message = str(body.get("message") or "").strip() | |
| if not message: | |
| return jsonify({"error": "missing message"}), 400 | |
| if len(message) > _AGENT_MESSAGE_MAX_CHARS: | |
| return jsonify({ | |
| "error": ( | |
| f"Message too long — trim to under {_AGENT_MESSAGE_MAX_CHARS:,} " | |
| "characters. For a full plasmid or long sequence, use the " | |
| "relevant tool directly instead of pasting it into chat." | |
| ), | |
| "kind": "message_too_long", | |
| }), 400 | |
| auth = _auth.get_auth() | |
| run = _orch.create_run(owner=(auth.user_id or ""), anonymous=auth.anonymous) | |
| # Continuing a run that ran out of budget mid-task. The old ceiling | |
| # said "start a new one to continue" and the new one started cold — | |
| # no target, no plan, no record of what had already happened — so the | |
| # advice was unfollowable and the work was simply lost. Passing the | |
| # source run's ID (never a client-supplied brief: that text lands in | |
| # the model's context) rebuilds the handover server-side, and | |
| # _orch.start enforces that the caller owns it. | |
| carry_id = str(body.get("carry_from") or "").strip() | |
| source = _orch.get_run(carry_id) if carry_id else None | |
| try: | |
| # The model picked under the composer. Validated here rather than | |
| # trusted: effective_model() raises for anything this deployment | |
| # cannot actually run, and a bad value must not silently become a | |
| # different model's numbers. | |
| run.model = _orch.sanitize_model(body.get("model")) | |
| _orch.start(run, message, body.get("workspace"), carry_from=source) | |
| except ValueError: | |
| return jsonify({"error": "That run isn't yours to continue.", | |
| "kind": "carry_forbidden"}), 403 | |
| resp = jsonify(_orch.public_state(run)) | |
| return _auth.increment_anon_runs_on_response(resp) | |
| # Poll lives under its own /poll/ prefix purely so _RL_RULES can give it a | |
| # generous bucket (it fires ~1/s and spends nothing) while start/reply/stop | |
| # keep the tight billed-LLM budget. Prefix matching can't otherwise tell a | |
| # free poll apart from a paid turn. | |
| def _orch_find(run_id: str): | |
| """A run from memory, or rebuilt from the database if it was evicted. | |
| Runs live in process memory with a 6-hour sweep, so a deploy or an | |
| overnight gap used to lose them outright. Signed-in runs are now | |
| persisted, so before reporting one gone we try to bring it back — | |
| that turns "your session vanished" into a page refresh. | |
| """ | |
| run = _orch.get_run(run_id) | |
| if run is not None: | |
| return run | |
| auth = _auth.get_auth() | |
| if auth.anonymous or not auth.user_id: | |
| return None | |
| return _orch.rehydrate(run_id, auth.user_id) | |
| def orchestrator_poll(run_id: str) -> Response: | |
| run = _orch_find(run_id) | |
| if not run: | |
| # Nothing in memory and nothing persisted (or the caller is | |
| # anonymous, whose runs are never stored). A distinct kind lets | |
| # the client start a fresh one transparently instead of showing | |
| # the user an error for something they didn't do. | |
| return jsonify({"error": "run not found", "kind": "run_expired"}), 410 | |
| denied = _orch_owner_or_403(run) | |
| if denied is not None: | |
| return denied | |
| try: | |
| after = int(request.args.get("after") or 0) | |
| except (TypeError, ValueError): | |
| after = 0 | |
| state = _orch.public_state(run) | |
| state["events"] = _orch.events_since(run, after) | |
| return jsonify(state) | |
| def orchestrator_decision(run_id: str) -> Response: | |
| # Viewing a counterfactual or recording a scientist's observation uses | |
| # no model/GPU budget. Ownership is the same as replay and polling. | |
| run = _orch_find(run_id) | |
| if not run: | |
| return jsonify({"error": "run not found", "kind": "run_expired"}), 410 | |
| denied = _orch_owner_or_403(run) | |
| if denied is not None: | |
| return denied | |
| from dee.core.scientific_decisions import Conflict, DecisionError | |
| try: | |
| decision = _orch.scientific_decision_action(run, request.get_json(silent=True)) | |
| except Conflict as exc: | |
| return jsonify({"error": str(exc), "kind": "decision_conflict"}), 409 | |
| except (DecisionError, TypeError) as exc: | |
| return jsonify({"error": str(exc), "kind": "invalid_decision"}), 400 | |
| return jsonify({"ok": True, "decision": decision}) | |
| def orchestrator_reply(run_id: str) -> Response: | |
| gate = _auth.require_auth_or_quota() | |
| if gate is not None: | |
| return gate | |
| run = _orch_find(run_id) | |
| if not run: | |
| return jsonify({"error": "run not found", "kind": "run_expired"}), 410 | |
| denied = _orch_owner_or_403(run) | |
| if denied is not None: | |
| return denied | |
| body = request.get_json(force=True, silent=True) or {} | |
| message = str(body.get("message") or "").strip() | |
| if not message: | |
| return jsonify({"error": "missing message"}), 400 | |
| if len(message) > _AGENT_MESSAGE_MAX_CHARS: | |
| return jsonify({"error": "Message too long.", "kind": "message_too_long"}), 400 | |
| # Working right now? Then this is a CORRECTION, and it is the most | |
| # valuable message the user can send — they have watched the agent | |
| # start down the wrong path and are stopping it. This used to 409 and | |
| # sit in a client-side queue until the run finished, by which point | |
| # the wrong work was already done and paid for. steer() hands it to | |
| # the run loop, which picks it up at its next step boundary. | |
| # A run whose compute allowance is gone cannot answer this. Spawning | |
| # it again would spend one more model call to rediscover that and show | |
| # the user a second dead end. 410 is the shape the cockpit already | |
| # recovers from (it clears the run and re-sends as a fresh start), and | |
| # the server-side handover is what makes that fresh start land on the | |
| # work instead of a blank page. `carry_from` is returned so a client | |
| # that wants to be explicit about it can be. | |
| if run.budget_spent: | |
| return jsonify({ | |
| "error": ("That run used its full compute allowance. Your next " | |
| "message starts a fresh run carrying its target, " | |
| "plan and progress over."), | |
| "kind": "run_budget_spent", | |
| "carry_from": run.run_id, | |
| }), 410 | |
| if run.status == "running": | |
| if _orch.steer(run, message): | |
| _orch.set_workspace(run, body.get("workspace"), message) | |
| resp = jsonify(dict(_orch.public_state(run), steered=True)) | |
| return _auth.increment_anon_runs_on_response(resp) | |
| return jsonify({"error": "run is still working", "kind": "run_busy"}), 409 | |
| # A reply spends model time exactly as a start does — metering in | |
| # credits rather than runs exists so that a long conversation costs | |
| # what it costs — so resuming a finished or parked run passes the same | |
| # wall. Steering a run that is already working (above) does not: that | |
| # run was let through when it began, and a correction adds no step of | |
| # its own. | |
| gate = _auth.require_monthly_quota() | |
| if gate is not None: | |
| return gate | |
| run.model = _orch.sanitize_model(body.get("model")) | |
| if not _orch.reply(run, message, body.get("workspace")): | |
| return jsonify({"error": "run is still working", "kind": "run_busy"}), 409 | |
| resp = jsonify(_orch.public_state(run)) | |
| return _auth.increment_anon_runs_on_response(resp) | |
| def orchestrator_runs() -> Response: | |
| """This user's past runs, newest first — the history menu's source. | |
| Metadata only (no transcripts): rendering a dropdown shouldn't ship | |
| every event log the user has ever produced. Anonymous callers get an | |
| empty list rather than a 403; their runs genuinely don't exist | |
| server-side, and the cockpit falls back to its local record. | |
| """ | |
| auth = _auth.get_auth() | |
| if auth.anonymous or not auth.user_id: | |
| return jsonify({"ok": True, "runs": [], "anonymous": True}) | |
| return jsonify({"ok": True, "runs": _auth.list_agent_runs(auth.user_id)}) | |
| # ── catalog verbs ──────────────────────────────────────────────────── | |
| # The history dropdown could only ever OPEN a run. Nothing could be | |
| # removed and nothing could be renamed, so a workspace silted up with | |
| # "Untitled run" and abandoned attempts, and the only cure was to wait | |
| # ninety days for the TTL. Both verbs are owner-enforced twice over: the | |
| # caller's own user_id is what reaches the query (never a client-supplied | |
| # one), and the run_id shape is checked before it is interpolated. | |
| def _run_guard(run_id: str): | |
| """(auth, None) when the caller may act on this run, else (None, resp).""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous or not auth.user_id: | |
| return None, (jsonify({"ok": False, "kind": "signin_required", "error": | |
| "Sign in to manage your runs."}), 403) | |
| if not re.match(r"^[0-9a-f]{32}$", run_id or ""): | |
| return None, (jsonify({"ok": False, "kind": "bad_run_id", "error": | |
| "That isn't a run id."}), 422) | |
| return auth, None | |
| def orchestrator_run_delete(run_id: str) -> Response: | |
| """Delete one run — the row, and the process-memory copy behind it.""" | |
| auth, denied = _run_guard(run_id) | |
| if denied: | |
| return denied | |
| result = _auth.delete_agent_run(auth.user_id, run_id) | |
| # Evict AFTER the row is gone. A cached Run keeps answering the | |
| # client's poll and re-persists itself at its next terminal | |
| # transition, which would resurrect the row we just deleted. | |
| if result.get("ok"): | |
| from .core import orchestrator as _orch | |
| _orch.forget_run(run_id, auth.user_id) | |
| return jsonify(result) | |
| def orchestrator_run_rename(run_id: str) -> Response: | |
| """Retitle one run.""" | |
| auth, denied = _run_guard(run_id) | |
| if denied: | |
| return denied | |
| body = request.get_json(force=True, silent=True) or {} | |
| title = str(body.get("title", "")).strip()[:120] | |
| if not title: | |
| return jsonify({"ok": False, "kind": "bad_title", "error": | |
| "Give the run a name."}), 422 | |
| result = _auth.rename_agent_run(auth.user_id, run_id, title) | |
| # Keep the live Run in step. It holds its own title and writes it on | |
| # every persist, so without this the next terminal transition would | |
| # quietly put the old name back. | |
| if result.get("ok"): | |
| from .core import orchestrator as _orch | |
| live = _orch.get_run(run_id) | |
| if live is not None and live.owner == auth.user_id: | |
| live.title = title | |
| return jsonify(result) | |
| # ── Engine ↔ Network: source-backed Research Objects ─────────────── | |
| # A browser never sends a publishable card. It names one tool_result in a | |
| # run it owns; the server reconstructs the public-safe projection from the | |
| # recorded result and stores an opaque 30-minute draft. Network is where | |
| # the scientist reviews/removes metadata and explicitly publishes it. | |
| _NETWORK_OBJECT_TOOLS = { | |
| "design_crispr_guides": ("crispr_design", "CRISPR guide design", "crispr"), | |
| "design_variant_library": ("protein_engineering", "Protein engineering run", "design"), | |
| "design_primers": ("primer_design", "Primer design", "primers"), | |
| "fold_structure": ("workflow", "Structure prediction", "structure"), | |
| } | |
| def _network_share_parts(run, call_id: str): | |
| # Result-card buttons name their exact event. Conversational sharing | |
| # ("let's share it") intentionally sends no event id, so resolve the | |
| # most recent compatible successful result from this owned run. The | |
| # browser still cannot supply or alter publishable metadata. | |
| if call_id: | |
| event = next((e for e in run.events if e.get("kind") == "tool_result" | |
| and e.get("id") == call_id and e.get("ok")), None) | |
| else: | |
| event = next((e for e in reversed(run.events) | |
| if e.get("kind") == "tool_result" and e.get("ok") | |
| and e.get("name") in _NETWORK_OBJECT_TOOLS), None) | |
| call_id = str((event or {}).get("id") or "") | |
| call = next((e for e in run.events if e.get("kind") == "tool_call" | |
| and e.get("id") == call_id), None) | |
| if not call or not event or event.get("name") not in _NETWORK_OBJECT_TOOLS: | |
| return None | |
| name = event["name"] | |
| object_type, noun, route = _NETWORK_OBJECT_TOOLS[name] | |
| result = ((event.get("ui") or {}).get("payload") or {}) | |
| panel = ((event.get("ui") or {}).get("panel") or {}) | |
| args = call.get("args") if isinstance(call.get("args"), dict) else {} | |
| # Only fields emitted by the tool are candidates. Workspace/construct | |
| # names are intentionally excluded: a private project can create a | |
| # public summary without its name becoming public by accident. | |
| target = (result.get("gene_symbol") or result.get("wt_identifier") | |
| or result.get("uniprot") or panel.get("wt_identifier") | |
| or args.get("label") or args.get("gene_symbol") or None) | |
| # UI defaults are navigation labels, not scientific targets. Keeping | |
| # one would make an auto-filled share technically non-empty but useless. | |
| if str(target or "").strip().lower() in {"turing run", "this protein", "result"}: | |
| target = None | |
| organism = result.get("organism") or None | |
| item_count = shortlisted = None | |
| if name == "design_crispr_guides": | |
| item_count = int(result.get("n_guides") or len(result.get("guides") or [])) | |
| elif name == "design_variant_library": | |
| item_count = int(result.get("n_pool") or 0) or None | |
| shortlisted = len(result.get("variants") or []) or None | |
| elif name == "design_primers": | |
| item_count = len(result.get("pairs") or []) or None | |
| elif name == "fold_structure": | |
| item_count = 1 | |
| title = noun + ((" for " + str(target)) if target else "") | |
| summary_bits = [title + " generated with TuringDNA."] | |
| if name == "design_crispr_guides" and item_count is not None: | |
| summary_bits.append(f"{item_count} guides designed.") | |
| elif name == "design_variant_library": | |
| if item_count is not None: | |
| summary_bits.append(f"{item_count} candidate mutations evaluated.") | |
| if shortlisted is not None: | |
| summary_bits.append(f"{shortlisted} variants shortlisted.") | |
| if result.get("region") and len(result.get("region") or []) >= 2: | |
| summary_bits.append( | |
| f"Residues {result['region'][0]}–{result['region'][-1]} targeted.") | |
| elif name == "design_primers" and item_count is not None: | |
| summary_bits.append(f"{item_count} primer pairs designed.") | |
| projection = { | |
| "object_type": object_type, "origin_tool": name, | |
| "title": title[:160], "target": str(target)[:160] if target else None, | |
| "organism": str(organism)[:120] if organism else None, | |
| "summary": " ".join(summary_bits)[:1200], | |
| "item_count": item_count, "shortlisted_count": shortlisted, | |
| "model_label": str(result.get("model") or "")[:160] or None, | |
| "tool_version": str(result.get("version") or "")[:120] or None, | |
| "status": "designed", "deep_link": "/app/#" + route, | |
| } | |
| # Explicit allowlists. These are methodology controls, never molecular | |
| # inputs or outputs; sequence was already stripped from tool_call. | |
| if name == "design_crispr_guides": | |
| methodology = {k: v for k, v in { | |
| "enzyme": result.get("enzyme") or args.get("enzyme"), | |
| "mode": result.get("mode") or args.get("mode"), | |
| "base_editor": args.get("base_editor"), | |
| "max_results": args.get("max_results"), | |
| }.items() if v is not None} | |
| elif name == "design_variant_library": | |
| methodology = {k: v for k, v in { | |
| "host": result.get("host") or args.get("host"), | |
| "percentile": args.get("percentile"), "k": args.get("k"), | |
| "max_mutations": args.get("max_mutations"), | |
| "region_start": (result.get("region") or [None, None])[0], | |
| "region_end": (result.get("region") or [None, None])[-1], | |
| "model": result.get("model"), | |
| }.items() if v is not None} | |
| elif name == "design_primers": | |
| methodology = {k: args.get(k) for k in ("target_start", "target_end") | |
| if args.get(k) is not None} | |
| else: | |
| methodology = {} | |
| return projection, methodology, call_id | |
| def network_research_draft_create() -> Response: | |
| auth = _auth.get_auth() | |
| if auth.anonymous or not auth.user_id: | |
| return jsonify({"ok": False, "kind": "signin_required", | |
| "error": "Sign in to share scientific work."}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| run_id = str(body.get("run_id") or "") | |
| call_id = str(body.get("call_id") or "")[:128] | |
| run = _orch_find(run_id) | |
| if not run: | |
| return jsonify({"ok": False, "error": "That Turing run is no longer available."}), 404 | |
| denied = _orch_owner_or_403(run) | |
| if denied is not None: | |
| return denied | |
| parts = _network_share_parts(run, call_id) | |
| if not parts: | |
| return jsonify({"ok": False, "error": "That result cannot be shared as a Research Object."}), 422 | |
| projection, methodology, source_call_id = parts | |
| made = _auth.create_research_object_draft( | |
| auth.user_id, source_kind="orchestrator_event", source_id=run_id, | |
| source_event_id=source_call_id, projection=projection, methodology=methodology) | |
| if not made.get("ok"): | |
| return jsonify(made), 503 | |
| _auth.log_event("engine_to_network_share", meta={"tool": projection["origin_tool"]}) | |
| draft = made.get("draft") or {} | |
| return jsonify({"ok": True, "draft_id": draft.get("id"), | |
| "expires_at": draft.get("expires_at")}) | |
| def network_research_fork_get(object_id: str) -> Response: | |
| auth = _auth.get_auth() | |
| if auth.anonymous or not auth.user_id: | |
| return jsonify({"ok": False, "kind": "signin_required", | |
| "error": "Sign in to open this fork."}), 403 | |
| fork = _auth.get_research_object_fork(auth.user_id, object_id) | |
| if not fork: | |
| return jsonify({"ok": False, "error": "That fork is unavailable."}), 404 | |
| _auth.log_event("network_to_engine_open", meta={"object_type": fork.get("object_type"), | |
| "fork": True}) | |
| return jsonify({"ok": True, "fork": fork}) | |
| def network_research_object_open(object_id: str) -> Response: | |
| """Open a visible Network object without crossing its privacy boundary. | |
| The safe public projection and methodology are useful to every signed-in | |
| viewer. Only the object's author can receive the private Engine run id, | |
| because that lookup is separately owner-filtered in ``auth.py``. | |
| """ | |
| auth = _auth.get_auth() | |
| if auth.anonymous or not auth.user_id: | |
| return jsonify({"ok": False, "kind": "signin_required", | |
| "error": "Sign in to open this scientific work."}), 403 | |
| obj = _auth.get_research_object_open(auth.user_id, object_id) | |
| if not obj: | |
| return jsonify({"ok": False, "error": "That Research Object is unavailable."}), 404 | |
| run_id = obj.pop("source_run_id", None) | |
| _auth.log_event("network_to_engine_open", meta={ | |
| "object_type": obj.get("object_type"), "original": bool(run_id), | |
| }) | |
| payload = {"ok": True, "object": obj} | |
| if run_id: | |
| payload["run_id"] = run_id | |
| return jsonify(payload) | |
| def notifications() -> Response: | |
| from dee.core.notifications import Inbox, Unavailable | |
| def respond(data, status=200): | |
| resp = jsonify(data) | |
| resp.status_code = status | |
| resp.headers["Cache-Control"] = "no-store" | |
| resp.vary.add("Origin") | |
| origin = request.headers.get("Origin", "") | |
| if origin and _CORS_ORIGIN_RE.fullmatch(origin): | |
| resp.headers["Access-Control-Allow-Origin"] = origin | |
| resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" | |
| resp.headers["Access-Control-Allow-Headers"] = "authorization, content-type" | |
| resp.headers["Access-Control-Max-Age"] = "600" | |
| return resp | |
| if request.method == "OPTIONS": | |
| return respond({}) | |
| auth = _auth.get_auth() | |
| if auth.anonymous or not auth.user_id or not auth.token: | |
| return respond({"error": "Sign in to see your notifications."}, 401) | |
| try: | |
| inbox = Inbox(_auth.SUPABASE_URL, | |
| _auth.SUPABASE_ANON_KEY or _auth.SUPABASE_SERVICE_KEY, | |
| auth.token, auth.user_id) | |
| if request.method == "POST": | |
| body = request.get_json(silent=True) | |
| if not isinstance(body, dict): | |
| return respond({"error": "Expected a notification timestamp."}, 400) | |
| return respond(inbox.mark_read(body.get("through"))) | |
| return respond(inbox.read()) | |
| except ValueError as exc: | |
| return respond({"error": str(exc)}, 400) | |
| except Unavailable as exc: | |
| return respond({"error": str(exc)}, 503) | |
| def usage() -> Response: | |
| """Plan + this month's usage, for the account panel. | |
| Only reports numbers it actually has. A count that couldn't be | |
| fetched comes back as null, never 0 — a confident zero next to work | |
| the user knows they did is worse than an honest blank. | |
| """ | |
| auth = _auth.get_auth() | |
| if auth.anonymous or not auth.user_id: | |
| return jsonify({ | |
| "ok": True, "anonymous": True, "plan": "anonymous", | |
| "trial_seconds": _auth.ANON_TRIAL_SECONDS, | |
| }) | |
| # One source of truth with the enforcement gate — see | |
| # auth.monthly_credit_allowance. Computing the month boundary or the | |
| # total separately here is how the panel and the wall drift apart. | |
| allowance = _auth.monthly_credit_allowance(auth.user_id) | |
| return jsonify({ | |
| "ok": True, | |
| "anonymous": False, | |
| "plan": allowance["plan"], | |
| # Credits, not runs, since 2026-09-11 — see auth.CREDIT_USD. | |
| "credits_used": allowance["used"], | |
| # The ceiling, and whether it will actually be applied. `enforced` | |
| # False means "we could not count, so nothing is being blocked" — | |
| # the panel must not draw a hard limit it isn't backing up. | |
| "credits_limit": allowance["limit"], | |
| "credits_remaining": allowance["remaining"], | |
| "quota_enforced": allowance["enforced"], | |
| # Credits earned by referring (0060). Spent only after the plan's | |
| # allowance, and they do not reset, so they are reported apart | |
| # from it. null when unknown, like every other number here. | |
| "bonus_credits": allowance["bonus_remaining"], | |
| "bonus_granted": allowance["bonus_granted"], | |
| # The end of the billing period on a paid plan, the 1st otherwise | |
| # — the same instant the gate resets on (auth.allowance_window). | |
| "resets_at": allowance["resets_at"], | |
| # What "free" actually means, so the panel can say it rather than | |
| # leaving the user to guess where the ceiling is. The SAME rule the | |
| # lazy cleanup enforces (auth.keeps_work_forever), asked of the plan | |
| # we already hold: this line once said `plan == "pro"` and showed a | |
| # Starter subscriber "kept for 45 days" next to their 75 credits. | |
| "retention_days": (None if _auth.plan_keeps_work_forever(allowance["plan"]) | |
| else _auth.FREE_LIBRARY_TTL_DAYS), | |
| }) | |
| def billing_config() -> Response: | |
| """What the browser needs to render the Pro upsell and open checkout. | |
| Public values (client token, price id, environment) for everyone; | |
| the customer id, email and current plan only for the signed-in | |
| requester, about themselves. See dee/core/paddle.public_config. | |
| """ | |
| # The approved checkout origin is the website, not the HF iframe. | |
| # Explicitly allow only the site's two canonical origins, with Bearer | |
| # auth (no cookies). No other API route inherits this permission. | |
| def checkout_response(resp): | |
| resp.headers["Cache-Control"] = "no-store" | |
| resp.vary.add("Origin") | |
| if request.headers.get("Origin") in ( | |
| "https://turingdna.com", "https://www.turingdna.com"): | |
| resp.headers["Access-Control-Allow-Origin"] = request.headers["Origin"] | |
| resp.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" | |
| resp.headers["Access-Control-Allow-Headers"] = "authorization" | |
| resp.headers["Access-Control-Max-Age"] = "600" | |
| return resp | |
| if request.method == "OPTIONS": | |
| return checkout_response(app.make_response(("", 204))) | |
| from .core import paddle as _paddle | |
| auth = _auth.get_auth() | |
| uid = None if auth.anonymous else auth.user_id | |
| return checkout_response(jsonify(_paddle.public_config(uid, auth.email))) | |
| def account_delete() -> Response: | |
| """Self-service account deletion — the user erases their own account and | |
| every piece of personal data, with no email to anyone (GDPR/UK-GDPR | |
| right to erasure). Called cross-origin from the personal dashboard on | |
| turingdna.com, so it answers the CORS preflight and echoes the origin | |
| (turingdna.com only, via _authed_cors). | |
| Guarded two ways: a valid Bearer JWT — only the account's owner holds | |
| one, and it rides the Authorization header, so there is no cookie for a | |
| cross-site page to ride — and the user_id taken from that verified token | |
| (never from the body). An explicit `confirm:true` from the dashboard's | |
| final step guards against a stray programmatic call. The optional | |
| `reason`/`reason_text` feed an anonymous churn record kept BEFORE the | |
| account is gone (see auth.record_deletion_feedback). | |
| """ | |
| if request.method == "OPTIONS": | |
| return _authed_cors(app.make_response(("", 204))) | |
| auth = _auth.get_auth() | |
| if auth.anonymous or not auth.user_id: | |
| return _authed_cors(jsonify({"ok": False, "kind": "signin_required", | |
| "error": "Sign in to manage your account."})), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| if body.get("confirm") is not True: | |
| return _authed_cors(jsonify({"ok": False, "kind": "confirm_required", | |
| "error": "Deletion was not confirmed."})), 422 | |
| result = _auth.delete_account( | |
| auth.user_id, | |
| reason_code=str(body.get("reason") or "")[:64] or None, | |
| reason_text=str(body.get("reason_text") or "")[:1000] or None, | |
| ) | |
| status = 200 if result.get("ok") else 500 | |
| return _authed_cors(jsonify(result)), status | |
| def account_subscription() -> Response: | |
| """What the dashboard's billing panel renders: plan, next charge, any | |
| scheduled cancellation, and a fresh Paddle customer-portal link. | |
| Owner-only and owner-derived. The user_id comes from the verified | |
| Bearer JWT, never from a query string — the portal URL this returns | |
| authenticates whoever holds it straight into that person's billing, so | |
| there must be no parameter that could name somebody else's account. | |
| Same cross-origin shape as the delete endpoint below it: the personal | |
| dashboard lives on turingdna.com and the engine does not, and | |
| _authed_cors echoes that one origin. | |
| """ | |
| if request.method == "OPTIONS": | |
| return _authed_cors(app.make_response(("", 204))) | |
| auth = _auth.get_auth() | |
| if auth.anonymous or not auth.user_id: | |
| return _authed_cors(jsonify({"ok": False, "kind": "signin_required", | |
| "error": "Sign in to manage your account."})), 403 | |
| return _authed_cors(jsonify(_auth.subscription_status(auth.user_id))), 200 | |
| def account_subscription_cancel() -> Response: | |
| """End the subscription at the end of the period already paid for, | |
| keeping the account and everything in it. | |
| Guarded exactly like deletion — a valid Bearer JWT (no cookie, so no | |
| cross-site page can ride it), the user_id taken from that token, and an | |
| explicit `confirm:true` so a stray programmatic call cannot cancel | |
| somebody's plan. The optional reason feeds the same anonymous churn | |
| table, tagged 'cancel' so it is never averaged with deletions. | |
| Returns 502, not 500, when Paddle refuses: the engine is fine and the | |
| upstream is not, and the difference decides whether the user should | |
| retry here or go to Paddle directly. | |
| """ | |
| if request.method == "OPTIONS": | |
| return _authed_cors(app.make_response(("", 204))) | |
| auth = _auth.get_auth() | |
| if auth.anonymous or not auth.user_id: | |
| return _authed_cors(jsonify({"ok": False, "kind": "signin_required", | |
| "error": "Sign in to manage your account."})), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| if body.get("confirm") is not True: | |
| return _authed_cors(jsonify({"ok": False, "kind": "confirm_required", | |
| "error": "Cancellation was not confirmed."})), 422 | |
| result = _auth.cancel_subscription( | |
| auth.user_id, | |
| reason_code=str(body.get("reason") or "")[:64] or None, | |
| reason_text=str(body.get("reason_text") or "")[:1000] or None, | |
| ) | |
| if result.get("ok"): | |
| status = 200 | |
| elif result.get("kind") == "no_subscription": | |
| status = 409 | |
| elif result.get("kind") == "not_configured": | |
| # 503, not 502: nothing upstream refused us — we never asked, | |
| # because this deployment has no key to ask with. Retrying will | |
| # not help, and the message says so. | |
| status = 503 | |
| else: | |
| status = 502 | |
| return _authed_cors(jsonify(result)), status | |
| def paddle_webhook() -> Response: | |
| """Paddle's server calls this when a subscription changes. It is the | |
| ONLY thing that writes billing state — no browser path flips a plan. | |
| Authorised by the signature alone: no JWT (Paddle has none), no CSRF | |
| token (Paddle can't hold one). The raw body must be read UNMODIFIED — | |
| the signature is over these exact bytes, so it is verified before the | |
| JSON is parsed. | |
| """ | |
| from .core import paddle as _paddle | |
| raw = request.get_data() or b"" | |
| if not _paddle.verify_webhook(raw, request.headers.get("Paddle-Signature")): | |
| # 403, and nothing logged from the body: an unverified caller does | |
| # not get to write to our log either. | |
| return jsonify({"error": "invalid signature", "kind": "forbidden"}), 403 | |
| try: | |
| event = json.loads(raw.decode("utf-8")) | |
| except Exception: # noqa: BLE001 | |
| return jsonify({"error": "bad payload"}), 400 | |
| result = _paddle.apply_event(event) | |
| # A verified event we understood but could not persist is the one case | |
| # worth a retry (500). Everything else — applied, or deliberately | |
| # ignored — is a 200 so Paddle stops redelivering it. | |
| if result.get("handled") and not result.get("ok"): | |
| return jsonify({"error": "could not apply"}), 500 | |
| return jsonify({"ok": True, "handled": bool(result.get("handled"))}), 200 | |
| def orchestrator_stop(run_id: str) -> Response: | |
| run = _orch.get_run(run_id) | |
| if not run: | |
| # No rehydrate here on purpose: stopping a run that is not in | |
| # memory is a no-op by definition — there is no thread to stop. | |
| return jsonify({"error": "run not found", "kind": "run_expired"}), 410 | |
| denied = _orch_owner_or_403(run) | |
| if denied is not None: | |
| return denied | |
| _orch.stop(run) | |
| return jsonify(_orch.public_state(run)) | |
| def status(job_id: str) -> Response: | |
| job = _get_job(job_id) | |
| if not job: | |
| return jsonify({"error": "unknown job"}), 404 | |
| denied = _require_owner_or_403(job) | |
| if denied is not None: | |
| return denied | |
| return jsonify(job.public()) | |
| def result(job_id: str) -> Response: | |
| job = _get_job(job_id) | |
| if not job: | |
| return jsonify({"error": "unknown job"}), 404 | |
| denied = _require_owner_or_403(job) | |
| if denied is not None: | |
| return denied | |
| if job.status != "done": | |
| return jsonify({"error": f"job not done (status={job.status})"}), 409 | |
| # csv_path / desktop_path intentionally omitted (audit H2): they | |
| # leak the server's filesystem layout. Downloads use the | |
| # /api/download/<job_id> endpoint, which constructs the path | |
| # server-side. | |
| return jsonify( | |
| { | |
| "wt_identifier": job.wt_identifier, | |
| "wt_protein": job.wt_protein, | |
| "variants": job.variants or [], | |
| "library_id": job.library_id, | |
| "settings_used": job.settings_used, | |
| "started_at": job.started_at, | |
| "elapsed_seconds": job.elapsed(), | |
| "global_prior": job.global_prior_info or {"applied": False, "substitution_types": 0}, | |
| } | |
| ) | |
| def edit_variant_dna(job_id: str, variant_id: str) -> Response: | |
| """Persist a user-edited DNA sequence for one variant. | |
| The frontend POSTs ``{"dna": "ACGT..."}``. We re-derive every | |
| column from those bytes (translation, mutations diff, PCR metrics, | |
| restriction-site count), replace ``job.variants[i]`` in place, and | |
| rewrite the on-disk CSV atomically so subsequent /api/download | |
| calls return the edited sequence. Returns the full updated row so | |
| the frontend can replace its local copy without a second fetch. | |
| Rejects with 400 if the DNA isn't ACGT or isn't a multiple of 3. | |
| A premature stop is allowed (nonsense/truncation mutation) and simply | |
| truncates the encoded product. | |
| """ | |
| job = _get_job(job_id) | |
| if not job: | |
| return jsonify({"error": "unknown job"}), 404 | |
| denied = _require_owner_or_403(job) | |
| if denied is not None: | |
| return denied | |
| if job.status != "done": | |
| return jsonify({"error": f"job not done (status={job.status})"}), 409 | |
| if not job.variants: | |
| return jsonify({"error": "job has no variants"}), 404 | |
| body = request.get_json(force=True, silent=True) or {} | |
| edited_dna = (body.get("dna") or "").strip() | |
| with _edit_lock_for(job_id): | |
| idx = next( | |
| (i for i, v in enumerate(job.variants) | |
| if v.get("Variant_ID") == variant_id), | |
| None, | |
| ) | |
| if idx is None: | |
| return jsonify({"error": f"unknown variant {variant_id!r}"}), 404 | |
| try: | |
| new_row = _rederive_variant_row( | |
| variant_id=variant_id, | |
| edited_dna=edited_dna, | |
| wt_protein=job.wt_protein, | |
| original_row=job.variants[idx], | |
| ) | |
| except ValueError as exc: | |
| return jsonify({"error": str(exc), "kind": "validation"}), 400 | |
| except Exception as exc: # noqa: BLE001 | |
| logger.exception("Re-derive failed for %s/%s", job_id, variant_id) | |
| return jsonify({"error": f"{type(exc).__name__}: {exc}"}), 500 | |
| job.variants[idx] = new_row | |
| try: | |
| _atomic_rewrite_csv(job) | |
| except Exception as exc: # noqa: BLE001 | |
| # The in-memory state was updated but the on-disk CSV | |
| # rewrite failed. Surface this so the user knows downloads | |
| # may temporarily diverge from screen state. | |
| logger.exception("CSV rewrite failed for %s", job_id) | |
| return jsonify( | |
| {"error": f"saved in memory but CSV rewrite failed: {exc}", | |
| "row": new_row, "kind": "csv_warning"}, | |
| ), 207 | |
| return jsonify({"row": new_row}) | |
| def download(job_id: str) -> Response: | |
| job = _get_job(job_id) | |
| if not job or not job.csv_path: | |
| return jsonify({"error": "no result available"}), 404 | |
| denied = _require_owner_or_403(job) | |
| if denied is not None: | |
| return denied | |
| fmt = request.args.get("format", "csv").lower() | |
| stem = Path(job.csv_path).stem | |
| try: | |
| data, mimetype, ext = _render_download(job, fmt) | |
| except ValueError as exc: | |
| return jsonify({"error": str(exc)}), 400 | |
| return send_file( | |
| io.BytesIO(data) if isinstance(data, bytes) else data, | |
| mimetype=mimetype, | |
| as_attachment=True, | |
| download_name=f"{stem}.{ext}", | |
| ) | |
| def identify_start() -> Response: | |
| """Kick off an NCBI-BLAST identification for the session's WT protein. | |
| Returns ``{job_id, cached, hits?}``. If we already ran BLAST on this | |
| exact sequence we return the prior result inline with cached=true. | |
| """ | |
| body = request.get_json(force=True, silent=True) or {} | |
| sid = body.get("session_id") | |
| # A raw protein is an ALTERNATIVE to a session, not a shortcut past it. | |
| # Libraries designed inside a Turing conversation never create a | |
| # server-side job, so they have no session at all — and requiring one | |
| # made the wild-type structure lookup permanently dead for every | |
| # agent-designed library while looking like an ordinary button. | |
| raw_protein = str(body.get("protein") or "").strip() | |
| if not sid and not raw_protein: | |
| return jsonify({"error": "missing session_id or protein"}), 400 | |
| session = _get_session(sid) if sid else None | |
| if sid and not session: | |
| return jsonify({"error": "session expired"}), 410 | |
| # Audit H1: BLAST consent must be enforced server-side, not just | |
| # by the consent modal in the UI. The frontend posts | |
| # `blast_consent: true` only after the user has clicked Continue | |
| # in the modal; we require it here so anyone calling /api/identify | |
| # directly (curl, script, etc.) still has to opt in to leak the | |
| # sequence to NCBI. | |
| if not body.get("blast_consent"): | |
| return jsonify({ | |
| "error": ( | |
| "BLAST consent is required. The wild-type protein " | |
| "sequence is sent to ncbi.nlm.nih.gov; please confirm " | |
| "in the UI before retrying." | |
| ), | |
| "kind": "consent_required", | |
| }), 403 | |
| cds_feature = body.get("cds_feature") | |
| if session is None: | |
| # Sequence-only path. Validated the same way every other pasted | |
| # protein is, so a junk body cannot become a BLAST job. | |
| # classify_sequence, not an alphabet test. A, T, G and C are all | |
| # valid amino-acid letters, so a pasted DNA sequence passes any | |
| # naive "are these residues?" check and then gets blastp'd as if it | |
| # were protein — the realistic mistake, and a silently wrong answer | |
| # rather than an error. Anything the classifier will not call | |
| # protein outright is refused. | |
| from dee.core.sequence import classify_sequence | |
| protein = "".join(c for c in raw_protein.upper() if c.isalpha()) | |
| if len(protein) < 12 or classify_sequence(protein) != "protein": | |
| return jsonify({ | |
| "error": ("'protein' must be 12+ residues and readable as " | |
| "protein. A sequence of only A/C/G/T/U/N is DNA " | |
| "or is indistinguishable from it, and blastp " | |
| "would return a confidently wrong answer."), | |
| }), 400 | |
| return _identify_from_protein(protein) | |
| # Re-derive the protein to BLAST. For sessions with discovered ORFs | |
| # the user may have picked a specific one — use that if provided. | |
| orf_map = session.get("orfs") or {} | |
| if orf_map and cds_feature and cds_feature in orf_map: | |
| tmp_path = Path(STATE_DIR / f"blast_input_{uuid.uuid4().hex[:8]}.fa") | |
| tmp_path.write_text(f">{cds_feature}\n{orf_map[cds_feature]}\n") | |
| record = parse_input(tmp_path, require_start=False, require_stop=False) | |
| tmp_path.unlink(missing_ok=True) | |
| else: | |
| record = parse_input( | |
| Path(session["path"]), | |
| require_start=False, | |
| require_stop=False, | |
| cds_feature=cds_feature, | |
| ) | |
| protein = record.protein | |
| if not protein: | |
| return jsonify({"error": "no protein available to identify"}), 400 | |
| return _identify_from_protein(protein) | |
| def _identify_from_protein(protein: str) -> Response: | |
| """Submit (or reuse) a BLAST job for one protein. | |
| Shared by both entry paths — session-derived and sequence-supplied — so | |
| the hash-keyed cache, the job registry and the response shape cannot | |
| drift between them. | |
| """ | |
| seq_hash = _hash_sequence(protein) | |
| with _BLAST_LOCK: | |
| cached = _BLAST_CACHE.get(seq_hash) | |
| if cached and cached.status in {"done", "running", "submitting", "parsing", "pending"}: | |
| return jsonify( | |
| { | |
| "job_id": cached.job_id, | |
| "cached": cached.status == "done", | |
| **cached.public(), | |
| } | |
| ) | |
| job = BlastJob(job_id=uuid.uuid4().hex[:12], seq_hash=seq_hash) | |
| _BLAST_CACHE[seq_hash] = job | |
| _BLAST_BY_ID[job.job_id] = job | |
| threading.Thread( | |
| target=_run_blast, args=(job, protein), daemon=True | |
| ).start() | |
| return jsonify({"job_id": job.job_id, "cached": False, **job.public()}) | |
| def identify_status(job_id: str) -> Response: | |
| with _BLAST_LOCK: | |
| job = _BLAST_BY_ID.get(job_id) | |
| if not job: | |
| return jsonify({"error": "unknown job"}), 404 | |
| return jsonify(job.public()) | |
| # ============================================================ PRIMER PICKER | |
| # Batch-analyse candidate primers: score each against the user's template | |
| # (on-server, private) and against NCBI nt for off-target specificity | |
| # (opt-in), then rank and recommend the best. Sign-in gated like CRISPR. | |
| def primers_analyze() -> Response: | |
| from .core import primers as _P | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({ | |
| "error": "The Primer Picker requires a free account. Sign in to continue.", | |
| "kind": "signin_required", | |
| "signup_url": "https://turingdna.com/signin/?from=primers", | |
| }), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| primers_text = (body.get("primers") or "").strip() | |
| template = re.sub(r"\s+", "", (body.get("template") or "")).upper() | |
| scan_ncbi = bool(body.get("scan_ncbi")) | |
| strict_3p = bool(body.get("strict_3p")) | |
| organism = (body.get("organism") or "human").lower() | |
| if organism not in _PRIMER_ORGANISMS: | |
| organism = "human" | |
| if not primers_text: | |
| return jsonify({"error": "Paste at least one primer."}), 400 | |
| if template and not re.fullmatch(r"[ACGTN]+", template): | |
| return jsonify({"error": "Template must be DNA (A/C/G/T/N) only."}), 400 | |
| if len(template) > _P.MAX_TEMPLATE: | |
| return jsonify({"error": ( | |
| f"Template is longer than {_P.MAX_TEMPLATE:,} bp — paste just the " | |
| "region you're amplifying.")}), 400 | |
| parsed, warnings = _P.parse_primers(primers_text) | |
| if not parsed: | |
| return jsonify({"error": "No valid primers found.", "warnings": warnings}), 400 | |
| scored = [_P.score_primer(p, template) for p in parsed] | |
| if not scan_ncbi: | |
| return jsonify({ | |
| "status": "done", | |
| "result": _P.rank(scored, template), | |
| "warnings": warnings, | |
| }) | |
| # NCBI scan sends the primers off-server → require explicit consent | |
| # (same posture as the BLAST identify gate). | |
| if not body.get("blast_consent"): | |
| return jsonify({ | |
| "error": ("The specificity scan sends your primers to " | |
| "blast.ncbi.nlm.nih.gov; confirm in the UI before retrying."), | |
| "kind": "consent_required", | |
| }), 403 | |
| seq_hash = _hash_sequence("|".join(p["seq"] for p in parsed) | |
| + "::" + template + "::" + organism | |
| + ("::strict" if strict_3p else "")) | |
| with _PRIMER_LOCK: | |
| cached = _PRIMER_CACHE.get(seq_hash) | |
| if cached and cached.status in {"done", "scanning", "pending"}: | |
| return jsonify({ | |
| "job_id": cached.job_id, | |
| "status": cached.status, | |
| "result": cached.result(), | |
| "warnings": cached.warnings, | |
| }) | |
| job = PrimerJob(job_id=uuid.uuid4().hex[:12], seq_hash=seq_hash, | |
| user_id=auth.user_id, | |
| primers=scored, template=template, organism=organism, | |
| three_prime_strict=strict_3p, warnings=warnings) | |
| _PRIMER_CACHE[seq_hash] = job | |
| _PRIMER_BY_ID[job.job_id] = job | |
| threading.Thread(target=_run_primer_blast, args=(job,), daemon=True).start() | |
| return jsonify({ | |
| "job_id": job.job_id, | |
| "status": "scanning", | |
| "result": job.result(), # template-only ranking while NCBI runs | |
| "warnings": warnings, | |
| }) | |
| def _primers_signin_gate(): | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"error": "This requires a free account. Sign in to continue.", | |
| "kind": "signin_required", | |
| "signup_url": "https://turingdna.com/signin/?from=primers"}), 403 | |
| return None | |
| def primers_design() -> Response: | |
| """De novo primer design from a template (offline — no NCBI).""" | |
| from .core import primers as _P | |
| gate = _primers_signin_gate() | |
| if gate is not None: | |
| return gate | |
| body = request.get_json(force=True, silent=True) or {} | |
| template = re.sub(r"\s+", "", (body.get("template") or "")).upper() | |
| if not template: | |
| return jsonify({"error": "Paste a template to design primers against."}), 400 | |
| if not re.fullmatch(r"[ACGTN]+", template): | |
| return jsonify({"error": "Template must be DNA (A/C/G/T/N) only."}), 400 | |
| if len(template) > _P.MAX_TEMPLATE: | |
| return jsonify({"error": f"Template longer than {_P.MAX_TEMPLATE:,} bp — trim to the region of interest."}), 400 | |
| def _oi(v): | |
| try: | |
| return int(v) | |
| except (TypeError, ValueError): | |
| return None | |
| def _f(v, d): | |
| try: | |
| return float(v) | |
| except (TypeError, ValueError): | |
| return d | |
| s = body.get("settings") if isinstance(body.get("settings"), dict) else {} | |
| kw = dict( | |
| product_min=max(40, _oi(s.get("product_min")) or 100), | |
| product_max=min(_P.MAX_PRODUCT, _oi(s.get("product_max")) or 1000), | |
| len_min=max(_P.MIN_LEN, _oi(s.get("len_min")) or 18), | |
| len_max=min(_P.MAX_LEN, _oi(s.get("len_max")) or 27), | |
| tm_min=_f(s.get("tm_min"), 57.0), tm_max=_f(s.get("tm_max"), 63.0), | |
| tm_opt=_f(s.get("tm_opt"), 60.0), | |
| gc_min=_f(s.get("gc_min"), 40.0), gc_max=_f(s.get("gc_max"), 60.0), | |
| max_pairs=max(1, min(20, _oi(s.get("max_pairs")) or 5)), | |
| ) | |
| try: | |
| result = _P.design_primers(template, _oi(body.get("target_start")), | |
| _oi(body.get("target_end")), **kw) | |
| except Exception: # noqa: BLE001 | |
| logger.exception("primer design failed") | |
| return jsonify({"error": "Primer design failed — check the inputs."}), 500 | |
| return jsonify(result), (200 if result.get("ok") else 400) | |
| def primers_multiplex() -> Response: | |
| """Multiplex compatibility: pairwise cross-dimers + amplicon size clashes.""" | |
| from .core import primers as _P | |
| gate = _primers_signin_gate() | |
| if gate is not None: | |
| return gate | |
| body = request.get_json(force=True, silent=True) or {} | |
| prims = body.get("primers") | |
| if isinstance(prims, str): | |
| parsed, _w = _P.parse_primers(prims) | |
| prims = [{"name": p["name"], "seq": p["seq"]} for p in parsed] | |
| if not isinstance(prims, list) or not prims: | |
| return jsonify({"error": "Provide at least two primers."}), 400 | |
| products = body.get("products") if isinstance(body.get("products"), list) else None | |
| try: | |
| result = _P.multiplex_check(prims[:_P.MAX_PRIMERS], products=products) | |
| except Exception: # noqa: BLE001 | |
| logger.exception("multiplex check failed") | |
| return jsonify({"error": "Multiplex check failed — check the inputs."}), 500 | |
| return jsonify({"ok": True, **result}) | |
| def primers_local_spec() -> Response: | |
| """In-silico PCR against a user-uploaded genome/FASTA — runs entirely on | |
| our server, so primers never leave (unlike the NCBI scan).""" | |
| from .core import primers as _P | |
| gate = _primers_signin_gate() | |
| if gate is not None: | |
| return gate | |
| body = request.get_json(force=True, silent=True) or {} | |
| prims = body.get("primers") | |
| if isinstance(prims, str): | |
| parsed, _w = _P.parse_primers(prims) | |
| prims = [{"id": p["id"], "name": p["name"], "seq": p["seq"]} for p in parsed] | |
| if not isinstance(prims, list) or not prims: | |
| return jsonify({"error": "Provide the primers to check."}), 400 | |
| fasta = body.get("fasta") or body.get("genome") or "" | |
| if not fasta.strip(): | |
| return jsonify({"error": "Upload a genome or paste a FASTA to scan against."}), 400 | |
| if len(fasta) > 12_000_000: | |
| return jsonify({"error": "Genome larger than ~12 Mb — for human-scale targets use the NCBI scan instead."}), 413 | |
| try: | |
| result = _P.local_specificity(prims[:_P.MAX_PRIMERS], fasta) | |
| except Exception: # noqa: BLE001 | |
| logger.exception("local specificity failed") | |
| return jsonify({"error": "Local specificity scan failed — check the inputs."}), 500 | |
| return jsonify(result), (200 if result.get("ok") else 400) | |
| def primers_status(job_id: str) -> Response: | |
| with _PRIMER_LOCK: | |
| job = _PRIMER_BY_ID.get(job_id) | |
| if not job: | |
| return jsonify({"error": "unknown job"}), 404 | |
| # Same rule as _require_owner_or_403 (audit C1): every PrimerJob has | |
| # an owner (the creating route is sign-in gated, unlike /api/identify | |
| # or /api/run), so unlike those, there is no anonymous-job exception | |
| # here — a bare job_id must never be enough to read someone else's | |
| # primer sequences and template DNA. | |
| auth = _auth.get_auth() | |
| if auth.anonymous or auth.user_id != job.user_id: | |
| return jsonify({"error": "not authorized", "kind": "forbidden"}), 403 | |
| return jsonify(job.public()) | |
| # ============================================================ PLASMID STUDIO | |
| # Import GenBank/FASTA/raw → annotated plasmid model; restriction analysis + | |
| # digest (1088 REBASE enzymes via Biopython); GenBank/FASTA export; per-user | |
| # saved-plasmid library. Sign-in gated like CRISPR / the Primer Picker. | |
| def _plasmid_signin_guard(): | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return None, (jsonify({ | |
| "error": "Plasmid Studio requires a free account. Sign in to continue.", | |
| "kind": "signin_required", | |
| "signup_url": "https://turingdna.com/signin/?from=plasmid", | |
| }), 403) | |
| return auth, None | |
| def _validate_plasmid_body(body): | |
| """Pull (sequence, topology, name, features) from a request body for the | |
| analysis/export endpoints, with bounds. Returns (data, error_response).""" | |
| from .core import plasmid as _PL | |
| seq = re.sub(r"\s+", "", (body.get("sequence") or "")).upper() | |
| if not seq: | |
| return None, (jsonify({"error": "missing 'sequence'"}), 400) | |
| if len(seq) > _PL.MAX_LEN: | |
| return None, (jsonify({"error": f"Sequence longer than {_PL.MAX_LEN:,} bp."}), 400) | |
| if not re.fullmatch(r"[ACGTNRYSWKMBDHV]+", seq): | |
| return None, (jsonify({"error": "Sequence must be DNA."}), 400) | |
| topology = "linear" if (body.get("topology") == "linear") else "circular" | |
| # Sanitize features: clamp coords, force colors to safe hex, cap count. | |
| # Defends export/save + the SVG/style rendering from malformed or | |
| # injection-laden feature dicts (audit hardening). | |
| return {"sequence": seq, "topology": topology, | |
| "name": str(body.get("name", "") or "plasmid")[:120], | |
| "features": _PL.sanitize_features(body.get("features"), len(seq))}, None | |
| def plasmid_import() -> Response: | |
| from .core import plasmid as _PL | |
| _auth_ctx, denied = _plasmid_signin_guard() | |
| if denied: | |
| return denied | |
| body = request.get_json(force=True, silent=True) or {} | |
| topology = body.get("topology") if body.get("topology") in ("circular", "linear") else None | |
| b64 = body.get("b64") | |
| try: | |
| if b64: | |
| # Binary upload (e.g. SnapGene .dna) base64-encoded in the JSON | |
| # body. Bound the decoded size BEFORE handing to the parser. | |
| import base64 | |
| import binascii | |
| if not isinstance(b64, str) or len(b64) > 4 * (_PL.MAX_INPUT_TEXT // 3 + 4): | |
| return jsonify({"error": "File too large."}), 400 | |
| try: | |
| raw = base64.b64decode(b64, validate=True) | |
| except (binascii.Error, ValueError): | |
| return jsonify({"error": "Could not decode the uploaded file."}), 400 | |
| filename = str(body.get("filename") or "")[:200] | |
| plasmid = _PL.parse_binary(raw, filename=filename, topology=topology) | |
| else: | |
| text = body.get("text") or "" | |
| plasmid = _PL.parse_plasmid(text, topology=topology) | |
| except ValueError as exc: | |
| return jsonify({"error": str(exc)}), 400 | |
| if body.get("annotate", True): | |
| _PL.auto_annotate(plasmid) | |
| return jsonify({"ok": True, "plasmid": plasmid}) | |
| def plasmid_restriction() -> Response: | |
| from .core import plasmid as _PL | |
| _auth_ctx, denied = _plasmid_signin_guard() | |
| if denied: | |
| return denied | |
| body = request.get_json(force=True, silent=True) or {} | |
| data, err = _validate_plasmid_body(body) | |
| if err: | |
| return err | |
| enzymes = body.get("enzymes") if isinstance(body.get("enzymes"), list) else None | |
| enzymes = [str(e)[:24] for e in enzymes][:120] if enzymes else None | |
| return jsonify({"ok": True, | |
| "analysis": _PL.restriction_analysis(data["sequence"], data["topology"], enzymes)}) | |
| def plasmid_digest() -> Response: | |
| from .core import plasmid as _PL | |
| _auth_ctx, denied = _plasmid_signin_guard() | |
| if denied: | |
| return denied | |
| body = request.get_json(force=True, silent=True) or {} | |
| data, err = _validate_plasmid_body(body) | |
| if err: | |
| return err | |
| enzymes = body.get("enzymes") if isinstance(body.get("enzymes"), list) else [] | |
| enzymes = [str(e)[:24] for e in enzymes][:20] | |
| if not enzymes: | |
| return jsonify({"error": "Pick at least one enzyme to digest with."}), 400 | |
| return jsonify({"ok": True, | |
| "digest": _PL.digest(data["sequence"], data["topology"], enzymes)}) | |
| def plasmid_export() -> Response: | |
| from .core import plasmid as _PL | |
| _auth_ctx, denied = _plasmid_signin_guard() | |
| if denied: | |
| return denied | |
| body = request.get_json(force=True, silent=True) or {} | |
| data, err = _validate_plasmid_body(body) | |
| if err: | |
| return err | |
| fmt = (body.get("format") or "genbank").lower() | |
| plasmid = {"name": data["name"], "sequence": data["sequence"], | |
| "topology": data["topology"], "length": len(data["sequence"]), | |
| "features": data["features"]} | |
| stem = re.sub(r"[^A-Za-z0-9_.-]", "_", data["name"]) or "plasmid" | |
| try: | |
| if fmt in ("fasta", "fa"): | |
| payload, mime, ext = _PL.build_fasta(plasmid), "text/x-fasta", "fasta" | |
| else: | |
| payload, mime, ext = _PL.build_genbank(plasmid), "chemical/x-genbank", "gb" | |
| except Exception as exc: # noqa: BLE001 | |
| return jsonify({"error": f"Export failed: {exc}"}), 500 | |
| return send_file(io.BytesIO(payload.encode("utf-8")), mimetype=mime, | |
| as_attachment=True, download_name=f"{stem}.{ext}") | |
| def align_sequences() -> Response: | |
| """Pairwise alignment (global/local) of two sequences. Stateless utility, | |
| no sign-in needed; bounded by align()'s size cap + the global rate limit.""" | |
| from .core import align as _AL | |
| body = request.get_json(force=True, silent=True) or {} | |
| a, b = body.get("a") or "", body.get("b") or "" | |
| mode = "local" if body.get("mode") == "local" else "global" | |
| try: | |
| result = _AL.align(a, b, mode=mode) | |
| except ValueError as exc: | |
| return jsonify({"error": str(exc)}), 400 | |
| except Exception: # noqa: BLE001 | |
| logger.exception("alignment failed") | |
| return jsonify({"error": "Alignment failed — check the two sequences."}), 500 | |
| return jsonify({"ok": True, **result}) | |
| # ── Attribution ─────────────────────────────────────────────────────── | |
| # The whole point of the construct being the selected object: when a tool | |
| # saves something and the client sent the id it was working under, the | |
| # link is RECORDED at the moment it happened rather than reconstructed | |
| # later by matching name strings. | |
| # | |
| # Deliberately best-effort and silent. Attribution failing must never fail | |
| # the save the user actually asked for — the artifact still exists in its | |
| # own table, it is only the cross-reference that is missing. | |
| def _attribute(auth, body, kind, result): | |
| try: | |
| cid = str((body or {}).get("construct_id") or "").strip() | |
| new_id = (result or {}).get("id") | |
| if not cid or not new_id or auth.anonymous: | |
| return | |
| _auth.attach_artifact(auth.user_id, cid, kind, str(new_id)) | |
| # ...and the EDGE. attach_artifact says the construct has this | |
| # artifact; this says where the artifact came from. The client | |
| # sends `derived_from` when it knows the specific parent (a | |
| # plasmid region handed to CRISPR); when it does not, the honest | |
| # parent is the construct's own sequence, which is the root of | |
| # every graph and is always true. | |
| src = (body or {}).get("derived_from") or {} | |
| from_kind = str(src.get("kind") or "").strip() | |
| from_id = str(src.get("id") or "").strip() | |
| if from_kind and from_id: | |
| origin = "handoff" | |
| else: | |
| from_kind, from_id, origin = "sequence", cid, "handoff" | |
| _auth.record_edge( | |
| auth.user_id, | |
| from_kind=from_kind, from_id=from_id, | |
| to_kind=kind, to_id=str(new_id), | |
| relation=str(src.get("relation") or "derived_from"), | |
| origin=origin, construct_id=cid, | |
| detail={k: v for k, v in (src.get("detail") or {}).items() | |
| if isinstance(k, str)} or {}) | |
| except Exception: # noqa: BLE001 | |
| app.logger.warning("attribution failed for %s", kind, exc_info=True) | |
| def plasmid_save() -> Response: | |
| auth, denied = _plasmid_signin_guard() | |
| if denied: | |
| return denied | |
| body = request.get_json(force=True, silent=True) or {} | |
| data, err = _validate_plasmid_body(body) | |
| if err: | |
| return err | |
| try: | |
| gc = float(body.get("gc")) if body.get("gc") is not None else None | |
| except (TypeError, ValueError): | |
| gc = None | |
| result = _auth.save_plasmid( | |
| auth.user_id, | |
| name=data["name"], topology=data["topology"], | |
| length=len(data["sequence"]), gc=gc, | |
| source=str(body.get("source", "") or "")[:16] or None, | |
| sequence=data["sequence"], features=data["features"], | |
| ) | |
| _attribute(auth, body, "plasmid", result) | |
| _auth.cleanup_expired_plasmids_async(auth.user_id) | |
| return jsonify(result), (200 if result.get("ok") else 502) | |
| # ── Turing's memory ─────────────────────────────────────────────────── | |
| # Facts the agent carries between conversations. The whole point of this | |
| # surface is that the user can SEE and change what is remembered about | |
| # them — an agent whose beliefs are only visible in its behaviour is not a | |
| # glass box. Nothing here is optional polish; without it `remember` would | |
| # be a hidden accumulator. | |
| def _memory_signin_guard(): | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return None, (jsonify({ | |
| "error": "Memory needs a free account. Sign in to continue.", | |
| "kind": "signin_required", | |
| "signup_url": "https://turingdna.com/signin/?from=memory", | |
| }), 403) | |
| return auth, None | |
| def memory_list() -> Response: | |
| auth, denied = _memory_signin_guard() | |
| if denied: | |
| return denied | |
| rows = _auth.list_memory(auth.user_id) | |
| return jsonify({ | |
| "ok": True, | |
| "memories": rows, | |
| # Split so the client can show proposals as something to decide on | |
| # rather than as facts already in force. | |
| "kept": sum(1 for r in rows if r.get("status") == "kept"), | |
| "proposed": sum(1 for r in rows if r.get("status") == "proposed"), | |
| }) | |
| def memory_add() -> Response: | |
| """The user adding a fact themselves. Lands 'kept' — they are the one | |
| who decides, so there is nothing to confirm.""" | |
| auth, denied = _memory_signin_guard() | |
| if denied: | |
| return denied | |
| body = request.get_json(silent=True) or {} | |
| fact = str(body.get("fact") or "") | |
| why = _auth.memory_rejection(fact) | |
| if why: | |
| return jsonify({"ok": False, "error": why}), 400 | |
| out = _auth.remember_fact(auth.user_id, fact, | |
| kind=str(body.get("kind") or "context")) | |
| if not out.get("ok"): | |
| return jsonify(out), 400 | |
| if out.get("id"): | |
| _auth.keep_memory(auth.user_id, out["id"]) | |
| return jsonify({"ok": True, "id": out.get("id"), "status": "kept"}) | |
| def memory_keep(memory_id: str) -> Response: | |
| auth, denied = _memory_signin_guard() | |
| if denied: | |
| return denied | |
| out = _auth.keep_memory(auth.user_id, memory_id) | |
| return jsonify(out), (200 if out.get("ok") else 400) | |
| def memory_edit(memory_id: str) -> Response: | |
| auth, denied = _memory_signin_guard() | |
| if denied: | |
| return denied | |
| body = request.get_json(silent=True) or {} | |
| out = _auth.edit_memory(auth.user_id, memory_id, str(body.get("fact") or "")) | |
| return jsonify(out), (200 if out.get("ok") else 400) | |
| def memory_forget(memory_id: str) -> Response: | |
| auth, denied = _memory_signin_guard() | |
| if denied: | |
| return denied | |
| out = _auth.forget_memory(auth.user_id, memory_id) | |
| return jsonify(out), (200 if out.get("ok") else 400) | |
| # ── The scientist's own standing instructions (agent_instructions, 0052) ── | |
| # Their CLAUDE.md. Distinct from /api/memory above: memory holds facts the | |
| # AGENT proposed and they kept; this is prose they wrote about how they | |
| # want to be worked with. See orchestrator._instructions_note for why the | |
| # two are injected under different headings. | |
| def fold_capability() -> Response: | |
| """Can THIS caller fold privately? Asked before the sequence exists. | |
| The client needs this to decide whether to show the "Send sequence to | |
| Meta?" consent panel at all — a paid user must never see it, and | |
| discovering entitlement by POSTing the sequence first would mean | |
| building the whole flow around a request that is usually refused. | |
| Reports `private` only when the plan allows it AND the backend is | |
| actually configured. "Availability must mean reachable": a plan flag | |
| alone would promise a paid feature that 503s. | |
| """ | |
| from .core import folding as _fold | |
| auth = _auth.get_auth() | |
| plan = "free" if auth.anonymous else _auth.plan_for(auth.user_id) | |
| entitled = plan != "free" | |
| return jsonify({ | |
| "private": bool(entitled and _fold.is_configured()), | |
| "entitled": entitled, | |
| "plan": plan, | |
| "max_residues": _fold.max_residues(), | |
| }) | |
| def fold_private() -> Response: | |
| """Fold a sequence on OUR GPU. Paid plans only. | |
| The free tier keeps the existing client-side path to | |
| api.esmatlas.com, behind its consent dialog — nothing is taken away. | |
| What a paid plan buys is that the sequence never reaches a third | |
| party at all: browser -> Space -> our Modal GPU. | |
| FAILS CLOSED, TWICE OVER. plan_for() already resolves to 'free' on | |
| any uncertainty (anonymous, Supabase down, unknown plan name), so an | |
| error denies the perk rather than granting it. And if the backend is | |
| unreachable this returns an error rather than falling back to the | |
| public endpoint: quietly shipping a paying customer's sequence to | |
| Meta because our own GPU was down is the one outcome this feature | |
| exists to prevent, and the one they would never find out about. | |
| """ | |
| from .core import folding as _fold | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({ | |
| "error": "Private folding needs a signed-in account.", | |
| "kind": "signin_required", | |
| "signup_url": "https://turingdna.com/signin/?from=fold", | |
| }), 403 | |
| plan = _auth.plan_for(auth.user_id) | |
| if plan == "free": | |
| return jsonify({ | |
| "error": "Private folding is on the paid plans. On Free you can " | |
| "still fold through the public ESM Atlas service.", | |
| "kind": "upgrade_required", | |
| "plan": plan, | |
| "pricing_url": "https://turingdna.com/pricing/", | |
| }), 402 | |
| body = request.get_json(silent=True) or {} | |
| sequence = str(body.get("sequence") or "") | |
| try: | |
| out = _fold.fold(sequence) | |
| except _fold.NotConfigured as exc: | |
| # Configured != deployed. Say which it is, rather than implying | |
| # the user did something wrong. | |
| logger.warning("private fold not configured: %s", exc) | |
| return jsonify({"error": "Private folding isn't switched on for this " | |
| "deployment yet.", "kind": "not_configured"}), 503 | |
| except _fold.FoldError as exc: | |
| return jsonify({"error": str(exc), "kind": "fold_failed"}), 502 | |
| return jsonify({"ok": True, "pdb": out.get("pdb", ""), | |
| "length": out.get("length"), | |
| "seconds": out.get("seconds"), | |
| "source": "TuringDNA GPU"}) | |
| def instructions_get() -> Response: | |
| auth, denied = _memory_signin_guard() | |
| if denied: | |
| return denied | |
| row = _auth.get_instructions(auth.user_id) | |
| return jsonify({ | |
| "ok": True, | |
| "body": row.get("body") or "", | |
| "enabled": bool(row.get("enabled", True)), | |
| "updated_at": row.get("updated_at"), | |
| # The client renders the counter against the real server limit | |
| # rather than a hardcoded copy that can drift from the schema. | |
| "limit": _auth._INSTRUCTIONS_MAX, | |
| }) | |
| def instructions_put() -> Response: | |
| auth, denied = _memory_signin_guard() | |
| if denied: | |
| return denied | |
| body = request.get_json(silent=True) or {} | |
| text = str(body.get("body") or "") | |
| why = _auth.instructions_rejection(text) | |
| if why: | |
| return jsonify({"ok": False, "error": why}), 400 | |
| enabled = body.get("enabled") | |
| out = _auth.set_instructions( | |
| auth.user_id, text, | |
| enabled=(None if enabled is None else bool(enabled))) | |
| return jsonify(out), (200 if out.get("ok") else 400) | |
| def plasmid_library() -> Response: | |
| auth, denied = _plasmid_signin_guard() | |
| if denied: | |
| return denied | |
| return jsonify({"ok": True, "plasmids": _auth.list_plasmids(auth.user_id)}) | |
| def plasmid_library_get(plasmid_id: str) -> Response: | |
| auth, denied = _plasmid_signin_guard() | |
| if denied: | |
| return denied | |
| p = _auth.get_plasmid(auth.user_id, plasmid_id) | |
| if p is None: | |
| return jsonify({"ok": False, "error": "Plasmid not found."}), 404 | |
| return jsonify({"ok": True, "plasmid": p}) | |
| def plasmid_library_delete(plasmid_id: str) -> Response: | |
| auth, denied = _plasmid_signin_guard() | |
| if denied: | |
| return denied | |
| return jsonify(_auth.delete_plasmid(auth.user_id, plasmid_id)) | |
| def plasmid_clone() -> Response: | |
| """Cloning simulation: Gibson / Golden Gate / restriction. Returns the | |
| assembled construct in the standard plasmid-ish shape.""" | |
| from .core import cloning as _CL | |
| _auth_ctx, denied = _plasmid_signin_guard() | |
| if denied: | |
| return denied | |
| body = request.get_json(force=True, silent=True) or {} | |
| method = (body.get("method") or "gibson").lower() | |
| try: | |
| if method == "gibson": | |
| frags = body.get("fragments") | |
| if not isinstance(frags, list): | |
| return jsonify({"error": "Provide a list of fragments."}), 400 | |
| res = _CL.gibson(frags, circular=bool(body.get("circular", True))) | |
| elif method == "golden_gate": | |
| frags = body.get("fragments") | |
| if not isinstance(frags, list): | |
| return jsonify({"error": "Provide a list of parts."}), 400 | |
| res = _CL.golden_gate(frags, enzyme=str(body.get("enzyme", "BsaI"))[:12]) | |
| elif method == "restriction": | |
| res = _CL.restriction_clone( | |
| str(body.get("vector", "")), str(body.get("insert", "")), | |
| str(body.get("enzyme", ""))[:24], | |
| vector_topology="linear" if body.get("vector_topology") == "linear" else "circular") | |
| elif method in ("yeast_hr", "yeast"): | |
| frags = body.get("fragments") | |
| if not isinstance(frags, list): | |
| return jsonify({"error": "Provide a list of fragments (the linearised vector is one of them)."}), 400 | |
| res = _CL.yeast_assembly( | |
| frags, circular=bool(body.get("circular", True)), | |
| marker=(str(body.get("marker")).strip()[:40] if body.get("marker") else None)) | |
| elif method in ("yeast_integration", "integration"): | |
| res = _CL.yeast_integration( | |
| str(body.get("upstream", "")), str(body.get("downstream", "")), | |
| str(body.get("insert", "") or body.get("cassette", "")), | |
| arm=int(body.get("arm") or 40), | |
| marker=(str(body.get("marker")).strip()[:40] if body.get("marker") else None), | |
| site_name=str(body.get("site") or "locus")[:40]) | |
| else: | |
| return jsonify({"error": f"Unknown method: {method}."}), 400 | |
| except Exception as exc: # noqa: BLE001 | |
| logger.exception("clone failed") | |
| return jsonify({"error": f"Assembly failed: {exc}"}), 500 | |
| if not res.get("ok"): | |
| return jsonify(res), 200 # business-level failure (bad overlaps etc.) | |
| return jsonify(res) | |
| # ================================================================= CRISPR | |
| # Cas9 gRNA design for knockout applications. MVP scope: pasted DNA | |
| # → ranked SpCas9 guides with Doench-style heuristic on-target | |
| # scoring. Sign-in gated (free for any account); anonymous users | |
| # get a 403 with a sign-in CTA so the frontend can route them to | |
| # /signin instead of the engine's trial gate. Whole-genome off-target, | |
| # Cas12a, base editors, and HDR donor design are deferred — see | |
| # dee/core/crispr.py module docstring for the full scope ladder. | |
| def crispr_design() -> Response: | |
| # Sign-in gate. Distinct from the trial timer (which is per-anon- | |
| # session): CRISPR requires an account, no anonymous use. The | |
| # 403 carries kind="signin_required" so static auth.js can route | |
| # the parent window to /signin instead of showing the trial modal. | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({ | |
| "error": ( | |
| "CRISPR design requires a free account. " | |
| "Sign in or create one to keep going." | |
| ), | |
| "kind": "signin_required", | |
| "signup_url": "https://turingdna.com/signin/?from=crispr", | |
| }), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| seq = (body.get("sequence") or "").strip() | |
| if not seq: | |
| return jsonify({"error": "missing 'sequence'"}), 400 | |
| # Bound the input so a 10 MB paste doesn't tie up the worker. | |
| # 1 Mbp covers any reasonable cloning-scale region (largest | |
| # human genes are ~2.4 Mbp, but at that scale you'd use a | |
| # whole-genome tool, not a paste box). | |
| if len(seq) > 1_000_000: | |
| return jsonify({ | |
| "error": ( | |
| "Sequence too long. Cap is 1 Mbp — paste just the " | |
| "gene / region you're editing, not a whole " | |
| "chromosome." | |
| ), | |
| "kind": "input_too_large", | |
| }), 400 | |
| try: | |
| max_results = int(body.get("max_results", 50)) | |
| except (TypeError, ValueError): | |
| max_results = 50 | |
| max_results = max(1, min(500, max_results)) # bound | |
| try: | |
| min_score = float(body.get("min_score", 0.0)) | |
| except (TypeError, ValueError): | |
| min_score = 0.0 | |
| min_score = max(0.0, min(1.0, min_score)) | |
| # Enzyme picker — Phase 1 supports SpCas9 and Cas12a. Default cas9 | |
| # for backward compat with the existing frontend (which sends no | |
| # `enzyme` field on the current build). | |
| enzyme = str(body.get("enzyme", "cas9")).lower() | |
| if enzyme not in ("cas9", "cas12a"): | |
| enzyme = "cas9" | |
| # Phase 2B-1: optional genome off-target + Ensembl exon context. | |
| # Both are opt-in; default empty means "skip" so existing | |
| # frontends keep working unchanged. | |
| target_organism = str(body.get("target_organism", "")).lower().strip() | |
| gene_symbol = str(body.get("gene_symbol", "")).strip() | |
| # Light input validation — anything else gets ignored. | |
| # Validate against the actual genome registry rather than a hardcoded | |
| # list, so adding an organism to offtarget.GENOME_SOURCES enables it | |
| # end to end. The old literal tuple silently rejected any new organism | |
| # and fell back to "no genome search". | |
| from dee.core.offtarget import GENOME_SOURCES as _GENOMES | |
| if target_organism and target_organism not in _GENOMES: | |
| target_organism = "" | |
| if len(gene_symbol) > 32 or not re.match(r"^[A-Za-z0-9._-]*$", gene_symbol): | |
| gene_symbol = "" | |
| # Phase 2C-1: optional cloning vector for per-guide oligo gen. | |
| from dee.core import crispr_cloning as _cc | |
| vector_id = str(body.get("vector_id", "")).lower().strip() | |
| if vector_id and vector_id not in _cc.CRISPR_VECTORS: | |
| vector_id = "" | |
| # Phase 3 (M1): base-editing mode. Defaults to knockout so the | |
| # existing frontend (which sends no `mode`) is unaffected. | |
| from dee.core import base_editor as _be | |
| mode = str(body.get("mode", "knockout")).lower() | |
| if mode not in ("knockout", "base_edit"): | |
| mode = "knockout" | |
| base_editor = str(body.get("base_editor", "")).lower().strip() | |
| if base_editor and _be.get_base_editor(base_editor) is None: | |
| base_editor = "" | |
| try: | |
| guides = _find_crispr_guides( | |
| seq, enzyme=enzyme, max_results=max_results, min_score=min_score, | |
| target_organism=target_organism, gene_symbol=gene_symbol, | |
| vector_id=vector_id, mode=mode, base_editor=base_editor, | |
| ) | |
| except ValueError as exc: | |
| return jsonify({"error": str(exc), "kind": "validation"}), 400 | |
| except Exception as exc: # noqa: BLE001 | |
| logger.exception("CRISPR design failed.") | |
| return jsonify({ | |
| "error": f"{type(exc).__name__}: {exc}", | |
| "kind": "internal", | |
| }), 500 | |
| # Flatten dataclasses into plain dicts for JSON. New Phase 1 | |
| # fields (composite_score, cfd_max_offtarget, offtarget_count, | |
| # ko_efficacy, ko_reasoning, enzyme) get exposed so the frontend | |
| # table can render them. | |
| # | |
| # Phase 2D: genome_index_status surfaces whether the off-target | |
| # search actually ran. "ready" = index hit, hits are real. | |
| # "building" = first-time index build is in flight, guides | |
| # returned without genome data; the frontend shows a banner | |
| # telling the user to refresh in 2 min. "n/a" = no organism | |
| # requested. | |
| from dee.core import offtarget as _ot_status | |
| genome_index_status = _ot_status.index_status(target_organism) | |
| # Only the top-ranked guides get the (~3.5 s each) genome query, so | |
| # say exactly how many were screened rather than letting the UI imply | |
| # every guide was checked. | |
| from dee.core.crispr import GENOME_OFFTARGET_TOP_N as _GENOME_TOP_N | |
| genome_searched_top_n = ( | |
| min(_GENOME_TOP_N, len(guides)) | |
| if (genome_index_status == "ready" and enzyme == "cas9") else 0 | |
| ) | |
| from dee.core import outcomes as _O | |
| # One serializer, shared with the agent's design_crispr_guides tool | |
| # (dee/core/crispr.guide_to_dict) — both feed the same results table. | |
| from dee.core.crispr import guide_to_dict as _guide_to_dict | |
| guide_dicts = [_guide_to_dict(g) for g in guides] | |
| # Cross-user CRISPR on-target prior → fold into scores + re-rank. No-op | |
| # until public.outcome_priors has a 'crispr' aggregate (post effective date). | |
| guide_dicts = _O.calibrate_crispr_guides(guide_dicts, _load_tool_prior("crispr")) | |
| return jsonify({ | |
| "input_length": len(seq.strip()), | |
| "n_guides": len(guide_dicts), | |
| "enzyme": enzyme, | |
| "mode": mode, | |
| "base_editor": base_editor or (_be.DEFAULT_BASE_EDITOR if mode == "base_edit" else ""), | |
| "genome_organism": target_organism, | |
| "genome_index_status": genome_index_status, | |
| "genome_searched_top_n": genome_searched_top_n, | |
| "calibrated": any("calibration" in g for g in guide_dicts), | |
| "guides": guide_dicts, | |
| }) | |
| def crispr_methods() -> Response: | |
| """How every scored column in the CRISPR table was actually computed — | |
| formula, published basis, and where it stops being trustworthy. PUBLIC: | |
| the whole point is that a reviewer can audit the numbers without asking | |
| us. Mirrors dee/core/crispr.py exactly (see crispr_methods.py).""" | |
| from dee.core import crispr_methods as _cm | |
| payload = _cm.methods_payload() | |
| payload["ok"] = True | |
| return jsonify(payload) | |
| def crispr_vectors() -> Response: | |
| """List the curated CRISPR expression vectors the engine supports. | |
| Used by the frontend to populate the vector dropdown above the | |
| results table. Filterable by ?enzyme=cas9|cas12a.""" | |
| from dee.core import crispr_cloning as _cc | |
| enzyme = request.args.get("enzyme", "").strip().lower() | |
| if enzyme not in ("", "cas9", "cas12a"): | |
| enzyme = "" | |
| vectors = _cc.list_vectors(enzyme) | |
| return jsonify({ | |
| "default": _cc.default_vector_for(enzyme or "cas9"), | |
| "vendors": _cc.VENDORS, | |
| "vectors": [ | |
| { | |
| "id": v.id, | |
| "name": v.name, | |
| "addgene_id": v.addgene_id, | |
| "enzyme": v.enzyme, | |
| "nuclease": v.nuclease, | |
| "selection": v.selection, | |
| "description": v.description, | |
| } | |
| for v in vectors | |
| ], | |
| }) | |
| def crispr_base_editors() -> Response: | |
| """List the base editors the engine supports, for the base-edit | |
| mode dropdown. Filterable by ?kind=CBE|ABE.""" | |
| from dee.core import base_editor as _be | |
| kind = request.args.get("kind", "").strip().upper() | |
| if kind not in ("", "CBE", "ABE"): | |
| kind = "" | |
| editors = _be.list_base_editors(kind) | |
| return jsonify({ | |
| "default": _be.DEFAULT_BASE_EDITOR, | |
| "editors": [ | |
| { | |
| "id": e.id, | |
| "name": e.name, | |
| "kind": e.kind, | |
| "target_base": e.target_base, | |
| "result_base": e.result_base, | |
| "window": list(e.window), | |
| "citation": e.citation, | |
| "note": e.note, | |
| } | |
| for e in editors | |
| ], | |
| }) | |
| def crispr_structure() -> Response: | |
| """Resolve a gene → UniProt + AlphaFold model URL for the structure | |
| viewer (Phase 3, M5). Body: {organism, gene_symbol}. Sign-in gated. | |
| Only (organism, gene_symbol) leaves the Space.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| organism = str(body.get("organism", "")).lower().strip() | |
| gene_symbol = str(body.get("gene_symbol", "")).strip() | |
| # Any organism: AlphaFold DB covers all of UniProt. The old | |
| # human/mouse gate is why SpCas9 — which has had a model the whole | |
| # time — came back as "no structure". | |
| if not organism or len(organism) > 64 or not re.match(r"^[A-Za-z0-9 .'-]+$", organism): | |
| return jsonify({"ok": False, | |
| "error": "Provide an organism, e.g. human or S. pyogenes."}), 422 | |
| if not gene_symbol or len(gene_symbol) > 32 or not re.match(r"^[A-Za-z0-9._-]+$", gene_symbol): | |
| return jsonify({"ok": False, "error": "Provide a valid gene symbol."}), 422 | |
| from dee.core import resolve as _resolve | |
| try: | |
| result = _resolve.resolve_uniprot(organism, gene_symbol) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.exception("CRISPR structure resolve failed.") | |
| return jsonify({"ok": False, "error": f"{type(exc).__name__}: {exc}"}), 500 | |
| return jsonify(result), (200 if result.get("ok") else 422) | |
| def public_structure() -> Response: | |
| """PUBLIC gene → AlphaFold model resolve. Body: {organism, gene_symbol}. | |
| Deliberately NOT sign-in gated, unlike /api/crispr/structure. Both call | |
| the same resolver, but this one exists because gating it was producing a | |
| FALSEHOOD: the Director folds a protein as part of a workflow, and for an | |
| anonymous visitor every gene outside a small hardcoded list came back 403 | |
| → the UI narrated "no public structure resolved for PTEN", which is | |
| simply untrue (PTEN is P60484 in AlphaFold DB). Telling a scientist a | |
| structure doesn't exist when it does is a credibility failure, and it | |
| made the product un-evaluatable without an account. | |
| Safe to open: the ONLY thing that leaves the Space is (organism, | |
| gene_symbol) — both public identifiers, never a user sequence — and the | |
| answer is a URL into the public AlphaFold DB. Rate-limited like every | |
| other outbound-lookup route. | |
| """ | |
| body = request.get_json(force=True, silent=True) or {} | |
| organism = str(body.get("organism", "")).lower().strip() | |
| gene_symbol = str(body.get("gene_symbol", "")).strip() | |
| if not organism or len(organism) > 64 or not re.match(r"^[A-Za-z0-9 .'-]+$", organism): | |
| return jsonify({"ok": False, "kind": "bad_organism", | |
| "error": "Provide an organism, e.g. human or S. pyogenes."}), 422 | |
| if not gene_symbol or len(gene_symbol) > 32 or not re.match(r"^[A-Za-z0-9._-]+$", gene_symbol): | |
| return jsonify({"ok": False, "kind": "bad_symbol", | |
| "error": "Provide a valid gene symbol."}), 422 | |
| from dee.core import resolve as _resolve | |
| try: | |
| result = _resolve.resolve_uniprot(organism, gene_symbol) | |
| except Exception: # noqa: BLE001 — never leak internals | |
| logger.exception("public structure resolve failed") | |
| return jsonify({"ok": False, "kind": "lookup_failed", | |
| "error": "Structure lookup is unavailable right now."}), 503 | |
| # `not ok` here means the LOOKUP genuinely found nothing — the caller may | |
| # honestly say so. An auth/transport failure never reaches this branch. | |
| if not result.get("ok"): | |
| result.setdefault("kind", "not_found") | |
| return jsonify(result), (200 if result.get("ok") else 404) | |
| def crispr_resolve() -> Response: | |
| """Paste-anything resolver (Phase 3, M2). Body: {text, organism}. | |
| Resolves a gene symbol / accession / raw sequence to an editable | |
| DNA sequence + a human label. Same sign-in gate as /design. | |
| Privacy: only (organism, identifier) leaves the Space for lookups; | |
| a raw sequence makes no outbound call.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({ | |
| "ok": False, | |
| "error": "Sign in or create a free account to keep going.", | |
| "kind": "signin_required", | |
| "signup_url": "https://turingdna.com/signin/?from=crispr", | |
| }), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| text = (body.get("text") or "").strip() | |
| if not text: | |
| return jsonify({"ok": False, "error": "missing 'text'"}), 400 | |
| # Bound input: a sequence paste can be up to 1 Mbp; an identifier is | |
| # tiny. Cap defensively so a giant blob can't tie up the resolver. | |
| if len(text) > 1_000_000: | |
| return jsonify({"ok": False, "error": "Input too long (1 Mbp cap)."}), 400 | |
| # Bound but do NOT allow-list: blanking an unrecognized organism made | |
| # the resolver ask "which species?" right after the user had said. | |
| organism = str(body.get("organism", "")).lower().strip() | |
| if len(organism) > 64 or not re.match(r"^[A-Za-z0-9 .'-]*$", organism): | |
| organism = "" | |
| from dee.core import resolve as _resolve | |
| try: | |
| result = _resolve.resolve_target(text, organism=organism) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.exception("CRISPR resolve failed.") | |
| return jsonify({"ok": False, "error": f"{type(exc).__name__}: {exc}"}), 500 | |
| return jsonify(result), (200 if result.get("ok") else 422) | |
| def crispr_ip_radar() -> Response: | |
| """Live Collision IP Radar (v1, literature via Europe PMC). Opt-in, | |
| metadata-first: only the gene symbol, validated point-mutation set, and | |
| the 20-mer guide spacer are accepted — never a full novel sequence. | |
| Sign-in gated. Non-critical: any failure returns ok:False with 200 so | |
| the UI degrades quietly instead of erroring.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| gene_symbol = str(body.get("gene_symbol", "")).strip() | |
| if len(gene_symbol) > 32 or not re.match(r"^[A-Za-z0-9._-]*$", gene_symbol): | |
| gene_symbol = "" | |
| spacer = str(body.get("spacer", "")).strip().upper()[:25] | |
| if not re.match(r"^[ACGT]*$", spacer): | |
| spacer = "" | |
| raw_muts = body.get("mutations") | |
| mutations = [] | |
| if isinstance(raw_muts, list): | |
| for m in raw_muts[:8]: | |
| m = str(m).strip() | |
| # Only accept HGVS-ish point mutations (e.g. T47A) — refuse | |
| # arbitrary strings so the field can't smuggle a raw sequence. | |
| if re.match(r"^[A-Za-z]\d{1,5}[A-Za-z*]$", m): | |
| mutations.append(m) | |
| mode = str(body.get("mode", "knockout")).lower() | |
| from dee.core import ip_radar as _ipr | |
| try: | |
| result = _ipr.check_literature( | |
| gene_symbol=gene_symbol, mutations=mutations, | |
| spacer=spacer, mode=mode, | |
| ) | |
| # Patent layer (free PatentsView, gene-level "related patents"). | |
| # Off unless PATENTSVIEW_API_KEY is set; never blocks literature. | |
| try: | |
| pat = _ipr.check_patents(gene_symbol=gene_symbol) | |
| result["patents"] = pat.get("patents", []) | |
| result["patents_available"] = pat.get("available", False) | |
| except Exception: # noqa: BLE001 | |
| result["patents"] = [] | |
| result["patents_available"] = False | |
| except Exception: # noqa: BLE001 | |
| logger.exception("IP radar failed.") | |
| return jsonify({"ok": False, "error": "radar_unavailable"}), 200 | |
| return jsonify(result) | |
| # ─── Phase 3 (M4): save & revisit designs ───────────────────────── | |
| def crispr_save() -> Response: | |
| """Save the current guide set to the signed-in user's library.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required", | |
| "signup_url": "https://turingdna.com/signin/?from=crispr"}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| guides = body.get("guides") | |
| if not isinstance(guides, list) or not guides: | |
| return jsonify({"ok": False, "error": "Nothing to save — run a design first."}), 400 | |
| mode = str(body.get("mode", "knockout")).lower() | |
| if mode not in ("knockout", "base_edit"): | |
| mode = "knockout" | |
| try: | |
| input_length = int(body.get("input_length") or 0) | |
| except (TypeError, ValueError): | |
| input_length = 0 | |
| result = _auth.save_crispr_design( | |
| auth.user_id, | |
| label=str(body.get("label", ""))[:120], | |
| mode=mode, | |
| enzyme=str(body.get("enzyme", ""))[:16] or None, | |
| base_editor=str(body.get("base_editor", ""))[:32] or None, | |
| organism=str(body.get("organism", ""))[:16] or None, | |
| gene_symbol=str(body.get("gene_symbol", ""))[:32] or None, | |
| input_length=input_length, | |
| guides=guides, | |
| ) | |
| _attribute(auth, body, "crispr", result) | |
| _auth.cleanup_expired_crispr_designs_async(auth.user_id) # lazy sweep | |
| return jsonify(result), (200 if result.get("ok") else 502) | |
| def crispr_designs_list() -> Response: | |
| """The signed-in user's saved designs (metadata only).""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| return jsonify({"ok": True, "designs": _auth.list_crispr_designs(auth.user_id)}) | |
| def crispr_design_get(design_id: str) -> Response: | |
| """One saved design incl. guides (owner-only, server-enforced).""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| design = _auth.get_crispr_design(auth.user_id, design_id) | |
| if design is None: | |
| return jsonify({"ok": False, "error": "Design not found."}), 404 | |
| return jsonify({"ok": True, "design": design}) | |
| # ── Primer Analysis: saved sets (unified "My designs" hub) ── | |
| # Static routes win over /api/primers/<job_id> in Werkzeug, so no clash. | |
| def primers_save() -> Response: | |
| """Save the current primer analysis to the signed-in user's library.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required", | |
| "signup_url": "https://turingdna.com/signin/?from=primers"}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| result_obj = body.get("result") | |
| if not isinstance(result_obj, dict) or not result_obj.get("primers"): | |
| return jsonify({"ok": False, "error": "Nothing to save — analyse some primers first."}), 400 | |
| try: | |
| template_len = int(body.get("template_len") or 0) | |
| except (TypeError, ValueError): | |
| template_len = 0 | |
| try: | |
| n_primers = int(body.get("n_primers") or len(result_obj.get("primers") or [])) | |
| except (TypeError, ValueError): | |
| n_primers = len(result_obj.get("primers") or []) | |
| result = _auth.save_primer_analysis( | |
| auth.user_id, | |
| label=str(body.get("label", ""))[:120], | |
| organism=str(body.get("organism", ""))[:32] or None, | |
| template_len=template_len, | |
| n_primers=n_primers, | |
| result=result_obj, | |
| ) | |
| _attribute(auth, body, "primer", result) | |
| _auth.cleanup_expired_primer_analyses_async(auth.user_id) # lazy sweep | |
| return jsonify(result), (200 if result.get("ok") else 502) | |
| def primers_analyses_list() -> Response: | |
| """The signed-in user's saved primer sets (metadata only).""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| return jsonify({"ok": True, "analyses": _auth.list_primer_analyses(auth.user_id)}) | |
| def primers_analysis_get(analysis_id: str) -> Response: | |
| """One saved primer set incl. result (owner-only, server-enforced).""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| analysis = _auth.get_primer_analysis(auth.user_id, analysis_id) | |
| if analysis is None: | |
| return jsonify({"ok": False, "error": "Primer set not found."}), 404 | |
| return jsonify({"ok": True, "analysis": analysis}) | |
| def primers_analysis_delete(analysis_id: str) -> Response: | |
| """Delete one saved primer set (owner-only).""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| return jsonify(_auth.delete_primer_analysis(auth.user_id, analysis_id)) | |
| def primers_export() -> Response: | |
| """CSV / XLSX of a primer analysis the client already holds — so | |
| researchers can take the ranked table to the bench. The client posts | |
| the analyze result back (no re-run); we flatten + stream it. Sign-in | |
| gated like the rest of Primer Analysis.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"error": "signin_required", "kind": "signin_required"}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| result = body.get("result") | |
| primers = result.get("primers") if isinstance(result, dict) else None | |
| if not isinstance(primers, list) or not primers: | |
| return jsonify({"error": "Nothing to export — analyse some primers first."}), 400 | |
| primers = primers[:1000] # bound the export size | |
| fmt = str(body.get("format", "xlsx")).lower().strip() | |
| if fmt not in ("csv", "xlsx"): | |
| fmt = "xlsx" | |
| header = ["Rank", "Name", "Sequence", "Length (nt)", "GC %", "Tm (C)", | |
| "3' G/C clamp", "Sites on template", "Off-target products", "Fitness (0-1)"] | |
| def _row(i, p): | |
| if not isinstance(p, dict): | |
| p = {} | |
| ot = p.get("on_template") if isinstance(p.get("on_template"), dict) else {} | |
| sp = p.get("specificity") if isinstance(p.get("specificity"), dict) else {} | |
| offt = sp.get("offtarget_products") | |
| if offt is None: | |
| offt = sp.get("strong_offtargets") | |
| fit = p.get("fitness") | |
| return [ | |
| i + 1, | |
| str(p.get("name", "") or ""), | |
| str(p.get("seq", "") or ""), | |
| p.get("length", ""), | |
| p.get("gc", ""), | |
| p.get("tm", ""), | |
| "yes" if p.get("gc_clamp") else "no", | |
| ot.get("n_strong_sites", "") if ot else "", | |
| offt if offt is not None else "", | |
| round(fit, 3) if isinstance(fit, (int, float)) else "", | |
| ] | |
| rows = [header] + [_row(i, p) for i, p in enumerate(primers)] | |
| if fmt == "csv": | |
| import csv as _csv | |
| from io import StringIO as _SIO | |
| from flask import make_response as _mr | |
| sio = _SIO() | |
| w = _csv.writer(sio, quoting=_csv.QUOTE_MINIMAL, lineterminator="\r\n") | |
| for r in rows: | |
| w.writerow(r) | |
| data = ("" + sio.getvalue()).encode("utf-8") # BOM → Excel detects UTF-8 | |
| resp = _mr(data) | |
| resp.headers["Content-Type"] = "text/csv; charset=utf-8" | |
| resp.headers["Content-Disposition"] = 'attachment; filename="turingdna_primers.csv"' | |
| return resp | |
| from io import BytesIO as _BIO | |
| from openpyxl import Workbook as _WB | |
| from openpyxl.styles import Font as _Font | |
| wb = _WB() | |
| ws = wb.active | |
| ws.title = "Primers" | |
| for r in rows: | |
| ws.append(r) | |
| for cell in ws[1]: | |
| cell.font = _Font(bold=True) | |
| buf = _BIO() | |
| wb.save(buf) | |
| buf.seek(0) | |
| return send_file( | |
| buf, | |
| mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| as_attachment=True, | |
| download_name="turingdna_primers.xlsx", | |
| ) | |
| # ── Directed-Evolution libraries: per-user list + signed CSV download ── | |
| # Per-user, RLS-safe replacement for the shared-filesystem /api/library | |
| # removed in audit C2 — reads public.libraries filtered by the JWT user. | |
| def library_list() -> Response: | |
| """The signed-in user's saved DE libraries, each with its loop state. | |
| n_outcomes is what makes an OPEN LOOP visible. A library with none is | |
| an experiment still running — the numbers have not come back from the | |
| bench — but it rendered identically to one that was finished and done | |
| with, which is a large part of why nobody ever returned to close one. | |
| One extra query for the whole list, not one per library: see | |
| de_outcome_counts_by_library. | |
| """ | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| libs = _auth.list_libraries(auth.user_id) | |
| tested = _auth.de_outcome_counts_by_library(auth.user_id) | |
| for lib in libs: | |
| lib["n_outcomes"] = int(tested.get(lib.get("id"), 0)) | |
| return jsonify({"ok": True, "libraries": libs}) | |
| def library_get(library_id: str) -> Response: | |
| """One saved library, in the shape a fresh run result has. | |
| This is the door back into round 2. Everything the learning loop needs | |
| already existed on the server — the library_id, the wild type it was | |
| designed against, /api/de/outcomes/<id> to read back what was already | |
| logged — but a library could only be DOWNLOADED, so the round-2 panel | |
| was reachable only in the session that created the library. That is | |
| precisely the session in which nobody can have wet-lab results yet. | |
| Returns the same keys renderResults() consumes so a reopened library | |
| renders as itself rather than as a summary of itself. | |
| """ | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| lib = _auth.load_library(auth.user_id, library_id) | |
| if not lib: | |
| return jsonify({"ok": False, "error": "Library not found."}), 404 | |
| return jsonify({"ok": True, "reopened": True, **lib}) | |
| def library_download(library_id: str) -> Response: | |
| """Return a short-lived signed URL for the library CSV (owner-only). | |
| The browser fetches the CSV straight from Storage; the service key | |
| never leaves the server.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| url = _auth.library_csv_url(auth.user_id, library_id) | |
| if not url: | |
| return jsonify({"ok": False, "error": "Library not found."}), 404 | |
| return jsonify({"ok": True, "url": url}) | |
| # ── Assays: what a measured number means ────────────────────────────── | |
| # An assay is defined ONCE and reused by every outcome it produced. | |
| # Retyping the readout and the wild-type per measurement is how a lab ends | |
| # up with six near-identical assays and no way to pool any of them. | |
| def assays_list() -> Response: | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": True, "gated": True, "assays": []}) | |
| # Each row carries its own poolability verdict, computed by the same | |
| # `check_assay` the create path uses. The client must never re-derive | |
| # this: a second implementation in JavaScript is a second set of rules | |
| # about what counts as comparable evidence, and the two would drift. | |
| rows = _auth.list_assays(auth.user_id) | |
| for row in rows: | |
| probe = _assay.check_assay(_assay.Assay.from_row(row)) | |
| row["poolable"] = probe.comparable | |
| row["poolable_why"] = probe.why | |
| return jsonify({"ok": True, "gated": False, "assays": rows}) | |
| def assays_create() -> Response: | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"error": "Sign in to record assays.", | |
| "kind": "signin_required"}), 401 | |
| body = request.get_json(force=True, silent=True) or {} | |
| name = str(body.get("name") or "").strip() | |
| if not name: | |
| return jsonify({"error": "An assay needs a name."}), 400 | |
| res = _auth.save_assay( | |
| auth.user_id, name=name, | |
| readout=str(body.get("readout") or ""), | |
| unit=str(body.get("unit") or ""), | |
| scale=str(body.get("scale") or "ratio"), | |
| direction=str(body.get("direction") or "higher_is_better"), | |
| wt_value=body.get("wt_value"), wt_stdev=body.get("wt_stdev"), | |
| wt_n=body.get("wt_n"), | |
| conditions=body.get("conditions") if isinstance( | |
| body.get("conditions"), dict) else {}, | |
| notes=str(body.get("notes") or "")) | |
| if not res.get("ok"): | |
| return jsonify({"error": res.get("error") or "Couldn't save."}), 400 | |
| # Tell the caller straight away whether results against this assay will | |
| # be poolable, rather than letting them discover it after 40 | |
| # measurements. The missing field is almost always the wild-type, and | |
| # it is far cheaper to add before the plate is read than after. | |
| probe = _assay.check_assay(_assay.Assay.from_row(res.get("assay"))) | |
| return jsonify({"ok": True, "assay": res.get("assay"), | |
| "poolable": probe.comparable, | |
| "poolable_why": probe.why}) | |
| # ── DE learning flywheel: log outcomes + active-learning round 2 ── | |
| def de_outcomes_save() -> Response: | |
| """Save this user's measured variants for a saved DE library.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| library_id = str(body.get("library_id", "")).strip() | |
| outcomes = body.get("outcomes") | |
| if not isinstance(outcomes, list): | |
| return jsonify({"ok": False, "error": "No outcomes provided."}), 400 | |
| # An assay id is only written after it is confirmed to belong to this | |
| # user. Forwarding an unverified one would attach another lab's | |
| # readout, unit and wild-type to these numbers — worse than no assay | |
| # at all, because the result would then look poolable. | |
| assay_id = str(body.get("assay_id") or "").strip() | |
| assay_row = _auth.get_assay(auth.user_id, assay_id) if assay_id else None | |
| if assay_id and not assay_row: | |
| return jsonify({"ok": False, "error": "That assay wasn't found."}), 404 | |
| result = _auth.save_de_outcomes(auth.user_id, library_id, outcomes, | |
| assay_id=(assay_row or {}).get("id")) | |
| # THE EDGE THAT MATTERS MOST. Everything else in the graph is a | |
| # prediction pointing at another prediction; this is the one arrow that | |
| # touches reality — a library and what the bench actually measured. It | |
| # is also the edge the whole outcome loop is built to accumulate, so it | |
| # is recorded the moment results arrive rather than reconstructed. | |
| # | |
| # The results node id is derived from the library, not generated: one | |
| # results node per library, so logging a second batch adds observations | |
| # to the same node instead of littering the graph with orphans. | |
| if result.get("ok") and library_id: | |
| try: | |
| _auth.record_edge( | |
| auth.user_id, | |
| from_kind="library", from_id=library_id, | |
| to_kind="results", to_id="results:" + library_id, | |
| relation="measured", origin="handoff", | |
| construct_id=str(body.get("construct_id") or "") or None, | |
| detail={"n_outcomes": len(outcomes)}) | |
| except Exception: # noqa: BLE001 | |
| app.logger.warning("lineage: outcome edge failed", exc_info=True) | |
| # Say what was actually banked. "Saved" alone lets a lab log forty | |
| # measurements and only discover months later — when the commons is | |
| # built — that none of them could be pooled. The moment of saving is | |
| # the last point at which the fix (record your wild-type) is cheap. | |
| if result.get("ok"): | |
| a = _assay.Assay.from_row(assay_row) | |
| result["pooling"] = _assay.summarise_pooling( | |
| [_assay.normalise(r.get("measured_value"), a) | |
| for r in outcomes if isinstance(r, dict)]) | |
| _auth.cleanup_expired_de_outcomes_async(auth.user_id) # lazy sweep | |
| return jsonify(result), (200 if result.get("ok") else 502) | |
| def de_outcomes_list(library_id: str) -> Response: | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| return jsonify({"ok": True, "outcomes": _auth.list_de_outcomes(auth.user_id, library_id)}) | |
| # ── Did round 2 actually beat round 1? ──────────────────────────────── | |
| # The product's central claim, measured against this user's own recorded | |
| # loops rather than asserted in copy. See dee/core/uplift.py — it refuses | |
| # far more often than it measures, and refusing is the feature. | |
| def de_uplift() -> Response: | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| # Not an error: a signed-out visitor simply has no loops. Returning | |
| # a zero-uplift shape here would be a claim, and a false one. | |
| return jsonify({"ok": True, "gated": True, | |
| "summary": _uplift.summarise([]), "pairs": []}) | |
| pairs = _uplift.pair_rounds(_auth.list_lineage(auth.user_id)) | |
| if not pairs: | |
| return jsonify({"ok": True, "gated": False, | |
| "summary": _uplift.summarise([]), "pairs": []}) | |
| # One read per library involved, de-duplicated: a library that is | |
| # round 2 of one pair is often round 1 of the next. | |
| wanted = {lid for p in pairs for lid in (p.round1, p.round2)} | |
| outcomes = {lid: _auth.list_de_outcomes(auth.user_id, lid) for lid in wanted} | |
| assays = {str(a.get("id")): a for a in _auth.list_assays(auth.user_id)} | |
| results = [_uplift.measure_pair(p, outcomes.get(p.round1, []), | |
| outcomes.get(p.round2, []), assays) | |
| for p in pairs] | |
| return jsonify({"ok": True, "gated": False, | |
| "summary": _uplift.summarise(results), | |
| "pairs": [vars(r) for r in results]}) | |
| # ── "I already have data" ───────────────────────────────────────────── | |
| # The Learn phase used to be gated behind a wet-lab cycle: design a | |
| # library, wait three months, come back and type numbers in. Almost nobody | |
| # completes that, so the flywheel could not start and the engine's central | |
| # claim went untested by anyone but us. | |
| # | |
| # Most protein engineers arrive WITH data. /api/de/round2 never actually | |
| # required a prior library — it takes a wild-type and a list of | |
| # measurements — so the only thing between that spreadsheet and a | |
| # same-session result was a way to read it. | |
| # | |
| # Deliberately open to anonymous visitors, like the other design tools: a | |
| # scientist deciding whether to trust this should be able to check it | |
| # against results they already trust, before making an account. | |
| def de_match() -> Response: | |
| """Read a results file against ONE library's variants. | |
| After three weeks at the bench a scientist has a plate-reader export, | |
| not sixty numbers to retype into a form. This takes the file and fills | |
| the round-2 panel from it. | |
| The parsing is dee/core/measurements.py — the same module the "I | |
| already have data" door uses, deliberately. It is careful about the | |
| two things that quietly ruin this: silent loss (every rejected row | |
| comes back with its own reason) and residue numbering (a table written | |
| against the mature protein scores as garbage against a precursor | |
| wild-type, and "your data does not correlate" is the most damaging | |
| wrong answer this product can give). Re-implementing that in the | |
| browser would duplicate the careful half and drift from it. | |
| Three outcomes are reported separately, because they need different | |
| things from the user: rows we could not read, rows whose numbering | |
| does not fit the wild-type, and rows that read fine but name a variant | |
| this library does not contain. | |
| """ | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required"}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| lib = _auth.load_library(auth.user_id, str(body.get("library_id") or "")) | |
| if not lib: | |
| return jsonify({"ok": False, "error": "Library not found."}), 404 | |
| wt = re.sub(r"\s+", "", str(lib.get("wt_protein") or "")).upper() | |
| if not wt: | |
| return jsonify({"ok": False, "error": | |
| "This library has no stored wild-type, so there is nothing to " | |
| "check the mutation numbering against."}), 400 | |
| # A FILE is not a pasted two-column table. parse_table takes the value | |
| # from the last field and the mutation from everything before it, | |
| # which is right for a paste and wrong for a plate-reader export whose | |
| # first column is a well id. reduce_columns finds the two columns that | |
| # matter and says which it chose; it declines rather than guesses, and | |
| # a two-column file falls straight through to the original path. | |
| text = str(body.get("text") or "") | |
| reduced, columns_note = _meas.reduce_columns(text) | |
| parsed = _meas.parse_table(reduced if reduced is not None else text) | |
| rejects = [vars(j) for j in parsed.rejects] | |
| if not parsed.rows: | |
| return jsonify({"ok": True, "matched": [], "unmatched": [], | |
| "rejects": rejects, "offset_hint": None, | |
| "columns": columns_note, "n_parsed": 0}) | |
| rows = parsed.rows | |
| try: | |
| offset = int(body.get("offset") or 0) | |
| except (TypeError, ValueError): | |
| offset = 0 | |
| if offset: | |
| rows = _meas.apply_offset(rows, offset) | |
| # Never renumber someone's data unasked. Report the shift and let them | |
| # accept it; off-by-one on residue numbering is the classic way to | |
| # lose a month, and it is free to catch here. | |
| hint = None if offset else _meas.detect_offset(rows, wt) | |
| if hint: | |
| return jsonify({"ok": True, "matched": [], "unmatched": [], | |
| "rejects": rejects, "offset_hint": hint, | |
| "columns": columns_note, | |
| "n_parsed": len(parsed.rows)}) | |
| kept, wt_bad = _meas.check_against_wt(rows, wt) | |
| rejects += [vars(j) for j in wt_bad] | |
| # A12V,L7M and L7M,A12V are the same variant. Matching on the joined | |
| # string would drop the row and call it "not in this library", which | |
| # is the silent loss this whole module exists to prevent. | |
| def _key(label): | |
| return ",".join(sorted(p.strip().upper() | |
| for p in str(label or "").split(",") if p.strip())) | |
| by_key = {} | |
| for v in (lib.get("variants") or []): | |
| label = v.get("Mutations_AA") | |
| if label: | |
| by_key.setdefault(_key(label), label) | |
| matched, unmatched = [], [] | |
| for r in kept: | |
| hit = by_key.get(_key(r.mutations)) | |
| if hit: | |
| # Return the LIBRARY's spelling: the panel's inputs are keyed | |
| # by it, so the user's ordering must not reach the DOM lookup. | |
| matched.append({"mutations": hit, "measured_value": r.measured_value}) | |
| else: | |
| unmatched.append({"mutations": r.mutations, | |
| "measured_value": r.measured_value, "line": r.line}) | |
| return jsonify({"ok": True, "matched": matched, "unmatched": unmatched, | |
| "rejects": rejects, "offset_hint": None, | |
| "columns": columns_note, | |
| "n_parsed": len(parsed.rows)}) | |
| def de_import() -> Response: | |
| body = request.get_json(force=True, silent=True) or {} | |
| wt = re.sub(r"\s+", "", str(body.get("wt_protein", ""))).upper() | |
| if not wt or not re.match(r"^[ACDEFGHIKLMNPQRSTVWY*]+$", wt): | |
| return jsonify({"ok": False, | |
| "error": "Paste the wild-type protein sequence too — " | |
| "without it there is nothing to check the " | |
| "mutation numbering against."}), 400 | |
| if len(wt) > _DE_ROUND2_MAX_AA: | |
| return jsonify({"ok": False, "error": | |
| f"This runs in real time for proteins up to {_DE_ROUND2_MAX_AA} aa " | |
| f"on the free tier — yours is longer."}), 400 | |
| parsed = _meas.parse_table(str(body.get("text") or "")) | |
| if not parsed.rows: | |
| return jsonify({"ok": True, "n_parsed": 0, "header": parsed.header, | |
| "rejects": [vars(j) for j in parsed.rejects], | |
| "offset_hint": None, "calibration": None, | |
| "measurements": []}) | |
| rows = parsed.rows | |
| # An explicit offset means the user saw the suggestion and accepted it. | |
| # Renumbering somebody's data without being asked would be the worst | |
| # thing this endpoint could do, so it only ever happens on their say-so. | |
| try: | |
| offset = int(body.get("offset") or 0) | |
| except (TypeError, ValueError): | |
| offset = 0 | |
| if offset: | |
| rows = _meas.apply_offset(rows, offset) | |
| # Before spending an ESM pass: does the table even describe this | |
| # sequence? A whole table numbered from the mature protein would score | |
| # as garbage, and "your data doesn't correlate" is the single most | |
| # damaging wrong answer this product could give. | |
| hint = None if offset else _meas.detect_offset(rows, wt) | |
| if hint: | |
| return jsonify({"ok": True, "n_parsed": len(parsed.rows), | |
| "header": parsed.header, | |
| "rejects": [vars(j) for j in parsed.rejects], | |
| "offset_hint": hint, "calibration": None, | |
| "measurements": []}) | |
| kept, wt_bad = _meas.check_against_wt(rows, wt) | |
| rejects = [vars(j) for j in parsed.rejects] + [vars(j) for j in wt_bad] | |
| measurements = [{"mutations": r.mutations, "measured_value": r.measured_value} | |
| for r in kept] | |
| calibration = None | |
| calibration_error = "" | |
| if kept: | |
| # THE ANSWER A SCIENTIST CAME FOR: does this engine agree with | |
| # results they already trust? Same additive ΔLL the design engine | |
| # ranks by (benchmark.predict_additive), and the same two metrics | |
| # the published benchmarks report — so the number on their own | |
| # protein is computed by the code path as the number in the | |
| # Field Atlas, not a second one invented for this screen. | |
| try: | |
| from dee.core import benchmark as _bm | |
| scorer = _scoring.get_scorer("small") | |
| scores_df = _scoring.score_guarded(scorer, wt) | |
| labels = [r.mutations for r in kept] | |
| pred = _bm.predict_additive(scores_df, labels) | |
| meas_vals = [r.measured_value for r in kept] | |
| pts = [{"mutations": l, "predicted": (None if p != p else round(float(p), 4)), | |
| "measured": m} | |
| for l, p, m in zip(labels, pred, meas_vals)] | |
| scored = [(p, m) for p, m in zip(pred, meas_vals) if p == p] | |
| calibration = { | |
| "n": len(scored), | |
| "n_unscored": len(labels) - len(scored), | |
| "spearman": _bm.spearman([p for p, _ in scored], | |
| [m for _, m in scored]), | |
| # top_decile_precision is shared with the published | |
| # benchmarks, which run on thousands of variants. On a | |
| # pasted table of 13 the "top 10%" is ONE variant, and | |
| # reporting "0%" from a single pick reads as a damning | |
| # finding rather than as too little data. The function is | |
| # left alone (changing its floor would move a published | |
| # number); the floor that matters here is applied here. | |
| "top_decile_precision": ( | |
| _bm.top_decile_precision([p for p, _ in scored], | |
| [m for _, m in scored]) | |
| if len(scored) >= _IMPORT_TDP_MIN_N else None), | |
| "top_decile_min_n": _IMPORT_TDP_MIN_N, | |
| "points": pts, | |
| "basis": "ESM-2 additive ΔLL, the same ranking the design engine uses", | |
| } | |
| except Exception: # noqa: BLE001 — the import still succeeded | |
| app.logger.exception("import calibration failed") | |
| calibration = None | |
| # A missing number must never read as "your data didn't | |
| # correlate". The table was read and is still usable; what | |
| # failed is the scorer, and saying so is the difference | |
| # between an outage and an indictment of their data. | |
| calibration_error = ( | |
| "The scoring model isn't available on this deployment right " | |
| "now, so the rank-agreement check couldn't run. Your table " | |
| "was read and you can still design a round from it.") | |
| # Direction matters and only the assay knows it. Without one the | |
| # correlation above is still real, it just cannot be compared with | |
| # anybody else's — same rule as everywhere else. | |
| pooling = None | |
| aid = str(body.get("assay_id") or "").strip() | |
| auth = _auth.get_auth() | |
| if aid and not auth.anonymous: | |
| arow = _auth.get_assay(auth.user_id, aid) | |
| a = _assay.Assay.from_row(arow) | |
| pooling = _assay.summarise_pooling( | |
| [_assay.normalise(m["measured_value"], a) for m in measurements]) | |
| return jsonify({"ok": True, "n_parsed": len(parsed.rows), | |
| "header": parsed.header, "rejects": rejects, | |
| # The wild-type's own measurement, if the pasted table | |
| # carried one. It is the anchor that decides whether | |
| # any of these results can leave the account, and it is | |
| # usually already in the file — see dee/core/assay.py. | |
| "wt_value": parsed.wt_value, | |
| "offset_hint": None, "offset_applied": offset, | |
| "calibration": calibration, | |
| "calibration_error": calibration_error, "pooling": pooling, | |
| "measurements": measurements}) | |
| def de_round2() -> Response: | |
| """Propose a round-2 library learned from this user's logged outcomes.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required", | |
| "signup_url": "https://turingdna.com/signin/?from=de"}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| library_id = str(body.get("library_id", "")).strip() | |
| wt_protein = re.sub(r"\s+", "", str(body.get("wt_protein", ""))).upper() | |
| if not wt_protein or not re.match(r"^[ACDEFGHIKLMNPQRSTVWY*]+$", wt_protein): | |
| return jsonify({"ok": False, "error": "Missing or non-standard wild-type protein."}), 400 | |
| if len(wt_protein) > _DE_ROUND2_MAX_AA: | |
| return jsonify({"ok": False, "error": | |
| f"Round 2 runs in real time for proteins up to {_DE_ROUND2_MAX_AA} aa on the " | |
| "free tier — yours is longer."}), 400 | |
| # Outcomes: DB is authoritative; allow inline only as a fallback. | |
| rows = _auth.list_de_outcomes(auth.user_id, library_id) if library_id else [] | |
| if not rows and isinstance(body.get("measurements"), list): | |
| rows = body["measurements"] | |
| _OUT_VAL = {"worked": 1.0, "partial": 0.5, "failed": 0.0} | |
| measurements = [] | |
| for r in rows: | |
| if not isinstance(r, dict): | |
| continue | |
| mv = r.get("measured_value") | |
| try: | |
| val = (float(mv) if mv is not None and mv != "" | |
| else _OUT_VAL.get(str(r.get("outcome", "")).lower())) | |
| except (TypeError, ValueError): | |
| val = None | |
| if val is None: | |
| continue | |
| measurements.append((r.get("mutations", ""), val)) | |
| # Sanitize settings — clamp model + host to known values (never load an | |
| # arbitrary HF model id from the client). | |
| s = body.get("settings") if isinstance(body.get("settings"), dict) else {} | |
| model = s.get("model", "small") | |
| if model not in ("small", "medium", "large"): | |
| model = "small" | |
| host = s.get("host", "e_coli") | |
| if host not in ("e_coli", "yeast", "human"): | |
| host = "e_coli" | |
| try: | |
| pct = float(s.get("percentile", 85.0)) | |
| except (TypeError, ValueError): | |
| pct = 85.0 | |
| pct = min(99.0, max(1.0, pct)) | |
| def _clampi(v, lo, hi, d): | |
| try: | |
| return max(lo, min(hi, int(v))) | |
| except (TypeError, ValueError): | |
| return d | |
| settings = { | |
| "model": model, "host": host, "percentile": pct, | |
| "k": _clampi(s.get("k", 30), 1, 200, 30), | |
| "min_mutations": _clampi(s.get("min_mutations", 2), 1, 10, 2), | |
| "max_mutations": _clampi(s.get("max_mutations", 5), 1, 12, 5), | |
| "restarts": _clampi(s.get("restarts", 8), 1, 24, 8), | |
| "steps": _clampi(s.get("steps", 1200), 100, 5000, 1200), | |
| "seed": s.get("seed"), | |
| } | |
| try: | |
| rows2, surrogate = _de_round2_library(wt_protein, settings, measurements) | |
| except Exception: # noqa: BLE001 | |
| logger.exception("DE round-2 failed.") | |
| return jsonify({"ok": False, "error": | |
| "Couldn't compute round 2 — check the sequence and try again."}), 500 | |
| _auth.cleanup_expired_de_outcomes_async(auth.user_id) | |
| # What of this could be compared with another lab's work, and what | |
| # could not. Reported even though round 2 uses ALL of the user's own | |
| # measurements — their own results are comparable with each other by | |
| # construction. The split matters for what leaves this account, and | |
| # showing it here is where a scientist is most likely to act on it: | |
| # the fix is usually one number, and it is far cheaper to add before | |
| # the next plate than after. | |
| assay_row = None | |
| aid = str(body.get("assay_id") or "").strip() | |
| if aid: | |
| assay_row = _auth.get_assay(auth.user_id, aid) | |
| a = _assay.Assay.from_row(assay_row) | |
| pooling = _assay.summarise_pooling( | |
| [_assay.normalise(v, a) for _, v in measurements]) | |
| return jsonify({ | |
| "ok": True, | |
| "variants": rows2, | |
| "wt_protein": wt_protein, | |
| "wt_identifier": str(body.get("wt_identifier", "") or "round 2"), | |
| "settings_used": settings, | |
| "surrogate": surrogate, | |
| "round": 2, | |
| "pooling": pooling, | |
| }) | |
| def de_epistasis() -> Response: | |
| """Interaction Radar — zero-shot epistasis analysis of a library's | |
| multi-mutants. Scores each variant's full mutant sequence with ESM-2 | |
| and reports how the model's preference for each mutation SHIFTS once | |
| the others are present (dee.core.epistasis). Opt-in + bounded: signed- | |
| in only, top-N variants, length-capped, since it's ~1 forward pass per | |
| variant on the free CPU tier.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", | |
| "kind": "signin_required", | |
| "signup_url": "https://turingdna.com/signin/?from=de"}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| wt_protein = re.sub(r"\s+", "", str(body.get("wt_protein", ""))).upper() | |
| if not wt_protein or not re.match(r"^[ACDEFGHIKLMNPQRSTVWY*]+$", wt_protein): | |
| return jsonify({"ok": False, "error": "Missing or non-standard wild-type protein."}), 400 | |
| # The 500-aa ceiling protects the shared CPU from free-tier work. ESM-2 | |
| # safely windows longer proteins around each mutated position, so Pro | |
| # can analyze Cas9-sized proteins without exceeding model context. | |
| if (len(wt_protein) > _DE_ROUND2_MAX_AA | |
| and not _auth.has_pro_plan(auth.user_id)): | |
| return jsonify({"ok": False, "error": | |
| f"Interaction analysis for proteins above {_DE_ROUND2_MAX_AA} aa requires Pro."}), 400 | |
| variants = body.get("variants") | |
| if not isinstance(variants, list) or not variants: | |
| return jsonify({"ok": False, "error": "No variants to analyze."}), 400 | |
| # Only keep the fields we use, and bound the count BEFORE any scoring. | |
| clean_variants = [] | |
| for v in variants[:60]: | |
| if isinstance(v, dict) and v.get("Mutations_AA"): | |
| clean_variants.append({ | |
| "Variant_ID": str(v.get("Variant_ID", "") or ""), | |
| "Mutations_AA": str(v.get("Mutations_AA", "") or ""), | |
| }) | |
| model = body.get("model", "small") | |
| if model not in ("small", "medium", "large"): | |
| model = "small" | |
| try: | |
| from dee.core import epistasis as _epi | |
| scorer = _scoring.get_scorer(model) | |
| # score_guarded enforces the same safety/timeout wrapper the rest of | |
| # the DE path uses; epistasis needs raw per-residue log-probs, so we | |
| # hand analyze_library the scorer's masked_log_probs directly. | |
| # MASKED, not log_probs: comparing an unmasked WT background against | |
| # an unmasked mutant one measures the model seeing the residue it is | |
| # scoring, not the interaction. See dee.core.epistasis. | |
| results = _epi.analyze_library( | |
| wt_protein, clean_variants, scorer.masked_log_probs, max_variants=12, | |
| ) | |
| except Exception: # noqa: BLE001 | |
| logger.exception("epistasis analysis failed") | |
| return jsonify({"ok": False, "error": | |
| "Couldn't analyze interactions — try again."}), 500 | |
| n_clash = sum(1 for r in results if r.verdict == "clash") | |
| return jsonify({ | |
| "ok": True, | |
| "analysis": [r.as_dict() for r in results], | |
| "n_analyzed": len(results), | |
| "n_clash": n_clash, | |
| "wt_protein_length": len(wt_protein), | |
| }) | |
| def _atlas_growth(source: str) -> Optional[Dict[str, Any]]: | |
| """Honest week-ish-over-week delta for one source, from the append- | |
| only public.atlas_snapshots ledger (migration 0020) — never derived | |
| from mutation_priors.updated_at, which is rewritten on every rebuild | |
| whether or not anything changed. Returns None (not zero) until at | |
| least two real snapshots exist, so a cold-start or pre-migration | |
| deployment reads as "no history yet", not "no growth".""" | |
| import datetime as _dt4 | |
| hist = _auth.get_atlas_history(source, limit=60) # newest-first | |
| if len(hist) < 2: | |
| return None | |
| def _parse(ts): | |
| try: | |
| return _dt4.datetime.fromisoformat(str(ts).replace("Z", "+00:00")) | |
| except (TypeError, ValueError): | |
| return None | |
| latest = hist[0] | |
| latest_at = _parse(latest.get("taken_at")) | |
| if latest_at is None: | |
| return None | |
| target = latest_at - _dt4.timedelta(days=7) | |
| best, best_at, best_diff = None, None, None | |
| for snap in hist[1:]: | |
| at = _parse(snap.get("taken_at")) | |
| if at is None: | |
| continue | |
| diff = abs((at - target).total_seconds()) | |
| if best_diff is None or diff < best_diff: | |
| best, best_at, best_diff = snap, at, diff | |
| if best is None: | |
| return None | |
| return { | |
| "substitutions_delta": int(latest.get("substitutions", 0) or 0) - int(best.get("substitutions", 0) or 0), | |
| "observations_delta": int(latest.get("observations", 0) or 0) - int(best.get("observations", 0) or 0), | |
| "since": best.get("taken_at"), | |
| "days": round((latest_at - best_at).total_seconds() / 86400.0, 1), | |
| } | |
| _COMMONS_CACHE: Dict[str, Any] = {} | |
| _COMMONS_TTL = 300.0 # seconds | |
| def _commons_summary() -> Dict[str, Any]: | |
| """The one place that reads public.mutation_priors and splits it by | |
| provenance. /api/atlas and /api/mission both call this so they can | |
| never drift apart on what "contributors" means — see migration 0018: | |
| a DMS row's n_users counts independent PUBLISHED STUDIES, a user row's | |
| n_users counts real platform labs, and summing or conflating them | |
| overstates lab adoption this product has not earned.""" | |
| # CACHED, because this is a FULL-TABLE read of mutation_priors over the | |
| # network and it ran on every page load — including for signed-out | |
| # visitors, since /api/mission builds the public trust block before it | |
| # checks auth. Measured at 680ms of the mission call's 680ms TTFB. | |
| # | |
| # Safe to cache and honest to: these are deployment-wide public | |
| # aggregates that move when the weekly literature harvest lands or a | |
| # lab logs a result, not per-request state. 300s matches the TTL this | |
| # repo already uses for availability probes. It is NOT a durable cache | |
| # — a restart re-reads, and five minutes is well inside the cadence | |
| # anything here actually changes at. | |
| _now = time.monotonic() | |
| _hit = _COMMONS_CACHE.get("v") | |
| if _hit is not None and (_now - _hit[0]) < _COMMONS_TTL: | |
| return _hit[1] | |
| rows = _auth.get_mutation_priors() # [{substitution, n_users, n_obs, mean_effect, source}] | |
| total_obs = 0 | |
| by_source = {"user": 0, "dms": 0} | |
| obs_by_source = {"user": 0, "dms": 0} | |
| labs_by_source = {"user": 0, "dms": 0} | |
| for r in rows: | |
| src = r.get("source") or "user" | |
| try: | |
| n = int(r.get("n_obs", 0) or 0) | |
| except (TypeError, ValueError): | |
| n = 0 | |
| try: | |
| nu = int(r.get("n_users", 0) or 0) | |
| except (TypeError, ValueError): | |
| nu = 0 | |
| total_obs += n | |
| if src in by_source: | |
| by_source[src] += 1 | |
| obs_by_source[src] += n | |
| labs_by_source[src] = max(labs_by_source[src], nu) | |
| out = { | |
| "rows": rows, | |
| "count": len(rows), | |
| "total_observations": total_obs, | |
| "provenance": { | |
| "from_published_studies": { | |
| "substitutions": by_source["dms"], | |
| "observations": obs_by_source["dms"], | |
| "studies": labs_by_source["dms"], | |
| "unit": "independent published DMS assays", | |
| }, | |
| "from_platform_labs": { | |
| "substitutions": by_source["user"], | |
| "observations": obs_by_source["user"], | |
| "labs": labs_by_source["user"], | |
| "unit": "distinct labs logging wet-lab outcomes", | |
| }, | |
| }, | |
| "growth": {"dms": _atlas_growth("dms"), "user": _atlas_growth("user")}, | |
| } | |
| # Only a REAL read is worth caching. get_mutation_priors() returns [] | |
| # on a misconfigured or unreachable Supabase, and freezing that for | |
| # five minutes would turn a blip into a commons that reads empty long | |
| # after it recovered — the same "availability must mean reachable" | |
| # rule this repo applies to model tiers. | |
| if rows: | |
| _COMMONS_CACHE["v"] = (_now, out) | |
| return out | |
| def atlas() -> Response: | |
| """The Field Atlas — a PUBLIC, de-identified read of the cross-user | |
| substitution-effect aggregate (Pillar 2: the living commons). Every | |
| row is a substitution TYPE (e.g. 'W>L'), pooled across labs, k- | |
| anonymized (kept only when ≥ MIN_USERS distinct users measured it) and | |
| z-scored per library before pooling — so no raw value, position, or | |
| user identity survives. Safe to expose openly: the commons being | |
| visible IS the product. Empty until the first post-effective-date | |
| rebuild accrues enough data (that's the honest cold-start, shown as | |
| such in the UI).""" | |
| from dee.core import aggregate as _agg | |
| summary = _commons_summary() | |
| return _public_cors(jsonify({ | |
| "ok": True, | |
| "substitutions": summary["rows"], | |
| "count": summary["count"], | |
| "total_observations": summary["total_observations"], | |
| "min_users": _agg.MIN_USERS, | |
| "effective_date": _agg.EFFECTIVE_DATE.isoformat(), | |
| # The honest breakdown. `from_published_studies` is prior | |
| # knowledge the field already had; `from_platform_labs` is the | |
| # only number that measures THIS commons growing. | |
| "provenance": summary["provenance"], | |
| # Real week-ish deltas from atlas_snapshots, split by source; null | |
| # (never 0) until enough history exists to diff against. | |
| "growth": summary["growth"], | |
| })) | |
| def models() -> Response: | |
| """Which scoring models this deployment can actually run. | |
| The picker is built from THIS, not from a hardcoded list, so the UI can | |
| never offer a model the server would refuse. `available` is the whole | |
| point: 650M needs a GPU, the free CPU tier has none, and the honest | |
| answer is "not here" rather than quietly serving 35M under its name | |
| (see dee/core/scoring.effective_model). | |
| """ | |
| gpu = _scoring.gpu_available() | |
| out = [] | |
| # 'large' (3B) is deliberately NOT offered. It needs an A10G-class GPU | |
| # for a ~12 GB fp32 load where 650M fits a T4 at ~2.6 GB, and cold start | |
| # — not inference — dominates the bill. ESM-2's zero-shot variant-effect | |
| # performance is also not monotonic in size; 650M is the usual sweet | |
| # spot. Offering a model that costs several times more for no MEASURED | |
| # gain is exactly the claim this codebase refuses to make. It stays in | |
| # MODEL_PRESETS so dee/core/benchmark.py can settle the question; put it | |
| # back here when a benchmark says it earns its cost. | |
| for key in ("small", "medium"): | |
| needs_gpu = key in ("medium", "large") | |
| # "available" must mean the run will WORK, not that a backend | |
| # exists somewhere. Modal being configured is necessary but not | |
| # sufficient: nothing routes a scoring call to it yet, so offering | |
| # 650M here would hand the user a picker entry that errors. That is | |
| # the same dishonesty as faking the number, inverted — promising a | |
| # capability rather than a result. | |
| available = _scoring.runnable(key) | |
| note = _scoring.MODEL_NOTES[key] | |
| # Key the note off `available` — the SAME predicate the flag uses. | |
| # It was keyed off gpu_available() instead, and the two diverged the | |
| # moment remote GPU scoring started routing: runnable() is | |
| # `gpu_available() OR modal_client.is_configured()`, so on the Space | |
| # (no local CUDA, MODAL_SUBMIT_URL set) Prometheus went out as | |
| # available=true carrying the note "Not available on this deployment | |
| # yet". A picker that contradicts itself in the same row is worse | |
| # than either answer alone — and it is the same two-sources-of-truth | |
| # bug as the DNA tool gate, which is why both now read one function. | |
| if needs_gpu and not available: | |
| note = ("Not available on this workbench yet. " | |
| f"{_scoring.MODEL_LABELS['small']} is what runs here.") | |
| out.append({ | |
| "id": key, | |
| "label": _scoring.MODEL_LABELS[key], | |
| "note": note, | |
| "available": available, | |
| # Must agree with `available`: "not-routed" alongside | |
| # available:true said two opposite things about the same model. | |
| "where": ("local" if not needs_gpu | |
| else "local-gpu" if gpu | |
| else "remote-gpu" if available | |
| else "unavailable"), | |
| }) | |
| # DNA tiers (Evo 2, via Modal) — a SEPARATE id-space from the protein | |
| # tiers above ("achilles"/"prometheus", not "small"/"medium"), since | |
| # neither picker key means anything for the other modality. Same | |
| # honesty contract: `available` reflects dna_scoring.runnable(), not | |
| # "a backend exists somewhere". There is no local/free tier here — | |
| # unlike protein's 35M, every DNA call is a metered GPU call; see | |
| # dee/core/dna_scoring.py's module docstring. | |
| dna_out = [] | |
| for tier in ("achilles", "prometheus"): | |
| dna_out.append({ | |
| "id": tier, | |
| "label": _dna_scoring.TIER_LABELS[tier], | |
| "note": _dna_scoring.TIER_NOTES[tier], | |
| "available": _dna_scoring.runnable(tier), | |
| # Two different numbers, deliberately, because conflating them | |
| # is what let a request 122x larger than anything that can run | |
| # get accepted. `context` is the CHECKPOINT's architectural | |
| # window — a fact about Evo 2. `max_reference_nt` is what this | |
| # deployment will actually accept, which is far smaller and is | |
| # the only one a caller can plan against. Reporting `context` | |
| # alone advertised a capacity no request could ever use. | |
| "context": _dna_scoring.TIER_CONTEXT[tier], | |
| "max_reference_nt": _dna_scoring.MAX_REFERENCE_NT, | |
| "max_total_score_bases": _dna_scoring.MAX_TOTAL_SCORE_TOKENS, | |
| "generation": tier in _dna_scoring.GENERATION_TIERS, | |
| }) | |
| # Never cached. This reports what the deployment can do RIGHT NOW, and | |
| # the two pickers (the bench form and the Turing composer) fetch it at | |
| # different moments — one on DOMContentLoaded, one when the rail mounts. | |
| # With no cache directive the browser cached it heuristically, so the | |
| # two disagreed about whether 650M existed: one showed it, one did not, | |
| # from the same deployment. A capability answer that can go stale is | |
| # worse than no answer. | |
| resp = jsonify({"models": out, "default": "small", | |
| "dna_models": dna_out, "dna_default": "achilles"}) | |
| resp.headers["Cache-Control"] = "no-store, max-age=0" | |
| return resp | |
| # ── DNA-level scoring / generation (Evo 2) ─────────────────────────── | |
| # Until now the ONLY route to Evo 2 was the agent deciding to call | |
| # score_or_generate_dna mid-conversation. The engine computed dna_models | |
| # for /api/models and no frontend read it, so a capability that runs on a | |
| # 7B genome model was, in practice, unreachable by clicking. These are the | |
| # two endpoints the DNA view calls. | |
| # | |
| # Both are deliberately thin: dee/core/dna_scoring.py already owns tier | |
| # rules, bounds and the "not configured is not the same as broken" | |
| # distinction, and duplicating any of that here would give the product two | |
| # answers to the same question. | |
| def _dna_signin_gate(where: str): | |
| """Same contract as CRISPR/primers: no anonymous use. Distinct from | |
| the trial timer — the 403 carries kind=signin_required so auth.js | |
| routes to /signin rather than showing the trial modal.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({ | |
| "error": ("DNA scoring requires a free account. Sign in or " | |
| "create one to keep going."), | |
| "kind": "signin_required", | |
| "signup_url": f"https://turingdna.com/signin/?from={where}", | |
| }), 403 | |
| return None | |
| def dna_score() -> Response: | |
| gate = _dna_signin_gate("dna") | |
| if gate is not None: | |
| return gate | |
| body = request.get_json(force=True, silent=True) or {} | |
| reference = "".join(str(body.get("reference") or "").split()).upper() | |
| raw_variants = body.get("variants") or [] | |
| tier = str(body.get("tier") or "achilles").strip().lower() | |
| if not reference: | |
| return jsonify({"error": "missing 'reference'"}), 400 | |
| if not isinstance(raw_variants, list) or not raw_variants: | |
| return jsonify({"error": "missing 'variants'"}), 400 | |
| variants = [str(v).strip().upper() for v in raw_variants if str(v).strip()] | |
| if not variants: | |
| return jsonify({"error": "missing 'variants'"}), 400 | |
| try: | |
| result = _dna_scoring.score_dna_variants(reference, variants, tier=tier) | |
| except _dna_scoring.DnaModelUnavailable as exc: | |
| # Customer-safe by construction — dna_scoring keeps env-var names | |
| # and repo paths in the log, not in this message. | |
| return jsonify({"error": str(exc), "kind": "dna_unavailable"}), 503 | |
| except ValueError as exc: | |
| return jsonify({"error": str(exc)}), 400 | |
| except Exception: # noqa: BLE001 | |
| app.logger.exception("dna_score failed") | |
| return jsonify({ | |
| "error": ("The DNA model didn't return a result for that " | |
| "request. Nothing was scored — try again, and if it " | |
| "keeps failing the backend is having a moment."), | |
| "kind": "dna_error", | |
| }), 502 | |
| return jsonify(result) | |
| def dna_generate() -> Response: | |
| gate = _dna_signin_gate("dna") | |
| if gate is not None: | |
| return gate | |
| body = request.get_json(force=True, silent=True) or {} | |
| prompt = "".join(str(body.get("prompt") or "").split()).upper() | |
| tier = str(body.get("tier") or "prometheus").strip().lower() | |
| if not prompt: | |
| return jsonify({"error": "missing 'prompt'"}), 400 | |
| try: | |
| n_tokens = int(body.get("n_tokens") or 200) | |
| temperature = float(body.get("temperature") or 1.0) | |
| top_k = int(body.get("top_k") or 4) | |
| except (TypeError, ValueError): | |
| return jsonify({"error": "n_tokens/temperature/top_k must be numbers"}), 400 | |
| try: | |
| result = _dna_scoring.generate_dna_sequence( | |
| prompt, n_tokens=n_tokens, tier=tier, | |
| temperature=temperature, top_k=top_k) | |
| except _dna_scoring.DnaModelUnavailable as exc: | |
| # Also the path for "generation is Prometheus-only" — enforced in | |
| # dna_scoring AND again in modal/evo2_scoring, so a caller that | |
| # skips this route still gets refused. | |
| return jsonify({"error": str(exc), "kind": "dna_unavailable"}), 503 | |
| except ValueError as exc: | |
| return jsonify({"error": str(exc)}), 400 | |
| except Exception: # noqa: BLE001 | |
| app.logger.exception("dna_generate failed") | |
| return jsonify({ | |
| "error": ("The DNA model didn't return a sequence for that " | |
| "prompt. Nothing was generated — try again."), | |
| "kind": "dna_error", | |
| }), 502 | |
| return jsonify(result) | |
| # ── Therapeutic compiler ───────────────────────────────────────────── | |
| # Lowers a pathogenic variant to an editing strategy, or refuses with a | |
| # diagnostic. See dee/core/compiler.py for the scope this operates in — | |
| # somatic design and assessment only, never a clinical decision. | |
| def _score_variant_consequence(wt: str, patient: str, window: str, offset: int): | |
| """Ask Evo 2 what the PATIENT allele does, in its real sequence context. | |
| The variant scored is the pathogenic one against the wild-type window | |
| — `<wt><1-based pos><patient>` — so a negative delta log-likelihood | |
| means the model finds the patient's sequence less likely than | |
| wild-type. That is supporting evidence about the lesion, not proof: | |
| zero-shot, no training on this locus, no validated relationship to | |
| clinical consequence. | |
| Returns None when there is nothing to score (no window, or a lesion | |
| with no single-base correction). Never raises — a scoring failure must | |
| degrade the record's completeness, not the compile. | |
| """ | |
| if not window or offset < 0 or offset >= len(window): | |
| return None | |
| if len(wt) != 1 or len(patient) != 1: | |
| return None # indels are not point substitutions | |
| if window[offset].upper() != wt.upper(): | |
| return None # the verify pass will refuse this anyway | |
| label = f"{wt.upper()}{offset + 1}{patient.upper()}" | |
| try: | |
| res = _dna_scoring.score_dna_variants(window, [label], tier="achilles") | |
| except Exception as exc: # noqa: BLE001 | |
| app.logger.warning("consequence scoring failed: %s", exc) | |
| return {"ok": False, "label": label, | |
| "error": "the DNA model did not return a score"} | |
| scores = (res or {}).get("scores") or [] | |
| hit = next((s for s in scores if s.get("label") == label), None) | |
| if hit is None: | |
| skipped = (res or {}).get("skipped") or [] | |
| return {"ok": False, "label": label, | |
| "error": (f"the model returned no score for {label}" | |
| + (f" (skipped: {skipped})" if skipped else ""))} | |
| return {"ok": True, "label": label, "delta_ll": hit.get("delta_ll"), | |
| "reference_ll": (res or {}).get("reference_ll"), | |
| "checkpoint": (res or {}).get("checkpoint"), | |
| "tier": (res or {}).get("tier")} | |
| def _score_labels(window: str, labels): | |
| """Score a batch of forward-strand variant labels in ONE model call. | |
| Batched on purpose: a 7B GPU round trip per bystander would make the | |
| blast-radius view unusable. Returns a label->delta_ll map containing | |
| only what actually came back — a missing label stays missing, and the | |
| caller leaves it as None rather than defaulting it to neutral. | |
| """ | |
| if not window or not labels: | |
| return {} | |
| try: | |
| res = _dna_scoring.score_dna_variants(window, list(labels), | |
| tier="achilles") | |
| except Exception as exc: # noqa: BLE001 | |
| app.logger.warning("bystander scoring failed: %s", exc) | |
| return {} | |
| out = {} | |
| for row in (res or {}).get("scores") or []: | |
| lab, d = row.get("label"), row.get("delta_ll") | |
| if lab and isinstance(d, (int, float)): | |
| out[lab] = d | |
| return out | |
| def compiler_compile() -> Response: | |
| gate = _dna_signin_gate("compiler") | |
| if gate is not None: | |
| return gate | |
| body = request.get_json(force=True, silent=True) or {} | |
| # A notation is the normal way to name a variant. Resolving it here | |
| # means the caller never hand-types alleles or coordinates — and the | |
| # forward-strand alleles come back RECONCILED against the reference, | |
| # not taken on trust from the notation's strand. | |
| resolved = None | |
| notation = str(body.get("variant") or "").strip() | |
| if notation: | |
| try: | |
| rv = _variant_resolve.resolve(notation) | |
| except _variant_resolve.ResolveError as exc: | |
| return jsonify({"error": str(exc), "kind": "unresolved"}), 422 | |
| except Exception: # noqa: BLE001 | |
| app.logger.exception("variant resolution failed") | |
| return jsonify({ | |
| "error": ("Could not reach the variant databases just now. " | |
| "Nothing was resolved — try again, or supply the " | |
| "alleles and reference window directly."), | |
| "kind": "resolver_unavailable"}), 503 | |
| resolved = _variant_resolve.resolved_to_dict(rv) | |
| body = dict(body) | |
| body["wt_allele"] = rv.wt_base | |
| body["patient_allele"] = rv.patient_base | |
| body["window"] = rv.window | |
| body["offset"] = rv.offset | |
| wt = str(body.get("wt_allele") or "") | |
| patient = str(body.get("patient_allele") or "") | |
| window = "".join(str(body.get("window") or "").split()).upper() | |
| try: | |
| offset = int(body.get("offset", -1)) | |
| except (TypeError, ValueError): | |
| return jsonify({"error": "offset must be an integer"}), 400 | |
| if not wt and not patient: | |
| return jsonify({"error": "missing 'wt_allele' and 'patient_allele'"}), 400 | |
| # Consequence scoring is OPT-IN. The compile itself is pure logic and | |
| # returns in milliseconds; an Evo 2 call is a rented 7B GPU and ~80s | |
| # cold, so running it on every compile would make the fast, honest | |
| # part of the tool feel broken. Off by default means the pass reports | |
| # `unavailable` — which is true — rather than quietly costing money. | |
| consequence = None | |
| if body.get("score_consequence"): | |
| consequence = _score_variant_consequence(wt, patient, window, offset) | |
| # Actually design. Cheap, deterministic, no network. | |
| strategies, pegrnas, enum_diags = None, None, None | |
| call = _compiler.classify_lesion(wt, patient) | |
| if call.correction is not None and window and 0 <= offset < len(window): | |
| try: | |
| strategies, enum_diags = _compiler.plan_base_edit_strategies( | |
| window, offset, call.correction, | |
| editor_id=str(body.get("editor") or "")) | |
| except Exception: # noqa: BLE001 | |
| app.logger.exception("guide enumeration failed") | |
| strategies, enum_diags = None, None | |
| elif call.route == "prime_editing" and window and offset >= 0: | |
| # Transversions and small indels land here — the majority of | |
| # pathogenic variation, and everything that used to stop at | |
| # `prime_editing_unavailable`. | |
| try: | |
| pegrnas, enum_diags = _prime_editor.design_pegrnas( | |
| window, offset, wt, patient) | |
| except Exception: # noqa: BLE001 | |
| app.logger.exception("pegRNA design failed") | |
| pegrnas, enum_diags = None, None | |
| # The blast radius: what ELSE this guide changes, scored in context. | |
| # Standard practice is to assume a silent or intronic bystander is | |
| # benign. Nothing checks that today; this does. | |
| if strategies and body.get("score_consequence"): | |
| try: | |
| labels = _compiler.label_bystanders(window, strategies) | |
| if labels: | |
| _compiler.attach_bystander_scores( | |
| strategies, _score_labels(window, labels)) | |
| except ValueError: | |
| # A label that disagrees with the genome means the mapping is | |
| # broken; leave every bystander unscored rather than score a | |
| # position we cannot vouch for. | |
| app.logger.exception("bystander labelling refused") | |
| try: | |
| report = _compiler.compile_report( | |
| wt, patient, window=window, offset=offset, | |
| germline=bool(body.get("germline")), | |
| strategies=strategies, pegrnas=pegrnas, | |
| enumerate_diags=enum_diags, | |
| consequence=consequence, | |
| can_check_specificity=False, | |
| ) | |
| except _compiler.GermlineRefused as exc: | |
| # 422, not 400: the request is well-formed and understood, and is | |
| # being refused on scope. Distinct code so nothing retries it. | |
| return jsonify({"error": str(exc), "kind": "out_of_scope"}), 422 | |
| out = _compiler.report_to_dict(report) | |
| out["resolved"] = resolved | |
| # The deliverable: a design and its complete justification, in a form | |
| # that diffs and pastes. Deterministic — same inputs, same bytes. | |
| out["record"] = _compiler.design_record( | |
| report, variant=notation, resolved=resolved, | |
| provenance={"engine": "TuringDNA compiler", | |
| "record": _compiler.RECORD_VERSION}) | |
| # The certificate. The record above is the human document; this is its | |
| # identity. Content-addressed, so "certificate 8f2a…" names exactly one | |
| # compile and nobody can rename a result into agreeing with them — and | |
| # `not_established` is hashed with everything else, so the caveats | |
| # cannot be quietly dropped while the id survives. | |
| try: | |
| cert = _certificate.build( | |
| kind="therapeutic_compile", | |
| inputs={"variant": notation, "wt_allele": wt, | |
| "patient_allele": patient, | |
| "window_sha256": _hash_sequence(window) if window else "", | |
| "window_length": len(window), "offset": offset, | |
| "scored_consequence": bool(body.get("score_consequence"))}, | |
| outputs={"compiled": bool(report.compiled), | |
| "route": (report.lesion.route if report.lesion else ""), | |
| "n_strategies": len(report.strategies or []), | |
| "n_pegrnas": len(report.pegrnas or [])}, | |
| passes=[{"name": p.name, "title": p.title, "status": p.status, | |
| "detail": p.detail} for p in report.passes], | |
| not_established=report.incomplete_because, | |
| versions={"record": _compiler.RECORD_VERSION, | |
| "cert": _certificate.CERT_VERSION}, | |
| scope=report.scope) | |
| out["certificate"] = cert | |
| out["certificate_text"] = _certificate.render_text(cert) | |
| except _certificate.CanonicalError as exc: | |
| # Refusing beats minting an id over a value we cannot reproduce. | |
| app.logger.warning("certificate refused: %s", exc) | |
| out["certificate"] = None | |
| out["certificate_error"] = str(exc) | |
| return jsonify(out) | |
| def certificate_verify() -> Response: | |
| """Recompute a certificate's id from its content. PUBLIC and unauthenticated | |
| on purpose: a document that only its issuer can verify is not evidence. | |
| Nothing is stored — the certificate is supplied by the caller.""" | |
| body = request.get_json(force=True, silent=True) or {} | |
| cert = body.get("certificate") | |
| if not isinstance(cert, dict): | |
| return jsonify({"ok": False, | |
| "error": "Send {certificate: {...}}."}), 400 | |
| ok, why = _certificate.verify(cert) | |
| return jsonify({"ok": True, "valid": ok, "explanation": why, | |
| "id": cert.get("id")}) | |
| def compiler_validation() -> Response: | |
| """Landmark designs the compiler is checked against. Public: the whole | |
| value is that anyone can look at it, including the gaps.""" | |
| report = _compiler_validation.run_validation() | |
| resp = jsonify(_compiler_validation.report_to_dict(report)) | |
| resp.headers["Cache-Control"] = "no-store, max-age=0" | |
| return resp | |
| def benchmarks() -> Response: | |
| """The receipts — how well the engine's zero-shot ranking predicts | |
| measured DMS fitness (Spearman ρ + top-decile precision), per published | |
| dataset. PUBLIC read. Served from dee/data/benchmarks.json, which is | |
| produced ONLY by scripts/run_benchmarks.py against real data with the | |
| live model — never hand-entered. Ships empty (honest 'pending' state) | |
| until a real run populates it; no fabricated numbers ever reach here.""" | |
| import json as _json | |
| path = Path(__file__).resolve().parent / "data" / "benchmarks.json" | |
| try: | |
| data = _json.loads(path.read_text(encoding="utf-8")) | |
| except Exception: # noqa: BLE001 | |
| data = {"generated_at": None, "model": None, | |
| "summary": {"n_datasets": 0, "n_variants": 0, | |
| "median_spearman": None, "median_top_decile_precision": None}, | |
| "datasets": []} | |
| data["ok"] = True | |
| return _public_cors(jsonify(data)) | |
| # Collaboration is an additive API; private tool routes retain their | |
| # owner-only authorization and do not depend on the workspace migration. | |
| from .core.project_workspaces import register as register_workspaces | |
| register_workspaces(app, _auth) | |
| # Referrals ride the same rail (0058) and the same JWT-only transport. | |
| from .core.referrals import register as register_referrals | |
| register_referrals(app, _auth) | |
| # ── Constructs: the object every tool operates on ──────────────────── | |
| # The inversion. Until now a tool was a destination you pasted a sequence | |
| # into, and each of the eight tool views had its own empty input. Nothing | |
| # carried across them, so "which project does this guide set belong to?" | |
| # could only ever be reconstructed afterwards by matching name strings. | |
| # | |
| # A construct is the noun. The tools become verbs applied to it. Once the | |
| # client holds a selected construct and sends its id with every tool call, | |
| # attribution stops being a reconstruction and becomes a record. | |
| # | |
| # SIGNED OUT these endpoints all 401 by design, and the client keeps its | |
| # selection purely in local storage. Public tool access was a deliberate | |
| # decision and must not regress: nothing here may become a precondition | |
| # for running a tool. | |
| def _construct_out(row): | |
| """Trim a row for the client. The full DNA/protein is only sent by the | |
| single-construct GET — the switcher must not ship megabytes.""" | |
| if not row: | |
| return None | |
| return { | |
| "id": row.get("id"), "name": row.get("name"), | |
| "phase": row.get("phase") or "Design", | |
| "sequence_hash": row.get("sequence_hash"), | |
| "wt_identifier": row.get("wt_identifier"), | |
| "created_at": row.get("created_at"), | |
| "updated_at": row.get("updated_at"), | |
| "n_plasmids": len(row.get("plasmid_ids") or []), | |
| "n_crispr": len(row.get("crispr_ids") or []), | |
| "n_primers": len(row.get("primer_ids") or []), | |
| "library_id": row.get("library_id"), | |
| } | |
| def constructs_list() -> Response: | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| # Not an error. A signed-out visitor has no saved projects and the | |
| # UI shows its local-only selection instead. | |
| return jsonify({"ok": True, "gated": True, "constructs": []}) | |
| rows = _auth.list_constructs(auth.user_id) | |
| return jsonify({"ok": True, "gated": False, | |
| "constructs": [_construct_out(r) for r in rows]}) | |
| def constructs_create() -> Response: | |
| """Create, or return the one already grouping this sequence. | |
| Pasting the same sequence twice means "the thing I am already working | |
| on", not a second project. Silently creating a duplicate is how a | |
| workspace turns into a list the user has to tidy, so the hash decides. | |
| """ | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"error": "Sign in to keep projects.", | |
| "kind": "signin_required"}), 401 | |
| body = request.get_json(force=True, silent=True) or {} | |
| dna = "".join(str(body.get("sequence_dna") or "").split()).upper() | |
| protein = "".join(str(body.get("wt_protein") or "").split()).upper() | |
| basis = dna or protein | |
| if not basis: | |
| return jsonify({"error": "A project needs a sequence."}), 400 | |
| seq_hash = _hash_sequence(basis) | |
| existing = _auth.find_construct_by_hash(auth.user_id, seq_hash) | |
| if existing: | |
| return jsonify({"ok": True, "reused": True, | |
| "construct": _construct_out(existing)}) | |
| res = _auth.save_construct( | |
| auth.user_id, | |
| name=str(body.get("name") or "").strip() or "Untitled construct", | |
| sequence_hash=seq_hash, | |
| wt_identifier=str(body.get("wt_identifier") or ""), | |
| wt_protein=protein, sequence_dna=dna, | |
| phase=str(body.get("phase") or "Design")) | |
| if not res.get("ok"): | |
| return jsonify({"error": res.get("error") or "Couldn't create."}), 400 | |
| return jsonify({"ok": True, "reused": False, | |
| "construct": _construct_out(res.get("construct"))}) | |
| def constructs_get(construct_id: str) -> Response: | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"error": "Sign in.", "kind": "signin_required"}), 401 | |
| row = _auth.get_construct(auth.user_id, construct_id) | |
| if not row: | |
| return jsonify({"error": "Not found."}), 404 | |
| out = _construct_out(row) | |
| # Only here, and only for the one the user actually selected. | |
| out["sequence_dna"] = row.get("sequence_dna") or "" | |
| out["wt_protein"] = row.get("wt_protein") or "" | |
| # What the project last had on the bench (project.js): which | |
| # structure, library and conversation to bring back when it is | |
| # reopened, on this machine or another. | |
| out["workspace"] = _auth.clean_workspace((row.get("meta") or {}).get("workspace")) | |
| return jsonify({"ok": True, "construct": out}) | |
| def constructs_workspace(construct_id: str) -> Response: | |
| """Record what a project has on the bench. | |
| Pointers only — an AlphaFold accession, a library id, a run id — each | |
| validated to its own shape, so this can never become a place to park | |
| a sequence or arbitrary JSON on the construct row.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"error": "Sign in.", "kind": "signin_required"}), 401 | |
| body = request.get_json(force=True, silent=True) or {} | |
| res = _auth.save_workspace(auth.user_id, construct_id, body.get("workspace")) | |
| if not res.get("ok"): | |
| return jsonify({"error": res.get("error") or "Couldn't save."}), 400 | |
| return jsonify({"ok": True, "workspace": res.get("workspace")}) | |
| def constructs_patch(construct_id: str) -> Response: | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"error": "Sign in.", "kind": "signin_required"}), 401 | |
| body = request.get_json(force=True, silent=True) or {} | |
| res = _auth.update_construct( | |
| auth.user_id, construct_id, | |
| **{k: body[k] for k in ("name", "phase") if k in body}) | |
| if not res.get("ok"): | |
| return jsonify({"error": res.get("error") or "Couldn't update."}), 400 | |
| return jsonify({"ok": True, "construct": _construct_out(res.get("construct"))}) | |
| def constructs_delete(construct_id: str) -> Response: | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"error": "Sign in.", "kind": "signin_required"}), 401 | |
| res = _auth.delete_construct(auth.user_id, construct_id) | |
| if not res.get("ok"): | |
| return jsonify({"error": res.get("error") or "Couldn't delete."}), 400 | |
| return jsonify({"ok": True}) | |
| def constructs_lineage(construct_id: str) -> Response: | |
| """The recorded graph for one project: what came from what. | |
| Nodes are derived from the edges, never stored separately, so the view | |
| cannot render a relationship no edge records. | |
| """ | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"error": "Sign in.", "kind": "signin_required"}), 401 | |
| edges = _auth.list_lineage(auth.user_id, construct_id) | |
| graph = _auth.lineage_graph(edges) | |
| # State the composition of what is being drawn. A caller that cannot | |
| # tell observed edges from reconstructed ones will eventually present | |
| # a guess as a fact. | |
| by_origin = {} | |
| for e in graph["edges"]: | |
| by_origin[e["origin"]] = by_origin.get(e["origin"], 0) + 1 | |
| graph["by_origin"] = by_origin | |
| return jsonify({"ok": True, "construct_id": construct_id, **graph}) | |
| def mission() -> Response: | |
| """Command-center rollup for the Mission Control home — ONE call that | |
| assembles the whole front-door state so the home never fans out into a | |
| dozen requests. Everything here is REAL, cheaply-available data: | |
| • constructs — the user's saved work, grouped best-effort by name, | |
| each enriched with its real loop phase, top-variant fitness, variant | |
| count, how many variants they've measured, and a computed | |
| next-best-action (round 2 ready / build / open). | |
| • loop — Design/Build/Edit counts + the honest Learn signal (results | |
| logged, loops closed). No fabricated improvement %. | |
| • trust — the PUBLIC receipts (benchmark ρ) + commons size, so even a | |
| signed-out visitor (an investor) sees the engine is validated. | |
| The trust block is public; constructs/loop require sign-in (gated flag | |
| so the UI can prompt without erroring). Running jobs are tracked | |
| client-side (per tab), not here.""" | |
| import json as _json | |
| # ── trust: public receipts + commons (works signed-out) ────────── | |
| bpath = Path(__file__).resolve().parent / "data" / "benchmarks.json" | |
| try: | |
| bench = _json.loads(bpath.read_text(encoding="utf-8")).get("summary", {}) | |
| except Exception: # noqa: BLE001 | |
| bench = {} | |
| # Same helper /api/atlas uses — was previously its own inline | |
| # max(n_users) over ALL rows regardless of source, which is the exact | |
| # conflation migration 0018 exists to prevent: with the commons | |
| # currently 100% DMS-seeded, that used to report a DMS row's | |
| # independent-study count as "N labs contributing" on the home page. | |
| summary = _commons_summary() | |
| prov = summary["provenance"] | |
| trust = { | |
| "spearman": bench.get("median_spearman"), | |
| "n_datasets": bench.get("n_datasets", 0), | |
| "n_variants": bench.get("n_variants", 0), | |
| "commons_substitutions": summary["count"], | |
| "commons_observations": summary["total_observations"], | |
| "commons_studies": prov["from_published_studies"]["studies"], | |
| "commons_labs": prov["from_platform_labs"]["labs"], | |
| "commons_growth": summary["growth"], | |
| } | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": True, "gated": True, "constructs": [], | |
| "loop": {"design": 0, "build": 0, "edit": 0, | |
| "learn_logged": 0, "loops_closed": 0}, | |
| "trust": trust}) | |
| libs = _auth.list_libraries(auth.user_id) | |
| plas = _auth.list_plasmids(auth.user_id) | |
| cris = _auth.list_crispr_designs(auth.user_id) | |
| prim = _auth.list_primer_analyses(auth.user_id) | |
| projects = _auth.list_constructs(auth.user_id) | |
| tested = _auth.de_outcome_counts_by_library(auth.user_id) # {library_id: n} | |
| def _norm(s): | |
| return re.sub(r"[^a-z0-9]+", "", str(s or "").lower())[:40] | |
| # Flatten every artifact into a common shape, carrying the library-only | |
| # enrichment (fitness / variant count / measured count) where present. | |
| raw = [] | |
| project_names = {} | |
| artifact_projects = {} | |
| for c in projects: | |
| cid = c.get("id") | |
| if not cid: | |
| continue | |
| project_names.setdefault(_norm(c.get("name")), []).append(cid) | |
| # Every library filed under the project (attach_library keeps the | |
| # history in meta; library_id is only the newest). | |
| lib_ids = [c.get("library_id")] + list(((c.get("meta") or {}).get("library_ids")) or []) | |
| for kind, ids in (("library", lib_ids), | |
| ("plasmid", c.get("plasmid_ids") or []), | |
| ("crispr", c.get("crispr_ids") or []), | |
| ("primer", c.get("primer_ids") or [])): | |
| for aid in ids: | |
| if aid: | |
| artifact_projects[(kind, aid)] = cid | |
| # Explicit allowlist: the rollup must never serialize sequences or | |
| # arbitrary project metadata, even if a storage helper returns them. | |
| raw.append({"kind": "construct", "id": cid, | |
| "name": c.get("name") or "Construct", | |
| "at": c.get("updated_at") or c.get("created_at"), | |
| "created_at": c.get("created_at"), | |
| "phase": {"Design": 0, "Build": 1, "Edit": 2, "Learn": 3}.get(c.get("phase"), 0)}) | |
| for l in libs: | |
| lid = l.get("id") | |
| raw.append({"kind": "library", "id": lid, "name": l.get("name") or "Library", | |
| "at": l.get("created_at"), "phase": 0, | |
| "top_fitness": l.get("top_fitness"), "n_variants": l.get("n_variants"), | |
| "n_tested": int(tested.get(lid, 0)), | |
| # For the card's thumbnail: the same protein the library | |
| # was built on, so a construct can show a real fold | |
| # instead of a glyph — resolved client-side, lazily. | |
| "gene": _gene_token(l.get("wt_identifier")), | |
| "organism": (l.get("host_organism") or "")}) | |
| for p in plas: | |
| raw.append({"kind": "plasmid", "id": p.get("id"), "name": p.get("name") or "Plasmid", | |
| "at": p.get("created_at"), "phase": 1}) | |
| for d in cris: | |
| raw.append({"kind": "crispr", "id": d.get("id"), | |
| "name": d.get("label") or d.get("gene_symbol") or "CRISPR design", | |
| "at": d.get("created_at"), "phase": 2, | |
| "gene": _gene_token(d.get("gene_symbol")), | |
| "organism": (d.get("organism") or "")}) | |
| for a in prim: | |
| raw.append({"kind": "primer", "id": a.get("id"), "name": a.get("label") or "Primer set", | |
| "at": a.get("created_at"), "phase": 1}) | |
| # Group by normalized name into constructs; the most-recent member is the | |
| # card's "primary" (drives click + kind); phase = furthest step reached, | |
| # bumped to Learn(3) once any grouped library has measured results. | |
| groups = {} | |
| for r in raw: | |
| matches = project_names.get(_norm(r["name"]), []) | |
| cid = (r["id"] if r["kind"] == "construct" else | |
| artifact_projects.get((r["kind"], r["id"])) or | |
| (matches[0] if len(matches) == 1 else None)) | |
| key = ("construct:" + str(cid)) if cid else (_norm(r["name"]) or (r["kind"] + str(r["id"]))) | |
| g = groups.get(key) | |
| if not g: | |
| # The key the client files this project's workspace under | |
| # (project.js). It must not change as work lands, so it is | |
| # never the newest artifact's id while a steadier one exists. | |
| client_key = ("construct:" + str(cid) if cid else | |
| ("name:" + _norm(r["name"])) if _norm(r["name"]) else | |
| (r["kind"] + ":" + str(r["id"]))) | |
| # `created_at` is when this project FIRST appeared (min | |
| # across grouped members); `updated_at` is when the newest | |
| # piece of work landed on it (max). Legacy `at` = updated | |
| # (existing behaviour preserved for older clients). | |
| # The construct id the card belongs to, or None for a group | |
| # that only name-matched. The client needs a STABLE id to join | |
| # collaboration state onto: `primary.id` is whichever artifact | |
| # happens to be newest, so it changes as work lands. | |
| g = {"construct_id": cid, "key": client_key, "members": {}, | |
| "name": r["name"], "at": r["at"], "phase": r["phase"], "primary": r, | |
| "created_at": r.get("created_at") or r["at"], "updated_at": r["at"], | |
| "top_fitness": None, "n_variants": None, "n_tested": 0, | |
| "gene": "", "organism": ""} | |
| groups[key] = g | |
| if cid and not g.get("construct_id"): | |
| g["construct_id"] = cid | |
| # Every artifact in the project, by kind, so a reopened project | |
| # can put each tool's own work back — not only the one artifact | |
| # the card happens to lead with. | |
| if r["kind"] != "construct" and r.get("id"): | |
| g["members"].setdefault(r["kind"], []).append((str(r.get("at") or ""), str(r["id"]))) | |
| g["phase"] = max(g["phase"], r["phase"]) | |
| # Carry a protein identity for the thumbnail. A library's is the | |
| # truest (it IS the protein being engineered), so it wins; otherwise | |
| # the first member that has one. | |
| if r.get("gene") and (not g["gene"] or r["kind"] == "library"): | |
| g["gene"], g["organism"] = r["gene"], r.get("organism") or "" | |
| if r["kind"] == "library": | |
| g["top_fitness"] = r.get("top_fitness") | |
| g["n_variants"] = r.get("n_variants") | |
| g["n_tested"] = max(g["n_tested"], r.get("n_tested", 0)) | |
| if r.get("n_tested", 0) > 0: | |
| g["phase"] = 3 # measured → the loop reached Learn | |
| if r["at"]: | |
| if not g["updated_at"] or r["at"] > g["updated_at"]: | |
| g["updated_at"] = r["at"] | |
| g["at"] = r["at"] | |
| born = r.get("created_at") or r["at"] | |
| if not g["created_at"] or born < g["created_at"]: | |
| g["created_at"] = born | |
| # A saved result remains directly reopenable even if the enclosing | |
| # project was renamed/touched more recently than that result. | |
| p = g["primary"] | |
| if r["kind"] != "construct" and (p["kind"] == "construct" or str(r["at"] or "") > str(p["at"] or "")): | |
| g["primary"] = r | |
| def _next_action(g): | |
| p = g["primary"] | |
| if g.get("n_tested", 0) > 0: | |
| n = g["n_tested"] | |
| return {"type": "round2", | |
| "text": f"<b>Round 2 is ready.</b> Turing can re-rank from your " | |
| f"{n} logged result{'s' if n != 1 else ''}.", | |
| "cta": "Open round 2", "route": "design"} | |
| if p["kind"] == "library" and (g.get("n_variants") or 0) > 0: | |
| # A LIBRARY THAT HAS BEEN SITTING A WHILE IS WAITING ON A BENCH. | |
| # | |
| # "Ready to build" is right for a library designed this morning | |
| # and wrong for one designed a month ago: by then the person is | |
| # far more likely to be holding numbers than to have forgotten | |
| # to start. The product cannot see a bench, but it can see age, | |
| # and age is the honest proxy — so past the threshold the card | |
| # asks for results instead of pointing at the plasmid editor. | |
| # | |
| # This is the return path that needs no email. Whenever they | |
| # come back, for any reason, the open loop is what greets them. | |
| if _days_since(g.get("at")) >= MISSION_AWAITING_RESULTS_DAYS: | |
| return {"type": "awaiting", | |
| "text": "<b>Waiting on your results.</b> Designed " | |
| f"{_days_since(g.get('at'))} days ago and nothing " | |
| "logged yet — open it and drop in what you measured.", | |
| "cta": "Log results", "route": "library"} | |
| return {"type": "build", | |
| "text": "<b>Library's ready to build.</b> Map the construct, design " | |
| "primers, then order your top picks — and log what you measure.", | |
| "cta": "Build construct", "route": "plasmid"} | |
| return {"type": "open", | |
| "text": f"Open this {p['kind']} to keep working.", | |
| "cta": "Open", "route": p["kind"]} | |
| # OPEN LOOPS FIRST, then recency. Sorting purely by date buries the | |
| # one card that is waiting on the user under whatever they opened most | |
| # recently — and the whole reason this surface exists is to put the | |
| # unfinished thing in front of them. | |
| def _rank(g): | |
| act = _next_action(g) | |
| waiting = 1 if act.get("type") in ("awaiting", "round2") else 0 | |
| return (waiting, str(g["at"] or "")) | |
| constructs = [] | |
| for g in sorted(groups.values(), key=_rank, reverse=True): | |
| p = g["primary"] | |
| members = {k: [i for _, i in sorted(v, reverse=True)][:10] | |
| for k, v in (g.get("members") or {}).items()} | |
| constructs.append({ | |
| "kind": p["kind"], "id": p["id"], "name": g["name"], "at": g["at"], | |
| "construct_id": g.get("construct_id"), | |
| "key": g.get("key"), "members": members, | |
| # Two dates for the card: | |
| # created_at = earliest member.created_at (project's birth) | |
| # updated_at = latest member.created_at (last activity) | |
| # `at` is preserved (== updated_at) so older clients still work. | |
| "created_at": g.get("created_at"), | |
| "updated_at": g.get("updated_at"), | |
| "phase": g["phase"], | |
| "top_fitness": g.get("top_fitness"), "n_variants": g.get("n_variants"), | |
| "n_tested": g.get("n_tested", 0), | |
| # Protein identity for the card thumbnail (may be ""). The client | |
| # resolves this to a UniProt accession and draws the fold, or — | |
| # if it doesn't resolve — shows an honest "no structure" plate. | |
| "gene": g.get("gene") or "", "organism": g.get("organism") or "", | |
| "next": _next_action(g), | |
| }) | |
| total_tested = sum(tested.values()) | |
| loops_closed = sum(1 for v in tested.values() if v > 0) | |
| trust["you_contributed"] = total_tested | |
| return jsonify({ | |
| "ok": True, "gated": False, "constructs": constructs, | |
| "loop": {"design": len(libs), "build": len(plas) + len(prim), | |
| "edit": len(cris), "learn_logged": total_tested, | |
| "loops_closed": loops_closed}, | |
| "trust": trust, | |
| }) | |
| def structure_resolve() -> Response: | |
| """Resolve a gene symbol (+ organism) to a UniProt accession for a | |
| construct's thumbnail — the same lookup the CRISPR structure viewer uses | |
| (dee.core.resolve), cached so the catalog grid doesn't re-hit UniProt. | |
| Returns {ok:true, uniprot} on a confident hit, else {ok:false}: the card | |
| then draws no structure rather than a fold for the wrong protein — the | |
| same rule the structure card already follows for a run with no accession. | |
| Public data (a gene maps to a public accession), so no auth gate.""" | |
| gene = _gene_token(request.args.get("gene")) | |
| organism = (request.args.get("organism") or "").strip() | |
| if not gene: | |
| return jsonify({"ok": False, "error": "bad_gene"}), 422 | |
| if not organism or len(organism) > 64 or not re.match(r"^[A-Za-z0-9 .'-]+$", organism): | |
| return jsonify({"ok": False, "error": "bad_organism"}), 422 | |
| key = (gene.lower(), organism.lower()) | |
| if key in _CONSTRUCT_ACC_CACHE: | |
| acc = _CONSTRUCT_ACC_CACHE[key] | |
| return jsonify({"ok": bool(acc), "uniprot": acc, "cached": True}) | |
| from dee.core import resolve as _resolve | |
| try: | |
| result = _resolve.resolve_uniprot(organism, gene) | |
| except Exception: # noqa: BLE001 | |
| logger.exception("construct accession resolve failed") | |
| return jsonify({"ok": False, "error": "resolve_failed"}), 200 | |
| acc = result.get("uniprot") if result.get("ok") else None | |
| # Cache the answer — including a confirmed miss — but never a transient | |
| # UniProt outage, so a blip isn't remembered forever as "no structure". | |
| if result.get("ok") or not result.get("transient"): | |
| _CONSTRUCT_ACC_CACHE[key] = acc | |
| return jsonify({"ok": bool(acc), "uniprot": acc, | |
| "protein_name": result.get("protein_name")}) | |
| def admin_mutation_priors() -> Response: | |
| """View the stored cross-user aggregate (de-identified). Admin-token gated.""" | |
| from dee.core import aggregate as _agg | |
| if not _admin_ok(): | |
| return jsonify({"ok": False, "error": "forbidden"}), 403 | |
| rows = _auth.get_mutation_priors() | |
| return jsonify({"ok": True, "count": len(rows), "priors": rows, | |
| "effective_date": _agg.EFFECTIVE_DATE.isoformat(), | |
| "min_users": _agg.MIN_USERS}) | |
| def admin_learn_signal() -> Response: | |
| """What Turing is worst at, ranked by how often users hit it. | |
| The Learn phase was built on `log_outcome` — a scientist synthesises a | |
| design, measures it, logs the numbers. That is the honest loop and it | |
| stays, but nobody has ever closed it (platform labs = 0). This reads | |
| the signal that accrues whether or not the user does anything: every | |
| run already persists its steers, tool failures, asks and abandons, and | |
| in aggregate that has never been read. | |
| Cross-user, so it inherits the mutation-commons privacy regime — | |
| `field_report` is date-gated, k-anonymous, and emits no user text and | |
| no tool arguments. Admin-token gated on top, because the underlying | |
| scan uses service-role credentials to cross users at all. | |
| ?days=30 window (default 30, max 365) | |
| ?limit= runs to read (default 2000, max 5000) | |
| ?format=text for the pasteable version | |
| """ | |
| from dee.core import learn_signal as _ls | |
| from dee.core.aggregate import AggregationGateError as _Gate | |
| import datetime as _dt4 | |
| if not _admin_ok(): | |
| return jsonify({"ok": False, "error": "forbidden"}), 403 | |
| try: | |
| days = max(1, min(int(request.args.get("days", 30)), 365)) | |
| except ValueError: | |
| days = 30 | |
| try: | |
| limit = max(1, min(int(request.args.get("limit", 2000)), 5000)) | |
| except ValueError: | |
| limit = 2000 | |
| since = (_dt4.datetime.now(_dt4.timezone.utc) | |
| - _dt4.timedelta(days=days)).isoformat() | |
| runs = _auth.scan_agent_runs(since_iso=since, limit=limit) | |
| try: | |
| rep = _ls.field_report(runs) | |
| except _Gate as exc: | |
| return jsonify({"ok": False, "error": str(exc)}), 403 | |
| rep["window_days"] = days | |
| rep["ok"] = True | |
| if request.args.get("format") == "text": | |
| return Response(_ls.as_text(rep), mimetype="text/plain") | |
| return jsonify(rep) | |
| def admin_worked_examples() -> Response: | |
| """Tool chains real users have completed cleanly. | |
| The other half of /api/admin/learn-signal: that one reads the corpus | |
| for what went wrong, this one for what went right. Same privacy | |
| regime — tool names only, k-anonymous, date-gated. These are fed back | |
| into the agent's system prompt, so this endpoint is also how you see | |
| what it is currently being told. | |
| """ | |
| from dee.core import worked_examples as _we | |
| from dee.core.aggregate import AggregationGateError as _Gate | |
| if not _admin_ok(): | |
| return jsonify({"ok": False, "error": "forbidden"}), 403 | |
| try: | |
| limit = max(1, min(int(request.args.get("limit", 400)), 5000)) | |
| except ValueError: | |
| limit = 400 | |
| runs = _auth.scan_agent_runs(limit=limit) | |
| try: | |
| rep = _we.promote(runs) | |
| except _Gate as exc: | |
| return jsonify({"ok": False, "error": str(exc)}), 403 | |
| if request.args.get("format") == "prompt": | |
| return Response(_we.as_prompt_section(rep) or | |
| "(nothing has cleared the k-anonymity floor yet)", | |
| mimetype="text/plain") | |
| return jsonify(rep) | |
| def admin_cache_stats() -> Response: | |
| """Resolution-cache hit rate — the number that says whether repeat | |
| lookups are actually compounding.""" | |
| from dee.core import resolution_cache as _rc | |
| if not _admin_ok(): | |
| return jsonify({"ok": False, "error": "forbidden"}), 403 | |
| return jsonify({"ok": True, "resolution_cache": _rc.stats()}) | |
| def admin_run_benchmarks() -> Response: | |
| """Produce REAL validation numbers — runs the live ESM-2 model already | |
| loaded in this Space against the bundled public DMS fixtures | |
| (dee/data/dms_fixtures/manifest.json) and writes dee/data/benchmarks.json. | |
| This is the ONLY code path that may populate that file; the app never | |
| hand-enters a number. Admin-token gated — this is a heavy call (loads | |
| the model, scores thousands of variants across every bundled assay). | |
| Provenance for the numbers this writes lives in the manifest itself | |
| (citation, exact fitness column used, WT-residue cross-validation).""" | |
| import datetime as _dt3 | |
| from dee.core import benchmark as _bm | |
| from dee.core.dms_seed import parse_proteingym_csv as _parse_dms | |
| if not _admin_ok(): | |
| return jsonify({"ok": False, "error": "forbidden"}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| model = body.get("model", "small") | |
| if model not in ("small", "medium", "large"): | |
| model = "small" | |
| fixtures_dir = Path(__file__).resolve().parent / "data" / "dms_fixtures" | |
| manifest_path = fixtures_dir / "manifest.json" | |
| if not manifest_path.exists(): | |
| return jsonify({"ok": False, "error": "no fixtures bundled"}), 404 | |
| try: | |
| assays = json.loads(manifest_path.read_text(encoding="utf-8")) | |
| except Exception: # noqa: BLE001 | |
| return jsonify({"ok": False, "error": "manifest unreadable"}), 500 | |
| scorer = _scoring.get_scorer(model) | |
| results = [] | |
| failed = [] | |
| for a in assays: | |
| name = a.get("name", a.get("protein", "?")) | |
| try: | |
| csv_path = fixtures_dir / a["csv"] | |
| recs = _parse_dms(csv_path.read_text(encoding="utf-8")) | |
| if not recs: | |
| failed.append({"name": name, "error": "no parseable records in csv"}) | |
| continue | |
| labels = [lab for (lab, _v) in recs] | |
| measured = [v for (_lab, v) in recs] | |
| scores_df = _scoring.score_guarded(scorer, a["sequence"]) | |
| predicted = _bm.predict_additive(scores_df, labels) | |
| results.append(_bm.evaluate_dataset( | |
| name, a.get("protein", ""), | |
| predicted, measured, source=a.get("source", ""), | |
| )) | |
| except Exception as exc: # noqa: BLE001 — one bad assay must not kill the run | |
| logger.exception("benchmark assay failed: %s", name) | |
| failed.append({"name": name, "error": f"{type(exc).__name__}: {exc}"}) | |
| out = { | |
| "generated_at": _dt3.datetime.now(_dt3.timezone.utc).isoformat(), | |
| "model": model, | |
| "note": "Computed live on this Space against dee/data/dms_fixtures/ " | |
| "(see manifest.json for provenance/citations). Never hand-entered.", | |
| "summary": _bm.summarize(results), | |
| "datasets": [r.as_dict() for r in results], | |
| } | |
| if failed: | |
| out["failed"] = failed | |
| out_path = Path(__file__).resolve().parent / "data" / "benchmarks.json" | |
| try: | |
| out_path.write_text(json.dumps(out, indent=2), encoding="utf-8") | |
| except Exception: # noqa: BLE001 — the response below still carries the real result | |
| logger.exception("could not persist benchmarks.json to disk") | |
| out["ok"] = True | |
| return jsonify(out) | |
| def admin_seed_commons() -> Response: | |
| """Seed the Field Atlas from the SAME bundled public DMS fixtures — | |
| ESM-free (pure aggregation of measured values, dee.core.dms_seed), | |
| pushed to public.mutation_priors via this Space's own Supabase | |
| credentials. Admin-token gated AND date-gated (the same Privacy/Terms | |
| v2.0 effective-date + k-anonymity floor as user-data aggregation — | |
| each bundled assay counts as one contributor).""" | |
| from dee.core import aggregate as _agg | |
| from dee.core.dms_seed import parse_proteingym_csv as _parse_dms | |
| from dee.core.dms_seed import seed_rows as _seed_rows | |
| if not _admin_ok(): | |
| return jsonify({"ok": False, "error": "forbidden"}), 403 | |
| fixtures_dir = Path(__file__).resolve().parent / "data" / "dms_fixtures" | |
| manifest_path = fixtures_dir / "manifest.json" | |
| if not manifest_path.exists(): | |
| return jsonify({"ok": False, "error": "no fixtures bundled"}), 404 | |
| try: | |
| manifest = json.loads(manifest_path.read_text(encoding="utf-8")) | |
| except Exception: # noqa: BLE001 | |
| return jsonify({"ok": False, "error": "manifest unreadable"}), 500 | |
| assays = [] | |
| failed = [] | |
| for a in manifest: | |
| name = a.get("name", a.get("protein", "?")) | |
| try: | |
| csv_path = fixtures_dir / a["csv"] | |
| recs = _parse_dms(csv_path.read_text(encoding="utf-8")) | |
| if recs: | |
| assays.append((name, recs)) | |
| else: | |
| failed.append({"name": name, "error": "no parseable records in csv"}) | |
| except Exception as exc: # noqa: BLE001 — one bad assay must not kill the seed | |
| logger.exception("seed-commons assay failed: %s", name) | |
| failed.append({"name": name, "error": f"{type(exc).__name__}: {exc}"}) | |
| try: | |
| rows = _seed_rows(assays) # enforces the effective-date gate | |
| except _agg.AggregationGateError as exc: | |
| return jsonify({"ok": False, "error": "gated", "detail": str(exc), | |
| "effective_date": _agg.EFFECTIVE_DATE.isoformat()}), 423 | |
| # source="dms": replaces only the published-study rows, leaving any | |
| # user-contributed commons untouched (migration 0018). _seed_rows | |
| # already stamps each row source="dms". | |
| result = _auth.replace_mutation_priors(rows, source="dms") | |
| _GLOBAL_PRIOR_CACHE["data"] = None # bust so the next design sees it immediately | |
| out = {"ok": bool(result.get("ok")), "substitutions": len(rows), | |
| "contributing_assays": len(assays)} | |
| if failed: | |
| out["failed"] = failed | |
| return jsonify(out) | |
| def admin_rebuild_priors() -> Response: | |
| """Recompute the cross-user aggregate from all logged outcomes and store | |
| it. Admin-token gated AND date-gated (refuses before the v2.0 effective | |
| date). De-identified by construction; k-anonymity floor MIN_USERS.""" | |
| from dee.core import aggregate as _agg | |
| if not _admin_ok(): | |
| return jsonify({"ok": False, "error": "forbidden"}), 403 | |
| # Opt-in richer aggregate: split each substitution by its ESM ΔLL bin. | |
| # Heavy (one ESM pass per library) → off by default; the plain path is | |
| # the tested default. Any ESM failure degrades to unbinned keys. | |
| esm_bins = bool((request.get_json(force=True, silent=True) or {}).get("esm_bins")) | |
| import datetime as _dt2 | |
| gate_open = _dt2.date.today() >= _agg.EFFECTIVE_DATE | |
| ll_maps = None | |
| if esm_bins and gate_open: # don't load ESM pre-gate | |
| grouped_lib = _auth.list_all_de_outcomes_grouped_with_lib() | |
| grouped = [(uid, meas) for uid, _lib, meas in grouped_lib] | |
| try: | |
| ll_maps = _compute_de_ll_maps(grouped_lib) | |
| except Exception: # noqa: BLE001 | |
| logger.exception("esm-bin ll_maps failed; falling back to plain aggregate") | |
| ll_maps = None | |
| else: | |
| grouped = _auth.list_all_de_outcomes_grouped() | |
| try: | |
| prior = _agg.build_priors(grouped, ll_maps=ll_maps) # enforces the 2026-07-08 gate | |
| except _agg.AggregationGateError as exc: | |
| return jsonify({"ok": False, "error": "gated", "detail": str(exc), | |
| "effective_date": _agg.EFFECTIVE_DATE.isoformat()}), 423 | |
| rows = prior.to_rows("user") | |
| result = _auth.replace_mutation_priors(rows, source="user") | |
| _GLOBAL_PRIOR_CACHE["data"] = None # bust the scorer cache | |
| return jsonify({"ok": bool(result.get("ok")), | |
| "substitutions": len(rows), | |
| "contributing_libraries": len(grouped), | |
| "esm_binned": bool(esm_bins and ll_maps and any(ll_maps)), | |
| "written": result.get("written", 0), | |
| "priors": rows}) | |
| # ── Unified outcomes framework — generic capture + per-tool aggregate ───── | |
| def outcomes_save() -> Response: | |
| """Save this user's wet-lab outcomes for any tool's saved design.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", "kind": "signin_required"}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| tool = str(body.get("tool", "")).strip().lower() | |
| design_id = str(body.get("design_id", "")).strip() | |
| rows = body.get("outcomes") | |
| if not isinstance(rows, list): | |
| return jsonify({"ok": False, "error": "No outcomes provided."}), 400 | |
| result = _auth.save_outcomes(auth.user_id, tool, design_id, rows) | |
| return jsonify(result), (200 if result.get("ok") else 400) | |
| def outcomes_list(tool: str, design_id: str) -> Response: | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"ok": False, "error": "signin_required", "kind": "signin_required"}), 403 | |
| return jsonify({"ok": True, | |
| "outcomes": _auth.list_outcomes(auth.user_id, tool.lower(), design_id)}) | |
| def admin_priors(tool: str) -> Response: | |
| from dee.core import outcomes as _O | |
| if not _admin_ok(): | |
| return jsonify({"ok": False, "error": "forbidden"}), 403 | |
| rows = _auth.get_outcome_priors(tool.lower()) | |
| return jsonify({"ok": True, "tool": tool.lower(), "count": len(rows), "priors": rows, | |
| "effective_date": _O.EFFECTIVE_DATE.isoformat(), | |
| "registered_tools": _O.registered_tools()}) | |
| def admin_rebuild_priors_tool(tool: str) -> Response: | |
| """Recompute one tool's cross-user aggregate from public.outcomes. | |
| Admin-token + date gated. De-identified; k-anonymity per the ToolSpec.""" | |
| from dee.core import outcomes as _O | |
| if not _admin_ok(): | |
| return jsonify({"ok": False, "error": "forbidden"}), 403 | |
| tool = tool.lower() | |
| if _O.get_spec(tool) is None: | |
| return jsonify({"ok": False, "error": "unknown_tool", | |
| "registered_tools": _O.registered_tools()}), 404 | |
| grouped = _auth.list_all_outcomes_grouped(tool) | |
| try: | |
| prior = _O.build_priors(tool, grouped) # enforces the effective-date gate | |
| except _O.AggregationGateError as exc: | |
| return jsonify({"ok": False, "error": "gated", "detail": str(exc), | |
| "effective_date": _O.EFFECTIVE_DATE.isoformat()}), 423 | |
| rows = prior.to_rows() | |
| result = _auth.replace_outcome_priors(tool, rows) | |
| return jsonify({"ok": bool(result.get("ok")), "tool": tool, | |
| "keys": len(rows), "contributing_designs": len(grouped), | |
| "written": result.get("written", 0), "priors": rows}) | |
| def crispr_download() -> Response: | |
| """Return the CSV body for a fresh design. We re-design from the | |
| sequence on download rather than caching per-job to avoid a | |
| whole job-state layer for what's essentially a pure function | |
| of the input. Same sign-in gate as /api/crispr/design.""" | |
| auth = _auth.get_auth() | |
| if auth.anonymous: | |
| return jsonify({"error": "signin_required"}), 403 | |
| body = request.get_json(force=True, silent=True) or {} | |
| seq = (body.get("sequence") or "").strip() | |
| if not seq: | |
| return jsonify({"error": "missing 'sequence'"}), 400 | |
| if len(seq) > 1_000_000: | |
| return jsonify({"error": "input_too_large"}), 400 | |
| try: | |
| max_results = int(body.get("max_results", 50)) | |
| except (TypeError, ValueError): | |
| max_results = 50 | |
| max_results = max(1, min(500, max_results)) | |
| enzyme = str(body.get("enzyme", "cas9")).lower() | |
| if enzyme not in ("cas9", "cas12a"): | |
| enzyme = "cas9" | |
| # Phase 2B-1 — same optional context as /api/crispr/design so the | |
| # downloaded XLSX matches what the user saw in the table. | |
| target_organism = str(body.get("target_organism", "")).lower().strip() | |
| gene_symbol = str(body.get("gene_symbol", "")).strip() | |
| # Validate against the actual genome registry rather than a hardcoded | |
| # list, so adding an organism to offtarget.GENOME_SOURCES enables it | |
| # end to end. The old literal tuple silently rejected any new organism | |
| # and fell back to "no genome search". | |
| from dee.core.offtarget import GENOME_SOURCES as _GENOMES | |
| if target_organism and target_organism not in _GENOMES: | |
| target_organism = "" | |
| if len(gene_symbol) > 32 or not re.match(r"^[A-Za-z0-9._-]*$", gene_symbol): | |
| gene_symbol = "" | |
| # Phase 2C-1 passthrough | |
| from dee.core import crispr_cloning as _cc_dl | |
| vector_id = str(body.get("vector_id", "")).lower().strip() | |
| if vector_id and vector_id not in _cc_dl.CRISPR_VECTORS: | |
| vector_id = "" | |
| # Phase 3 (M1) passthrough so the XLSX matches the on-screen mode. | |
| from dee.core import base_editor as _be_dl | |
| mode = str(body.get("mode", "knockout")).lower() | |
| if mode not in ("knockout", "base_edit"): | |
| mode = "knockout" | |
| base_editor = str(body.get("base_editor", "")).lower().strip() | |
| if base_editor and _be_dl.get_base_editor(base_editor) is None: | |
| base_editor = "" | |
| # Export format. XLSX (default) is locale-safe for Excel users in | |
| # ;-delimiter locales; CSV is the universal format for pipelines / | |
| # pandas / R that some users explicitly need. | |
| fmt = str(body.get("format", "xlsx")).lower().strip() | |
| if fmt not in ("xlsx", "csv"): | |
| fmt = "xlsx" | |
| try: | |
| guides = _find_crispr_guides( | |
| seq, enzyme=enzyme, max_results=max_results, | |
| target_organism=target_organism, gene_symbol=gene_symbol, | |
| vector_id=vector_id, mode=mode, base_editor=base_editor, | |
| ) | |
| except ValueError as exc: | |
| return jsonify({"error": str(exc)}), 400 | |
| rows = _crispr_csv_rows(guides) | |
| # ── CSV export ────────────────────────────────────────────────── | |
| # Comma-delimited, RFC-4180 quoting (csv module quotes any field | |
| # containing a comma/quote/newline — e.g. KO reasoning — so it stays | |
| # in one column even when opened in a ;-delimiter locale). A UTF-8 | |
| # BOM makes Excel (any locale) detect the encoding correctly. | |
| if fmt == "csv": | |
| import csv as _csv | |
| from io import StringIO as _SIO | |
| from flask import make_response as _mr_csv | |
| sio = _SIO() | |
| writer = _csv.writer(sio, quoting=_csv.QUOTE_MINIMAL, lineterminator="\r\n") | |
| for r in rows: | |
| writer.writerow(r) | |
| # Lead with a UTF-8 BOM so Excel auto-detects the encoding. | |
| body_bytes = ("" + sio.getvalue()).encode("utf-8") | |
| resp = _mr_csv(body_bytes) | |
| resp.headers["Content-Type"] = "text/csv; charset=utf-8" | |
| resp.headers["Content-Disposition"] = ( | |
| 'attachment; filename="turingdna_crispr_guides.csv"' | |
| ) | |
| return resp | |
| # ── XLSX export (default) ─────────────────────────────────────── | |
| # openpyxl produces a true Excel file with explicit column types; | |
| # opens cleanly in Excel/Numbers/Google Sheets regardless of locale. | |
| from io import BytesIO as _BIO | |
| from openpyxl import Workbook as _WB | |
| from openpyxl.styles import Font as _Font, Alignment as _Align, PatternFill as _Fill | |
| wb = _WB() | |
| ws = wb.active | |
| ws.title = "Guides" | |
| header_row, data_rows = rows[0], rows[1:] | |
| ws.append(header_row) | |
| # Numeric-typed cells where appropriate so Excel sorts + formats them | |
| # correctly (sort by Composite is the most useful thing the user | |
| # will do in Excel; force scientific notation off, keep 3 decimals). | |
| # Header → indices for the cells we want numeric. | |
| header_index = {name: i for i, name in enumerate(header_row)} | |
| num_cols = { | |
| header_index.get("Rank"), | |
| header_index.get("Position"), | |
| header_index.get("Composite score"), | |
| header_index.get("On-target"), | |
| header_index.get("Max self-CFD"), | |
| header_index.get("Off-target count"), | |
| header_index.get("KO efficacy"), | |
| header_index.get("GC %"), | |
| # Phase 2A numeric columns | |
| header_index.get("Frameshift %"), | |
| header_index.get("Top outcome dominance %"), | |
| # Phase 2B-1 numeric columns | |
| header_index.get("Genome off-target count"), | |
| header_index.get("Genome off-target max CFD"), | |
| # Phase 3 (M1) numeric column | |
| header_index.get("Editability"), | |
| } | |
| num_cols.discard(None) | |
| for r in data_rows: | |
| converted = [] | |
| for col_idx, cell in enumerate(r): | |
| if col_idx in num_cols: | |
| try: | |
| converted.append(float(cell) if "." in str(cell) else int(cell)) | |
| except (TypeError, ValueError): | |
| converted.append(cell) | |
| else: | |
| converted.append(cell) | |
| ws.append(converted) | |
| # Header styling — bold + light paper-tone fill so the header | |
| # row reads as the table caption, not data. | |
| bold = _Font(bold=True, color="0A0A0A") | |
| paper_fill = _Fill(start_color="EFECE5", end_color="EFECE5", fill_type="solid") | |
| for cell in ws[1]: | |
| cell.font = bold | |
| cell.fill = paper_fill | |
| cell.alignment = _Align(vertical="center") | |
| # Freeze the header row so it sticks when the user scrolls down a | |
| # large guide list. Also widen columns to something sensible — | |
| # Excel's autosize logic is browser-dependent. | |
| ws.freeze_panes = "A2" | |
| widths = { | |
| "Rank": 6, "Enzyme": 8, "Strand": 8, "Position": 10, | |
| "Spacer": 28, "PAM": 8, "Target context (5'→3')": 36, | |
| "Composite score": 16, "On-target": 12, "Max self-CFD": 14, | |
| "Off-target count": 16, "KO efficacy": 12, "KO reasoning": 56, | |
| "GC %": 8, "Flags": 18, | |
| # Phase 2A | |
| "Top predicted indel": 22, "Frameshift %": 14, "Top outcome dominance %": 22, | |
| "Indel distribution": 64, "Base-editor compatibility": 30, | |
| # Phase 2B-1 | |
| "Genome searched": 14, "Genome off-target count": 18, | |
| "Genome off-target max CFD": 22, "Top genome off-target": 44, | |
| "Exon context": 32, | |
| # Phase 2C-1 | |
| "Cloning vector": 36, "Addgene #": 12, "Cloning enzyme": 14, | |
| "Sense oligo (5'→3')": 32, "Antisense oligo (5'→3')": 32, | |
| } | |
| from openpyxl.utils import get_column_letter as _col_letter | |
| for i, name in enumerate(header_row, start=1): | |
| ws.column_dimensions[_col_letter(i)].width = widths.get(name, 14) | |
| bio = _BIO() | |
| wb.save(bio) | |
| body_bytes = bio.getvalue() | |
| from flask import make_response as _mr | |
| resp = _mr(body_bytes) | |
| resp.headers["Content-Type"] = ( | |
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" | |
| ) | |
| resp.headers["Content-Disposition"] = ( | |
| 'attachment; filename="turingdna_crispr_guides.xlsx"' | |
| ) | |
| return resp | |
| # NOTE: /api/library, /api/library/<filename> (GET + DELETE), and | |
| # /api/shutdown were removed on 2026-05-26 per SECURITY_AUDIT findings | |
| # C2 + C3. | |
| # | |
| # The library endpoints listed every CSV under the Space-shared | |
| # ~/.dee/output/ directory and let anyone download or delete files | |
| # by guessing filenames — they pre-dated the per-user storage we now | |
| # ship in Supabase. The dashboard (planned task #98) will be the | |
| # replacement, querying public.libraries via Supabase RLS-gated reads. | |
| # | |
| # /api/shutdown was an unauthenticated DoS vector — POSTing to it | |
| # killed the Flask process. Reasonable for the original macOS .app | |
| # use case, dangerous in the hosted multi-tenant deployment. The | |
| # HF Space's own restart mechanism replaces this for ops needs. | |
| return app | |
| # ----------------------------------------------------------------- helpers | |
| # NOTE: the ASSISTANT block (gradio_client connection to | |
| # winter4000/turingdna-assistant, _AssistantUnavailable, _get_assistant_client, | |
| # _format_history_into_message, _call_assistant) was removed on 2026-05-25. | |
| # Full implementation lives in _llm_backup_2026-05-25/server/server.py.pre-strip | |
| # and can be restored when the assistant is ready to ship again. | |
| _VALID_MODELS = {"small", "medium", "large"} | |
| _VALID_HOSTS = {"e_coli", "ecoli", "yeast", "s_cerevisiae", "human", "h_sapiens"} | |
| _VALID_DEVICES = {None, "", "cpu", "cuda", "mps", "auto"} | |
| _VALID_QUANT = {None, "", "none", "int8", "int4", "fp16", "bf16"} | |
| # ─── ESM-2 scorer cache + CPU model gate ────────────────────────────────── | |
| # Moved to dee/core/scoring.py (2026-07-10) so the Turing agent's | |
| # design_variant_library tool can share the SAME cached model instance and | |
| # scoring concurrency guard, without dee/core/ needing to import this file | |
| # (which would be circular — this file already imports dee/core/agent.py). | |
| from dee.core import scoring as _scoring | |
| # DNA-level tiers (Evo 2, via Modal) — same "/api/models reports what's | |
| # actually runnable" contract, separate id-space (see the models() route). | |
| from dee.core import compiler as _compiler | |
| from dee.core import compiler_validation as _compiler_validation | |
| from dee.core import prime_editor as _prime_editor | |
| from dee.core import certificate as _certificate | |
| from dee.core import assay as _assay | |
| from dee.core import measurements as _meas | |
| from dee.core import uplift as _uplift | |
| from dee.core import variant_resolve as _variant_resolve | |
| from dee.core import dna_scoring as _dna_scoring | |
| def _validate_run_settings(raw: Dict[str, Any]) -> Dict[str, Any]: | |
| """Normalize and bounds-check the user-supplied run settings. | |
| Returns a clean dict ready to hand to ``_run_pipeline``. Raises | |
| ``ValueError`` (the route turns this into HTTP 400) on any invalid | |
| value — better to fail loudly at submission than have the worker | |
| thread crash silently or produce a degenerate library. | |
| The original code did ``int(settings.get("k", 30))`` with no bounds, | |
| so a malformed request (k=-1, max_mutations=99999, percentile=200, | |
| host="postgres") would either crash the SearchConfig with an | |
| InternalError or return a 200 with a useless result. The bounds here | |
| are intentionally generous — the goal is to catch garbage, not | |
| second-guess the user's intent. | |
| """ | |
| def _num(field: str, default, lo, hi, kind): | |
| value = raw.get(field, default) | |
| try: | |
| value = kind(value) | |
| except (TypeError, ValueError): | |
| raise ValueError( | |
| f"{field!r} must be a {kind.__name__}; got {raw.get(field)!r}.", | |
| ) | |
| if value < lo or value > hi: | |
| raise ValueError( | |
| f"{field!r}={value} is outside the allowed range [{lo}, {hi}].", | |
| ) | |
| return value | |
| model = (raw.get("model") or "small") | |
| if model not in _VALID_MODELS: | |
| raise ValueError(f"model must be one of {sorted(_VALID_MODELS)}; got {model!r}.") | |
| host = (raw.get("host") or "e_coli") | |
| if host not in _VALID_HOSTS: | |
| raise ValueError(f"host must be one of {sorted(_VALID_HOSTS)}; got {host!r}.") | |
| device = raw.get("device") | |
| if device not in _VALID_DEVICES: | |
| raise ValueError(f"device must be one of {sorted(d or '<auto>' for d in _VALID_DEVICES)}; got {device!r}.") | |
| quant = raw.get("quantization") | |
| if quant not in _VALID_QUANT: | |
| raise ValueError(f"quantization must be one of {sorted(q or '<none>' for q in _VALID_QUANT)}; got {quant!r}.") | |
| # EVERY RUN GETS A SEED, whether or not one was asked for. | |
| # | |
| # The search is simulated annealing over restarts, so an unseeded run is | |
| # not repeatable — the same sequence and the same settings give a slightly | |
| # different library each time, and an outside audit reported exactly that: | |
| # ranks moving by one or two between identical runs. The plumbing to fix | |
| # it already existed end to end (main.py --seed, SearchConfig(seed=...)), | |
| # and nothing ever supplied a value. | |
| # | |
| # Minting one here rather than adding a field to the form is the whole | |
| # point: an optional seed only helps the person who thought to set it | |
| # BEFORE the run they now want to repeat. A seed that is always recorded | |
| # makes every run repeatable after the fact, including the one that | |
| # produced a result worth chasing. | |
| seed = raw.get("seed") | |
| if seed in (None, ""): | |
| import secrets as _secrets | |
| seed = _secrets.randbelow(2 ** 31) | |
| if seed not in (None, ""): | |
| try: | |
| seed = int(seed) | |
| except (TypeError, ValueError): | |
| raise ValueError(f"seed must be an integer or null; got {seed!r}.") | |
| percentile = _num("percentile", 85.0, 0.0, 100.0, float) | |
| k = _num("k", 30, 1, 500, int) | |
| min_mutations = _num("min_mutations", 2, 1, 50, int) | |
| max_mutations = _num("max_mutations", 5, 1, 50, int) | |
| restarts = _num("restarts", 8, 1, 100, int) | |
| steps = _num("steps", 1200, 50, 50000, int) | |
| if min_mutations > max_mutations: | |
| raise ValueError( | |
| f"min_mutations ({min_mutations}) must be ≤ max_mutations ({max_mutations}).", | |
| ) | |
| # CPU-tier safety: downgrade GPU-class models + drop a 'cuda'/quant request | |
| # so the run can't OOM the shared instance. Reflected in the returned dict | |
| # so analytics + the methods note describe what actually ran. | |
| model = _scoring.effective_model(model) | |
| if not _scoring.gpu_available(): | |
| device = None | |
| quant = None | |
| return { | |
| "model": model, | |
| "percentile": percentile, | |
| "k": k, | |
| "min_mutations": min_mutations, | |
| "max_mutations": max_mutations, | |
| "restarts": restarts, | |
| "steps": steps, | |
| "host": host, | |
| "seed": seed, | |
| "device": device, | |
| "quantization": quant, | |
| } | |
| def _safe_decode_text(path: Path) -> str: | |
| """Decode an uploaded sequence file as text without silently losing bytes. | |
| The previous code used ``path.read_text(errors="ignore")`` in the | |
| ORF-fallback branch, which silently drops any byte that isn't valid | |
| UTF-8 — a corrupted upload could yield a different ORF than the user | |
| intended. This helper tries common encodings in order of likelihood for | |
| a sequence file: | |
| 1. UTF-8 with BOM (UTF-8-SIG) — Windows Notepad's default for files | |
| containing any non-ASCII char. | |
| 2. Plain UTF-8 — the modern default everywhere else. | |
| 3. UTF-16 LE/BE — older Windows tooling, especially when the user | |
| exported from Excel as "Unicode Text". | |
| 4. Latin-1 — never raises, accepts all 256 byte values. Last-resort | |
| fallback for genuinely odd inputs (will pass through whatever | |
| bytes are there; downstream alphabet validation rejects them). | |
| Sequences are pure ASCII anyway (ACGTU and amino-acid letters), so for | |
| every legitimate input one of the early encodings produces a usable | |
| string. The point of the chain is to never SILENTLY drop a byte. | |
| """ | |
| raw = path.read_bytes() | |
| for enc in ("utf-8-sig", "utf-8", "utf-16", "utf-16-le", "utf-16-be"): | |
| try: | |
| return raw.decode(enc) | |
| except UnicodeDecodeError: | |
| continue | |
| # latin-1 cannot fail; this branch is the universal fallback. | |
| return raw.decode("latin-1") | |
| def _materialize_input() -> tuple[Path, str]: | |
| """Pull the request's sequence into a temp file we can hand to parse_input. | |
| For uploads we also impose a generous-but-finite size cap (8 MiB) so a | |
| runaway client or accidental binary-file drop can't blow up the server. | |
| Real protein CDS uploads top out under ~50 kB even for the largest | |
| genes; 8 MiB covers full plasmid GenBank files with comfortable margin. | |
| """ | |
| MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # 8 MiB | |
| if "file" in request.files: | |
| file = request.files["file"] | |
| suffix = Path(file.filename or "").suffix or ".txt" | |
| tmp = tempfile.NamedTemporaryFile( | |
| delete=False, suffix=suffix, dir=str(STATE_DIR) | |
| ) | |
| file.save(tmp.name) | |
| tmp.close() | |
| if Path(tmp.name).stat().st_size > MAX_UPLOAD_BYTES: | |
| Path(tmp.name).unlink(missing_ok=True) | |
| raise SequenceValidationError( | |
| f"Uploaded file exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)} MiB limit. " | |
| "Trim to just the gene of interest before uploading.", | |
| ) | |
| return Path(tmp.name), file.filename or "uploaded" | |
| body = request.get_json(force=True, silent=True) or {} | |
| text = body.get("text", "").strip() | |
| if not text: | |
| raise SequenceValidationError("No sequence provided.") | |
| if len(text.encode("utf-8")) > MAX_UPLOAD_BYTES: | |
| raise SequenceValidationError( | |
| f"Pasted text exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)} MiB limit.", | |
| ) | |
| tmp = tempfile.NamedTemporaryFile( | |
| delete=False, suffix=".txt", mode="w", encoding="utf-8", dir=str(STATE_DIR) | |
| ) | |
| tmp.write(text) | |
| tmp.close() | |
| return Path(tmp.name), body.get("name") or "pasted" | |
| def _humanize_paste_name(original_name: str) -> str: | |
| """A clean display label for a raw paste/upload that carries no real name.""" | |
| stem = Path(original_name or "").stem.strip() | |
| if stem and stem.lower() not in ("pasted", "uploaded"): | |
| return stem # a real uploaded filename, e.g. "TP53_exon2" | |
| return "Pasted sequence" | |
| def _clean_record_identifier(record, path: Path, original_name: str) -> None: | |
| """When a raw (non-FASTA) sequence has no header, parse_input falls back to | |
| the file stem as the identifier — and for our temp files that stem is a | |
| random 'tmpXXXXXXXX'. That leaked through as the displayed identifier | |
| ("random letters"). Replace it in place with a human label so it never | |
| surfaces. FASTA headers / GenBank LOCUS names are untouched (their | |
| identifier never equals the temp stem).""" | |
| try: | |
| if record.identifier and record.identifier == path.stem: | |
| record.identifier = _humanize_paste_name(original_name) | |
| except Exception: # noqa: BLE001 — display nicety must never break parsing | |
| pass | |
| def _summarize(path: Path, original_name: str) -> tuple[Dict[str, Any], Dict[str, Any]]: | |
| """Return (JSON-friendly summary, extra session payload to cache). | |
| Three flow modes: | |
| 1. Structured file (.dna/.gb/...) → list annotated CDS features. | |
| 2. Raw input parses cleanly as a single CDS or protein → return preview. | |
| 3. Raw input fails CDS validation (e.g. user pasted a whole plasmid) → | |
| fall back to a 6-frame ORF scan and return ORF choices. | |
| """ | |
| suffix = path.suffix.lower() | |
| # Mode 1 — annotated structured file. | |
| if suffix in {".dna", ".gb", ".gbk", ".genbank", ".embl"}: | |
| features = list_cds_features(path) | |
| cds_options = [ | |
| {"label": label, "length_nt": length} for label, length in features | |
| ] | |
| record = parse_input(path, require_start=False, require_stop=False) | |
| _clean_record_identifier(record, path, original_name) | |
| return ( | |
| { | |
| "original_name": original_name, | |
| "detected_kind": "plasmid", | |
| "identifier": record.identifier, | |
| "protein_length": len(record.protein), | |
| "protein_preview": record.protein[:80] | |
| + ("…" if len(record.protein) > 80 else ""), | |
| "cds_options": cds_options, | |
| }, | |
| # cds_options carried into the session so /api/run can enforce | |
| # the "pick one explicitly" check (audit M7). | |
| {"mode": "structured_file", "cds_options": cds_options}, | |
| ) | |
| # Mode 2 — try clean single-CDS / protein parse. | |
| try: | |
| record = parse_input(path, require_start=False, require_stop=False) | |
| _clean_record_identifier(record, path, original_name) | |
| return ( | |
| { | |
| "original_name": original_name, | |
| "detected_kind": "protein" if not record.dna else "dna", | |
| "identifier": record.identifier, | |
| "protein_length": len(record.protein), | |
| "protein_preview": record.protein[:80] | |
| + ("…" if len(record.protein) > 80 else ""), | |
| "cds_options": None, | |
| }, | |
| {"mode": "single"}, | |
| ) | |
| except SequenceValidationError as clean_exc: | |
| # Mode 3 — fall back to 6-frame ORF discovery for raw DNA. Use the | |
| # safe-decode chain instead of read_text(errors="ignore") so a UTF-16 | |
| # or BOM-prefixed file doesn't silently lose every other byte and | |
| # surface a different ORF than the user pasted. | |
| text = _safe_decode_text(path) | |
| orfs = find_orfs_in_dna(text) | |
| if not orfs: | |
| # Not recoverable; bubble the original parse error up. | |
| raise clean_exc | |
| cds_options = [ | |
| { | |
| "label": o.label, | |
| "length_nt": len(o.dna), | |
| "frame": o.frame, | |
| "protein_length": len(o.protein), | |
| } | |
| for o in orfs | |
| ] | |
| return ( | |
| { | |
| "original_name": original_name, | |
| "detected_kind": "multi_orf", | |
| "identifier": f"{_humanize_paste_name(original_name)} (no annotation)", | |
| "protein_length": 0, | |
| "protein_preview": ( | |
| f"{len(orfs)} ORFs detected across 6 reading frames " | |
| "(no annotated CDS features). Pick one below." | |
| ), | |
| "cds_options": cds_options, | |
| }, | |
| # Same here: carry cds_options into the session so the | |
| # multi-ORF chooser is enforced server-side too (audit M7). | |
| { | |
| "mode": "orf", | |
| "orfs": {o.label: o.dna for o in orfs}, | |
| "cds_options": cds_options, | |
| }, | |
| ) | |
| _DE_ROUND2_MAX_AA = 500 # sync round-2 length cap (bounds ESM score time on free CPU) | |
| # ── Cross-user aggregate prior (the data moat) ────────────────────────── | |
| # Admin access is gated on a shared secret (env TURINGDNA_ADMIN_TOKEN). If the | |
| # env var is unset, the admin endpoints are CLOSED (403) — no admin by default. | |
| _GLOBAL_PRIOR_CACHE: Dict[str, Any] = {"data": None, "ts": 0.0} | |
| _GLOBAL_PRIOR_TTL_S = 600.0 | |
| def _admin_ok() -> bool: | |
| token = os.environ.get("TURINGDNA_ADMIN_TOKEN", "") | |
| if not token: | |
| return False | |
| import hmac | |
| supplied = request.headers.get("X-Admin-Token", "") | |
| return bool(supplied) and hmac.compare_digest(supplied, token) | |
| def _load_global_prior(): | |
| """Load + cache the stored aggregate prior. Returns a GlobalPrior (possibly | |
| empty). Empty → apply_global_prior is a no-op, so this is inert until the | |
| first post-effective-date rebuild populates public.mutation_priors.""" | |
| import time as _t | |
| from dee.core import aggregate as _agg | |
| now = _t.time() | |
| if _GLOBAL_PRIOR_CACHE["data"] is not None and (now - _GLOBAL_PRIOR_CACHE["ts"]) < _GLOBAL_PRIOR_TTL_S: | |
| return _GLOBAL_PRIOR_CACHE["data"] | |
| try: | |
| prior = _agg.GlobalPrior.from_rows(_auth.get_mutation_priors()) | |
| except Exception: # noqa: BLE001 | |
| prior = _agg.GlobalPrior(effects={}, n_users={}, n_obs={}) | |
| _GLOBAL_PRIOR_CACHE["data"] = prior | |
| _GLOBAL_PRIOR_CACHE["ts"] = now | |
| return prior | |
| # Per-tool unified-outcomes prior cache (CRISPR, primer, …). Empty until the | |
| # tool's post-effective-date rebuild populates public.outcome_priors → inert. | |
| _TOOL_PRIOR_CACHE: Dict[str, Any] = {} | |
| def _load_tool_prior(tool: str): | |
| """Cached outcomes.Prior for a tool from public.outcome_priors.""" | |
| import time as _t | |
| from dee.core import outcomes as _O | |
| now = _t.time() | |
| ent = _TOOL_PRIOR_CACHE.get(tool) | |
| if ent is not None and (now - ent["ts"]) < _GLOBAL_PRIOR_TTL_S: | |
| return ent["data"] | |
| spec = _O.get_spec(tool) | |
| weight = spec.weight if spec else 0.2 | |
| try: | |
| prior = _O.Prior.from_rows(tool, _auth.get_outcome_priors(tool), weight=weight) | |
| except Exception: # noqa: BLE001 | |
| prior = _O.Prior(tool=tool, effects={}, n_users={}, n_obs={}, weight=weight) | |
| _TOOL_PRIOR_CACHE[tool] = {"data": prior, "ts": now} | |
| return prior | |
| # Rebuilding the aggregate priors used to require a human to POST to | |
| # /api/admin/rebuild-priors by hand with TURINGDNA_ADMIN_TOKEN — meaning | |
| # "the model compounds on aggregated results" (landing page, §3) was true of | |
| # the mechanism's design but not actually happening unless someone remembered | |
| # to trigger it. This background loop makes it self-sustaining instead. | |
| # Once a day is plenty (outcomes accrue slowly, and a rebuild scans every | |
| # logged outcome — not something to run on every request). Safe to run from | |
| # a single thread: Dockerfile pins gunicorn to --workers 1, so there is | |
| # exactly one of these loops per running instance, never a rebuild race. | |
| _PRIOR_REBUILD_INTERVAL_S = 24 * 3600.0 | |
| def _rebuild_all_priors_once() -> None: | |
| """One rebuild pass: the global DE prior, then every registered per-tool | |
| prior. Each is independent and best-effort — one failing (or still being | |
| before the Privacy/Terms v2.0 effective date, harmless) never blocks the | |
| others.""" | |
| from dee.core import aggregate as _agg | |
| from dee.core import outcomes as _O | |
| try: | |
| grouped = _auth.list_all_de_outcomes_grouped() | |
| rows = _agg.build_priors(grouped).to_rows("user") | |
| _auth.replace_mutation_priors(rows, source="user") | |
| _GLOBAL_PRIOR_CACHE["data"] = None # this route's own cache | |
| _agg.bust_cached_global_prior() # the shared one design_variant_library (chat) reads | |
| logger.info("prior rebuild: de -> %d substitutions from %d libraries", len(rows), len(grouped)) | |
| except _agg.AggregationGateError: | |
| pass # before the effective date — expected, not an error | |
| except Exception: # noqa: BLE001 — one bad cycle must not kill the loop | |
| logger.exception("prior rebuild failed for de") | |
| for tool in _O.registered_tools(): | |
| try: | |
| grouped = _auth.list_all_outcomes_grouped(tool) | |
| rows = _O.build_priors(tool, grouped).to_rows() | |
| _auth.replace_outcome_priors(tool, rows) | |
| _TOOL_PRIOR_CACHE.pop(tool, None) # this route's own cache | |
| _O.bust_cached_tool_prior(tool) # the shared one chat tools read | |
| logger.info("prior rebuild: %s -> %d keys from %d designs", tool, len(rows), len(grouped)) | |
| except _O.AggregationGateError: | |
| pass | |
| except Exception: # noqa: BLE001 | |
| logger.exception("prior rebuild failed for %s", tool) | |
| def _start_prior_rebuild_scheduler() -> None: | |
| """Daemon thread: first rebuild shortly after startup (so a fresh deploy | |
| doesn't wait a full day for the first pass), then once every | |
| _PRIOR_REBUILD_INTERVAL_S. Mirrors the off-target prewarm pattern in | |
| build_app() below — fire-and-forget, never blocks request handling.""" | |
| import threading | |
| import time as _t | |
| def _loop() -> None: | |
| _t.sleep(30.0) # let the rest of startup (prewarm, etc.) settle first | |
| while True: | |
| _rebuild_all_priors_once() | |
| _t.sleep(_PRIOR_REBUILD_INTERVAL_S) | |
| threading.Thread(target=_loop, name="prior-rebuild", daemon=True).start() | |
| def _compute_de_ll_maps(grouped_with_lib): | |
| """Best-effort {single-site label: ESM ΔLL} per library, for the richer | |
| (substitution × ESM-bin) DE aggregate. HEAVY (one ESM pass per distinct | |
| library) → only the opt-in rebuild calls it. Any failure for a library | |
| yields None for that unit (its keys stay unbinned). Returns a list parallel | |
| to grouped_with_lib.""" | |
| cache: Dict[str, Any] = {} | |
| scorer = None | |
| maps = [] | |
| for _uid, lib, _rows in grouped_with_lib: | |
| if lib in cache: | |
| maps.append(cache[lib]); continue | |
| m = None | |
| try: | |
| wt = _auth.get_library_wt(lib) | |
| if (wt and re.match(r"^[ACDEFGHIKLMNPQRSTVWY*]+$", wt) | |
| and len(wt) <= _DE_ROUND2_MAX_AA): | |
| if scorer is None: | |
| scorer = _scoring.get_scorer("small") | |
| df = _scoring.score_guarded(scorer, wt) | |
| m = {f"{r.wt_aa}{int(r.position) + 1}{r.mut_aa}": float(r.delta_ll) | |
| for r in df.itertuples(index=False)} | |
| except Exception: # noqa: BLE001 | |
| logger.exception("ll-map compute failed for library %s", lib) | |
| m = None | |
| cache[lib] = m | |
| maps.append(m) | |
| return maps | |
| # Below this the "top 10%" is one or two variants and its precision is a | |
| # coin flip printed as a percentage. 30 makes the top set at least 3. | |
| _IMPORT_TDP_MIN_N = 30 | |
| _POOL_DELTAS_MAX = 40 # cap the "what changed" payload — biggest movers only | |
| def _de_round2_library(wt_protein: str, settings: Dict[str, Any], | |
| measurements: list) -> tuple[list, Dict[str, Any]]: | |
| """Active-learning round 2 (Design→Build→Test→Learn). Score the WT, blend | |
| the cross-user field-wide prior (parity with round 1), fit the personal | |
| surrogate on the user's measured variants, re-rank the single-site pool by | |
| the learned acquisition score, evolve, and assemble the same variant table | |
| shape as a normal run. Returns (variant_rows, surrogate_info) — the latter | |
| now also carries `pool_deltas` (per-mutation before/after + plain-language | |
| reason, sorted by round-2 score) and `global_prior` (whether the field-wide | |
| aggregate contributed), so the frontend can show a genuine before/after | |
| re-rank instead of a silent number change.""" | |
| from dee.core import active_learning as _al | |
| from dee.core import aggregate as _agg | |
| from dee.optimizer.search import Mutation as _Mut | |
| scorer = _scoring.get_scorer( | |
| settings.get("model", "small"), | |
| settings.get("device"), | |
| settings.get("quantization"), | |
| ) | |
| scores_df = _scoring.score_guarded(scorer, wt_protein) | |
| pool_df = top_percentile_pool(scores_df, percentile=float(settings.get("percentile", 85.0))) | |
| muts = [_Mut(int(r.position), str(r.wt_aa), str(r.mut_aa), float(r.delta_ll)) | |
| for r in pool_df.itertuples(index=False)] | |
| # Same field-wide blend round 1 gets (soft nudge by substitution TYPE only, | |
| # no-op until a post-effective-date rebuild has populated the aggregate) — | |
| # applied BEFORE the personal surrogate so the two signals compose the same | |
| # way in both rounds: ESM-2 prior → +field-wide nudge → +your own data. | |
| global_prior_info = {"applied": False, "substitution_types": 0} | |
| try: | |
| _gp = _load_global_prior() | |
| if _gp and _gp.effects: | |
| muts = _agg.apply_global_prior(muts, _gp) | |
| global_prior_info = {"applied": True, "substitution_types": len(_gp.effects)} | |
| except Exception: # noqa: BLE001 — global-prior blend is a nice-to-have, never fatal | |
| logger.exception("global-prior blend skipped (round 2)") | |
| # Fit the surrogate on the (possibly field-nudged) single-site pool + the | |
| # user's results, then overwrite the pool's per-mutation score with the | |
| # learned acquisition (additive ⇒ evolve() consumes it unchanged). | |
| surrogate = _al.fit_surrogate(muts, measurements) | |
| baseline = {(m.position, m.mut_aa): float(m.delta_ll) for m in muts} | |
| adj_df = pool_df.copy() | |
| adj_df["delta_ll"] = [ | |
| surrogate.adjusted.get((int(r.position), str(r.mut_aa)), float(r.delta_ll)) | |
| for r in pool_df.itertuples(index=False) | |
| ] | |
| # "What Turing learned" — every pool mutation's prior vs adjusted score | |
| # plus a plain-language reason, sorted by the new score so the biggest | |
| # movers (and the ones round 2 now favors most) lead. Cheap: template | |
| # text only, no LLM call, and it's a straight decomposition of numbers | |
| # fit_surrogate already computed — nothing here can fabricate a reason | |
| # the data doesn't support. | |
| pool_deltas = [] | |
| for m in muts: | |
| key = (m.position, m.mut_aa) | |
| prior_score = baseline.get(key, float(m.delta_ll)) | |
| adjusted_score = surrogate.adjusted.get(key, prior_score) | |
| comp = surrogate.components.get(key, {"prior": prior_score, "learned": 0.0, | |
| "explore": 0.0, "n_measured": 0}) | |
| pool_deltas.append({ | |
| "label": f"{m.wt_aa}{m.position + 1}{m.mut_aa}", | |
| "prior_score": round(prior_score, 4), | |
| "adjusted_score": round(adjusted_score, 4), | |
| "delta": round(adjusted_score - prior_score, 4), | |
| "n_measured": int(comp.get("n_measured", 0)), | |
| "reason": _al.narrate(comp.get("prior", prior_score), comp.get("learned", 0.0), | |
| comp.get("explore", 0.0), int(comp.get("n_measured", 0))), | |
| }) | |
| pool_deltas.sort(key=lambda d: d["adjusted_score"], reverse=True) | |
| pool_deltas = pool_deltas[:_POOL_DELTAS_MAX] | |
| variants = evolve(adj_df, SearchConfig( | |
| k=int(settings.get("k", 30)), | |
| max_mutations=int(settings.get("max_mutations", 5)), | |
| min_mutations=int(settings.get("min_mutations", 2)), | |
| n_restarts=int(settings.get("restarts", 8)), | |
| steps_per_restart=int(settings.get("steps", 1200)), | |
| seed=settings.get("seed"), | |
| )) | |
| df = variants_to_dataframe( | |
| wt_protein, variants, | |
| host=settings.get("host", "e_coli"), | |
| forbidden_sites=DEFAULT_FORBIDDEN_SITES, | |
| ) | |
| rows2 = df.to_dict(orient="records") | |
| # Glass-box certainty on the round-2 rows too (free, reuses scores_df). | |
| try: | |
| from dee.core import confidence as _conf | |
| _conf.attach_confidence(rows2, _conf.position_confidence_from_scores(scores_df)) | |
| except Exception: # noqa: BLE001 | |
| logger.exception("confidence annotation skipped (round 2)") | |
| # ── Epistasis in the loop ──────────────────────────────────────────── | |
| # Round 2 doesn't just re-rank on your data — it re-ranks on predicted | |
| # INTERACTION too. We score the top variants' full sequences with ESM-2 | |
| # and demote combinations it thinks will antagonize (clashers sink, | |
| # cooperative combos rise), so the delivered library already avoids the | |
| # duds. Honest scope: this re-ranks the SA's output by ESM-2's contextual | |
| # opinion; the additive search itself doesn't explore epistatically. | |
| # Bounded (top 8, a forward pass each) to stay responsive on the free CPU. | |
| epistasis_info = {"applied": False, "n_clash": 0, "n_analyzed": 0} | |
| try: | |
| from dee.core import epistasis as _epi | |
| analyzed = _epi.analyze_library(wt_protein, rows2, scorer.masked_log_probs, | |
| max_variants=8) | |
| by_mut = {a.mutations: a for a in analyzed} | |
| for r in rows2: | |
| base_fit = float(r.get("Predicted_Fitness_Score", 0.0) or 0.0) | |
| a = by_mut.get(str(r.get("Mutations_AA", "") or "")) | |
| if a is not None: | |
| r["Epistasis_Verdict"] = a.verdict | |
| r["Epistasis_Signed"] = round(a.epistasis_signed, 4) | |
| # Adjust by the EXCESS over a random same-size recombination, | |
| # never by the raw Σshift. Σshift grows with the number of | |
| # mutations whether or not anything interacts, so adding it | |
| # here handed every large variant a bonus and then re-sorted | |
| # the library by it — changing which variants a user is told | |
| # to build, on the strength of their size. excess_signed is | |
| # 0.0 for an uncalibrated variant, leaving its rank untouched. | |
| r["Epistasis_Excess"] = round(a.excess_signed, 4) | |
| r["Interaction_Adjusted_Fitness"] = round(base_fit + a.excess_signed, 4) | |
| else: | |
| r["Epistasis_Verdict"] = "" | |
| r["Epistasis_Signed"] = 0.0 | |
| r["Epistasis_Excess"] = 0.0 | |
| r["Interaction_Adjusted_Fitness"] = round(base_fit, 4) | |
| if analyzed: | |
| # Re-rank the delivered library by the interaction-adjusted fitness, | |
| # then renumber so the visible ordering reflects it. | |
| rows2.sort(key=lambda r: r.get("Interaction_Adjusted_Fitness", 0.0), reverse=True) | |
| for i, r in enumerate(rows2, start=1): | |
| r["Variant_ID"] = f"V{i:04d}" | |
| n_clash = sum(1 for a in analyzed if a.verdict == "clash") | |
| epistasis_info = {"applied": True, "n_clash": n_clash, "n_analyzed": len(analyzed)} | |
| except Exception: # noqa: BLE001 — interaction re-rank is additive, never fatal | |
| logger.exception("epistasis re-rank skipped (round 2)") | |
| return rows2, { | |
| "learned": surrogate.learned, | |
| "n_train": surrogate.n_train, | |
| "w_prior": round(surrogate.w_prior, 3), | |
| "n_effects": surrogate.n_effects, | |
| "note": surrogate.note, | |
| "pool_deltas": pool_deltas, | |
| "global_prior": global_prior_info, | |
| "epistasis": epistasis_info, | |
| } | |
| def _run_pipeline( | |
| job: JobState, | |
| input_path: Path, | |
| original_name: str, | |
| cds_feature: Optional[str], | |
| settings: Dict[str, Any], | |
| ) -> None: | |
| """Background worker. Updates ``job`` in place as it makes progress.""" | |
| try: | |
| job.status = "parsing" | |
| job.message = "Reading sequence…" | |
| job.progress = 0.05 | |
| record = parse_input( | |
| input_path, | |
| require_start=False, | |
| require_stop=False, | |
| cds_feature=cds_feature, | |
| ) | |
| _clean_record_identifier(record, input_path, original_name) | |
| job.wt_identifier = record.identifier | |
| job.wt_protein = record.protein | |
| job.settings_used = { | |
| "model": settings.get("model", "small"), | |
| "percentile": float(settings.get("percentile", 85.0)), | |
| "k": int(settings.get("k", 30)), | |
| "min_mutations": int(settings.get("min_mutations", 2)), | |
| "max_mutations": int(settings.get("max_mutations", 5)), | |
| "restarts": int(settings.get("restarts", 8)), | |
| "steps_per_restart": int(settings.get("steps", 1200)), | |
| "host": settings.get("host", "e_coli"), | |
| "seed": settings.get("seed"), | |
| "device": settings.get("device") or "cpu", | |
| "cds_feature": cds_feature, | |
| } | |
| job.status = "scoring" | |
| job.message = ( | |
| f"Scoring {len(record.protein)} residues × 19 substitutions with ESM-2…" | |
| ) | |
| job.progress = 0.15 | |
| scorer = _scoring.get_scorer( | |
| settings.get("model", "small"), | |
| settings.get("device"), | |
| settings.get("quantization"), | |
| ) | |
| scores_df = _scoring.score_guarded(scorer, record.protein) | |
| job.progress = 0.55 | |
| pool = top_percentile_pool(scores_df, percentile=float(settings.get("percentile", 85.0))) | |
| job.message = f"Filtered to {len(pool)} high-impact single-site mutations." | |
| job.progress = 0.60 | |
| # Soft-blend the cross-user AGGREGATE prior into round-1 scores: every | |
| # design benefits from the field's pooled wet-lab results. No-op until a | |
| # post-effective-date rebuild populates public.mutation_priors (so this | |
| # is inert before 2026-07-08). Individual designs stay ESM-driven; this | |
| # is a small nudge by amino-acid substitution type only. | |
| try: | |
| _gp = _load_global_prior() | |
| if _gp and _gp.effects: | |
| _w = 0.3 | |
| pool = pool.copy() | |
| pool["delta_ll"] = [ | |
| float(r.delta_ll) + _w * _gp.effects.get( | |
| (str(r.wt_aa).upper(), str(r.mut_aa).upper()), 0.0) | |
| for r in pool.itertuples(index=False) | |
| ] | |
| job.message = (f"Filtered to {len(pool)} mutations; blended field-wide " | |
| f"priors ({len(_gp.effects)} substitution types).") | |
| # job.message gets overwritten by later status updates (searching, | |
| # done, ...), so by the time the job finishes this fact would | |
| # otherwise be lost — record it structurally so /api/result can | |
| # still tell the user "this was informed by other labs" after | |
| # the run is done, not just mid-flight in a progress string. | |
| job.global_prior_info = {"applied": True, "substitution_types": len(_gp.effects)} | |
| except Exception: # noqa: BLE001 | |
| logger.exception("global-prior blend skipped") | |
| job.status = "searching" | |
| job.message = ( | |
| f"Optimizing multi-mutants (K={int(settings.get('k', 30))}, " | |
| f"max {int(settings.get('max_mutations', 5))} subs)…" | |
| ) | |
| variants = evolve( | |
| pool, | |
| SearchConfig( | |
| k=int(settings.get("k", 30)), | |
| max_mutations=int(settings.get("max_mutations", 5)), | |
| min_mutations=int(settings.get("min_mutations", 2)), | |
| n_restarts=int(settings.get("restarts", 8)), | |
| steps_per_restart=int(settings.get("steps", 1200)), | |
| seed=settings.get("seed"), | |
| ), | |
| ) | |
| job.progress = 0.85 | |
| job.status = "encoding" | |
| job.message = f"Reverse-translating and scrubbing restriction sites for {len(variants)} variants…" | |
| df = variants_to_dataframe( | |
| record.protein, | |
| variants, | |
| host=settings.get("host", "e_coli"), | |
| forbidden_sites=DEFAULT_FORBIDDEN_SITES, | |
| ) | |
| # Per-variant model-certainty (glass-box): how sharp ESM-2's posterior | |
| # is at the changed residues, weakest-link. Free — reconstructed from | |
| # the ΔLL table we already have (softmax is shift-invariant), no extra | |
| # forward pass. Flows into the table, the CSV, and the result payload. | |
| try: | |
| from dee.core import confidence as _conf | |
| _pos_conf = _conf.position_confidence_from_scores(scores_df) | |
| _cv = [_conf.variant_confidence(str(mm or ""), _pos_conf) | |
| for mm in df["Mutations_AA"]] | |
| df["Confidence"] = [c for (c, _w) in _cv] | |
| df["Confidence_Weakest"] = [w for (_c, w) in _cv] | |
| except Exception: # noqa: BLE001 — confidence is additive, never fatal | |
| logger.exception("confidence annotation skipped (round 1)") | |
| # --------------------------------------------------------------- | |
| # Build a WT pseudo-row with the SAME codon-optimization and | |
| # restriction-site scrub applied to variants, so the user can | |
| # order the unmutated wild-type alongside the evolved variants. | |
| # Variant_ID 'WT' is special-cased on the client. | |
| # --------------------------------------------------------------- | |
| try: | |
| wt_host = settings.get("host", "e_coli") | |
| wt_raw_dna = reverse_translate(record.protein, host=wt_host, append_stop=True) | |
| wt_clean_dna, wt_report = scrub_restriction_sites( | |
| wt_raw_dna, | |
| record.protein + "*", | |
| host=wt_host, | |
| forbidden_sites=DEFAULT_FORBIDDEN_SITES, | |
| ) | |
| wt_pcr = pcr_metrics(wt_clean_dna) | |
| wt_row = { | |
| "Variant_ID": "WT", | |
| "Mutations_AA": "", | |
| "Mutant_AA_Seq": record.protein, | |
| "Optimized_DNA_Seq": wt_clean_dna, | |
| "Predicted_Fitness_Score": 0.0, | |
| "Length_bp": wt_pcr.get("length_bp"), | |
| "GC_Percent": wt_pcr.get("gc_percent"), | |
| "Primer_Fwd": wt_pcr.get("primer_fwd"), | |
| "Primer_Fwd_Tm_C": wt_pcr.get("primer_fwd_tm_c"), | |
| "Primer_Fwd_GC_Percent": wt_pcr.get("primer_fwd_gc"), | |
| "Primer_Rev": wt_pcr.get("primer_rev"), | |
| "Primer_Rev_Tm_C": wt_pcr.get("primer_rev_tm_c"), | |
| "Primer_Rev_GC_Percent": wt_pcr.get("primer_rev_gc"), | |
| "Annealing_Temp_C": wt_pcr.get("annealing_temp_c"), | |
| "Restriction_Sites_Found": sum(wt_report.sites_found.values()), | |
| "Restriction_Sites_Unresolved": len(wt_report.unresolved), | |
| "Confidence": None, # WT isn't a prediction — no certainty score | |
| "Confidence_Weakest": "", | |
| } | |
| except Exception as wt_exc: # noqa: BLE001 — never fail the whole run for the WT row | |
| # Audit H5: previously this branch set wt_row = None and the WT | |
| # row silently vanished from the table. The user's reference | |
| # sample (the unmutated sequence they paid us to design around) | |
| # was gone with no signal. Now we emit a sentinel row with the | |
| # error message in the Mutations_AA column so the UI can render | |
| # an explicit "WT row couldn't be built — <reason>" indicator | |
| # instead of silently omitting it. | |
| logger.warning("Couldn't build WT pseudo-row: %s", wt_exc) | |
| wt_row = { | |
| "Variant_ID": "WT", | |
| "Mutations_AA": f"ERROR: {wt_exc}", | |
| "Mutant_AA_Seq": record.protein, | |
| "Optimized_DNA_Seq": "", | |
| "Predicted_Fitness_Score": 0.0, | |
| "Length_bp": 0, | |
| "GC_Percent": 0.0, | |
| "Primer_Fwd": "", | |
| "Primer_Fwd_Tm_C": None, | |
| "Primer_Fwd_GC_Percent": 0.0, | |
| "Primer_Rev": "", | |
| "Primer_Rev_Tm_C": None, | |
| "Primer_Rev_GC_Percent": 0.0, | |
| "Annealing_Temp_C": None, | |
| "Restriction_Sites_Found": 0, | |
| "Restriction_Sites_Unresolved": 0, | |
| # Sentinel flag the frontend can check to render the error | |
| # banner inline on the WT row instead of trying to display | |
| # an empty DNA sequence as if it were valid. | |
| "WT_Error": str(wt_exc), | |
| } | |
| job.progress = 0.95 | |
| stem = Path(original_name).stem or "library" | |
| safe_id = "".join(c if c.isalnum() else "_" for c in record.identifier) or "wt" | |
| ts = time.strftime("%Y%m%d_%H%M%S") | |
| filename = f"{stem}__{safe_id}__{ts}_dee_library.csv" | |
| primary = OUTPUT_DIR / filename | |
| write_library_csv(df, str(primary)) | |
| job.csv_path = str(primary) | |
| # Best-effort copy to Desktop. Requires the user to have granted the | |
| # .app Files & Folders → Desktop access (NSDesktopFolderUsageDescription | |
| # in Info.plist triggers the system prompt). | |
| desktop_dest = DESKTOP_DIR / filename | |
| try: | |
| desktop_dest.write_bytes(primary.read_bytes()) | |
| job.desktop_path = str(desktop_dest) | |
| except (PermissionError, OSError) as exc: | |
| logger.warning("Could not copy to Desktop (%s); CSV stays in ~/.dee/output/.", exc) | |
| job.desktop_path = None | |
| variant_rows = df.to_dict(orient="records") | |
| # WT row first — UI sorts by Variant_ID so 'WT' sorts naturally last by | |
| # default; the client explicitly pins it to row 0. | |
| job.variants = ([wt_row] + variant_rows) if wt_row else variant_rows | |
| job.status = "done" | |
| job.progress = 1.0 | |
| job.message = f"Done. {len(variants)} variants ready." | |
| job.finished_at = time.time() | |
| # Save the library to Supabase Storage so the user can access it | |
| # from their dashboard. Signed-in users only — anonymous runs no-op | |
| # inside save_library_async. Fire-and-forget; failures are logged | |
| # but don't propagate to the user. | |
| try: | |
| top_fitness = None | |
| if variant_rows: | |
| # Variants are sorted descending by fitness; the first one's | |
| # Predicted_Fitness_Score (if present) is the top score. | |
| top_fitness = variant_rows[0].get("Predicted_Fitness_Score") | |
| if isinstance(top_fitness, (int, float)): | |
| top_fitness = float(top_fitness) | |
| else: | |
| top_fitness = None | |
| # Library name: prefer the original input filename's stem; fall | |
| # back to the WT identifier. Truncated to keep dashboard rows tidy. | |
| library_name = ( | |
| Path(original_name).stem | |
| or record.identifier | |
| or "library" | |
| )[:120] | |
| # Pre-assign the library id so the client gets it in the result and | |
| # can log wet-lab outcomes against it (the learning flywheel). | |
| job.library_id = uuid.uuid4().hex | |
| _auth.save_library_async( | |
| user_id=job.user_id, | |
| job_id=job.job_id, | |
| csv_path=job.csv_path, | |
| name=library_name, | |
| wt_identifier=record.identifier, | |
| wt_protein=record.protein, | |
| host_organism=settings.get("host", "e_coli"), | |
| n_variants=len(variants), | |
| top_fitness=top_fitness, | |
| library_id=job.library_id, | |
| ) | |
| # File it under the project it was designed in. Until this, a | |
| # library reached its project only by matching names in | |
| # /api/mission, so renaming either lost it — and reopening the | |
| # project showed whichever library happened to be on screen. | |
| if job.user_id and job.construct_id: | |
| _auth.attach_library_async(job.user_id, job.construct_id, job.library_id) | |
| except Exception as exc: # noqa: BLE001 | |
| # save_library_async is itself fire-and-forget, but the kwargs | |
| # build above could in principle raise. Never block the user. | |
| logger.warning("save_library kickoff failed: %s", exc) | |
| except SequenceValidationError as exc: | |
| job.status = "error" | |
| job.error = f"Couldn't parse input: {exc}" | |
| job.message = job.error | |
| job.finished_at = time.time() | |
| except Exception as exc: # noqa: BLE001 | |
| logger.exception("Pipeline crashed.") | |
| job.status = "error" | |
| job.error = f"{type(exc).__name__}: {exc}" | |
| job.message = job.error | |
| job.finished_at = time.time() | |
| finally: | |
| # Persist the run's terminal state to public.runs. The row was inserted | |
| # with status='started' when the job kicked off; this is the only place | |
| # status/finished_at/elapsed_seconds/n_variants get written. Best-effort. | |
| try: | |
| _n = len(job.variants) if getattr(job, "variants", None) else None | |
| _auth.mark_run_finished_async( | |
| job_id=job.job_id, | |
| status=job.status, | |
| elapsed_seconds=job.elapsed(), | |
| n_variants=_n, | |
| ) | |
| except Exception: # noqa: BLE001 — telemetry must never affect a run | |
| pass | |
| # ============================================================== EDIT PIPELINE | |
| # Re-derive every column of a variant row from a user-edited DNA string. The | |
| # input is treated as authoritative — we do NOT scrub restriction sites or | |
| # silently mutate the user's bases. We translate it, diff against WT to | |
| # rebuild the mutation label, recompute PCR metrics, and report restriction | |
| # sites as found (cleared count is always 0 — they wrote it, they keep it). | |
| # Returns the new row dict in the same shape as variants_to_dataframe rows | |
| # so it can drop into job.variants[i] directly. Raises ValueError on | |
| # user-correctable issues (callers should turn that into HTTP 400). | |
| def _diff_mutations_against_wt(wt_protein: str, mutant: str) -> str: | |
| """Build a 'A23V,K45R' label from the WT-vs-edited protein diff. | |
| Same convention used elsewhere in the codebase: 1-indexed AA positions, | |
| single-letter codes, '*' for stop. If lengths differ we still emit a | |
| label for the overlapping prefix and prefix the whole string with a | |
| length-change marker so the UI can render the warning prominently. | |
| """ | |
| overlap = min(len(wt_protein), len(mutant)) | |
| muts = [] | |
| for i in range(overlap): | |
| if wt_protein[i] != mutant[i]: | |
| muts.append(f"{wt_protein[i]}{i + 1}{mutant[i]}") | |
| label = ",".join(muts) | |
| if len(wt_protein) != len(mutant): | |
| marker = f"length:WT={len(wt_protein)}aa,edited={len(mutant)}aa" | |
| return f"{marker}|{label}" if label else marker | |
| return label | |
| def _scan_restriction_sites( | |
| dna: str, | |
| forbidden_sites: Optional[Dict[str, str]] = None, | |
| ) -> Dict[str, int]: | |
| """Scan both strands for forbidden recognition sites. Pure observation — | |
| no scrubbing. Returns {enzyme: count} for sites present in the user's | |
| edited DNA, summed across forward + reverse strands so a Type IIS site | |
| on either strand is counted once per occurrence.""" | |
| forbidden = forbidden_sites or DEFAULT_FORBIDDEN_SITES | |
| from Bio.Seq import Seq | |
| rc = str(Seq(dna).reverse_complement()) | |
| counts: Dict[str, int] = {} | |
| for enzyme, motif in forbidden.items(): | |
| fwd = sum(1 for _ in re.finditer(f"(?={re.escape(motif)})", dna)) | |
| rev = sum(1 for _ in re.finditer(f"(?={re.escape(motif)})", rc)) | |
| counts[enzyme] = fwd + rev | |
| return counts | |
| def _rederive_variant_row( | |
| *, | |
| variant_id: str, | |
| edited_dna: str, | |
| wt_protein: str, | |
| original_row: Dict[str, Any], | |
| ) -> Dict[str, Any]: | |
| """Recompute every derived column of a variant row from a new DNA string. | |
| The user's edit is treated as authoritative. We validate (ACGT only, | |
| length multiple of 3), translate (a premature stop is allowed — it | |
| truncates the product, e.g. an intentional nonsense/knockout mutation), | |
| diff against WT for the Mutations_AA label, recompute PCR metrics, and | |
| report restriction-site presence honestly. | |
| Raises ValueError with a user-readable message on validation failure — | |
| callers should turn that into HTTP 400 so the frontend can display it. | |
| """ | |
| from Bio.Seq import Seq | |
| if not edited_dna: | |
| raise ValueError("Edited DNA is empty.") | |
| cleaned = edited_dna.upper() | |
| bad = set(cleaned) - set("ACGT") | |
| if bad: | |
| raise ValueError( | |
| f"Edited DNA contains non-ACGT character(s): {sorted(bad)!r}. " | |
| "Only A, C, G, T are allowed. RNA U should be converted to T " | |
| "and IUPAC ambiguity codes must be resolved before saving." | |
| ) | |
| if len(cleaned) % 3 != 0: | |
| raise ValueError( | |
| f"Edited DNA length ({len(cleaned)} bp) is not a multiple of 3 — " | |
| "frame would be broken. Add or remove bases until the length " | |
| "is divisible by 3 before saving." | |
| ) | |
| # Translate. Stop codons appear as '*'. A premature stop is a LEGITIMATE | |
| # edit — a nonsense / truncation mutation (e.g. an intentional knockout or | |
| # a single-base SNP that happens to create a stop). We don't block it: | |
| # synthesis vendors synthesize the DNA as-is, they don't reject internal | |
| # stops. The encoded protein simply ends at the first stop; the user's | |
| # client shows a non-blocking heads-up. (Previously this hard-rejected the | |
| # save with a false "vendors will reject this" claim — an audit finding.) | |
| raw_protein = str(Seq(cleaned).translate(table=1, to_stop=False)) | |
| protein = raw_protein.split("*")[0] | |
| # Mutations label — diff against WT. For length-changing edits this | |
| # prefixes a 'length:...' marker so the frontend can warn loudly. | |
| is_wt_row = variant_id.upper() == "WT" | |
| mutations_label = "" if is_wt_row and protein == wt_protein else ( | |
| _diff_mutations_against_wt(wt_protein, protein) | |
| ) | |
| # PCR metrics + restriction-site count from the actual edited bytes. | |
| pcr = pcr_metrics(cleaned) | |
| re_counts = _scan_restriction_sites(cleaned) | |
| # Compose the row. Preserve fields we don't recompute (Variant_ID, | |
| # Predicted_Fitness_Score) from the original row so the variant keeps | |
| # its identity in the table. | |
| return { | |
| "Variant_ID": original_row.get("Variant_ID", variant_id), | |
| "Mutations_AA": mutations_label, | |
| "Mutant_AA_Seq": protein, | |
| "Optimized_DNA_Seq": cleaned, | |
| "Predicted_Fitness_Score": original_row.get( | |
| "Predicted_Fitness_Score", 0.0 | |
| ), | |
| "Length_bp": pcr.get("length_bp"), | |
| "GC_Percent": pcr.get("gc_percent"), | |
| "Primer_Fwd": pcr.get("primer_fwd"), | |
| "Primer_Fwd_Tm_C": pcr.get("primer_fwd_tm_c"), | |
| "Primer_Fwd_GC_Percent": pcr.get("primer_fwd_gc"), | |
| "Primer_Rev": pcr.get("primer_rev"), | |
| "Primer_Rev_Tm_C": pcr.get("primer_rev_tm_c"), | |
| "Primer_Rev_GC_Percent": pcr.get("primer_rev_gc"), | |
| "Annealing_Temp_C": pcr.get("annealing_temp_c"), | |
| "Restriction_Sites_Found": sum(re_counts.values()), | |
| # We don't scrub edits, so 'Unresolved' equals 'Found' here. The | |
| # frontend treats anything > 0 as a hard warning before synthesis. | |
| "Restriction_Sites_Unresolved": sum(re_counts.values()), | |
| # Marker so exports + UI can show "edited" indicators without | |
| # second-guessing equality with original_row. | |
| "Edited": True, | |
| } | |
| def _atomic_rewrite_csv(job: JobState) -> None: | |
| """Rewrite the job's library CSV from current job.variants atomically. | |
| Writes to a sibling .tmp file and renames into place so a concurrent | |
| /api/download never sees a half-written file. WT-row 'Edited' flag and | |
| other UI-only fields are dropped from the CSV — the on-disk shape stays | |
| identical to what variants_to_dataframe originally wrote. | |
| """ | |
| import pandas as pd | |
| if not job.csv_path or not job.variants: | |
| return | |
| target = Path(job.csv_path) | |
| tmp = target.with_suffix(target.suffix + ".tmp") | |
| df_rows = [] | |
| for v in job.variants: | |
| row = {k: val for k, val in v.items() if k != "Edited"} | |
| df_rows.append(row) | |
| pd.DataFrame(df_rows).to_csv(str(tmp), index=False) | |
| tmp.replace(target) | |
| EXPORT_DISCLAIMER = ( | |
| "RESEARCH USE ONLY — NOT FOR DIAGNOSTIC OR THERAPEUTIC USE. " | |
| "Sequences in this file are computational predictions generated by " | |
| "TuringDNA. Visually verify every sequence before ordering DNA from a " | |
| "synthesis vendor. The TuringDNA authors disclaim all warranties; " | |
| "you are responsible for validating outputs against your experimental " | |
| "requirements." | |
| ) | |
| def _disclaimer_lines(prefix: str = "") -> List[str]: | |
| """Split the disclaimer into wrapped lines with an optional comment prefix. | |
| Used by formats whose native comment syntax is line-based (FASTA's ``;``, | |
| GenBank ``COMMENT`` blocks, shell-style ``#``).""" | |
| import textwrap | |
| wrapped = textwrap.wrap(EXPORT_DISCLAIMER, width=72) | |
| return [f"{prefix}{line}" for line in wrapped] | |
| def _fasta_from_job(job: JobState, *, of: str = "protein") -> str: | |
| """Serialize a job's variants as FASTA. ``of`` selects which sequence to | |
| emit per record — protein or DNA. Protein is the more common ask (mutants | |
| aligned against WT); DNA is included for completeness. | |
| Prepends the export disclaimer as ``;``-comment lines (the standard | |
| FASTA comment syntax — every conforming parser skips lines starting | |
| with ``;``). The disclaimer is also embedded in every record's | |
| description so a downstream tool that extracts a single record still | |
| carries the "predicted output; verify before use" annotation. | |
| """ | |
| if not job.variants: | |
| return "" | |
| lines: List[str] = [] | |
| lines.extend(_disclaimer_lines("; ")) | |
| lines.append(f"; Generated by TuringDNA · {time.strftime('%Y-%m-%d %H:%M:%S')}") | |
| lines.append(";") | |
| seq_key = "Mutant_AA_Seq" if of == "protein" else "Optimized_DNA_Seq" | |
| for v in job.variants: | |
| header = ( | |
| f">{v.get('Variant_ID')} {v.get('Mutations_AA')} " | |
| f"fitness={v.get('Predicted_Fitness_Score')} " | |
| f"[PREDICTED · verify before synthesis]" | |
| ) | |
| seq = v.get(seq_key, "") | |
| lines.append(header) | |
| for i in range(0, len(seq), 60): | |
| lines.append(seq[i : i + 60]) | |
| return "\n".join(lines) + "\n" | |
| def _genbank_from_job(job: JobState) -> str: | |
| """Build a multi-record GenBank file with one record per variant. | |
| Each record gets a CDS feature whose ``/translation`` is the mutant | |
| protein, plus a misc_feature per substitution so the mutation positions | |
| are visible when opened in SnapGene / Benchling. | |
| """ | |
| from Bio.Seq import Seq | |
| from Bio.SeqFeature import FeatureLocation, SeqFeature | |
| from Bio.SeqRecord import SeqRecord | |
| from Bio import SeqIO | |
| records = [] | |
| for v in job.variants or []: | |
| dna = v.get("Optimized_DNA_Seq", "") | |
| protein = v.get("Mutant_AA_Seq", "") | |
| muts = v.get("Mutations_AA", "") | |
| record = SeqRecord( | |
| Seq(dna), | |
| id=v.get("Variant_ID", "variant"), | |
| name=v.get("Variant_ID", "variant"), | |
| description=( | |
| f"TuringDNA predicted variant — RESEARCH USE ONLY · " | |
| f"verify before synthesis. mutations={muts}; " | |
| f"fitness={v.get('Predicted_Fitness_Score')}" | |
| ), | |
| annotations={ | |
| "molecule_type": "DNA", | |
| "organism": "synthetic construct", | |
| # GenBank COMMENT block — SnapGene / Benchling render this | |
| # at the top of the entry when the file is opened. | |
| "comment": EXPORT_DISCLAIMER, | |
| }, | |
| ) | |
| # Whole-CDS feature with the translation in /translation. | |
| cds_qualifiers = { | |
| "gene": [job.wt_identifier or "designed_variant"], | |
| "product": ["designed variant"], | |
| "note": [f"mutations={muts}"], | |
| } | |
| if protein: | |
| cds_qualifiers["translation"] = [protein] | |
| record.features.append( | |
| SeqFeature( | |
| FeatureLocation(0, len(dna)), | |
| type="CDS", | |
| qualifiers=cds_qualifiers, | |
| ) | |
| ) | |
| # One misc_feature per substitution at the nucleotide it touches. | |
| # Audit H6: the previous parser did | |
| # pos_aa = int("".join(c for c in mut[1:-1] if c.isdigit())) | |
| # which would happily turn a malformed label like "A1V2K3R" into the | |
| # position string "123" and place a feature at nt 367 — completely | |
| # wrong. It would also silently truncate at the first non-digit | |
| # character. Use the same strict regex the frontend's detail panel | |
| # uses (^[A-Z](\d+)[A-Z*]$) so labels that don't match the format | |
| # are skipped rather than parsed into garbage positions. The | |
| # length-marker prefix from manual edits ("length:WT=8aa,...|G3V") | |
| # is handled by taking only the post-"|" portion if present. | |
| _MUT_RE = re.compile(r"^([A-Z])(\d+)([A-Z*])$") | |
| mut_payload = muts.split("|", 1)[-1] if "|" in muts else muts | |
| for mut in mut_payload.split(","): | |
| mut = mut.strip() | |
| m = _MUT_RE.match(mut) | |
| if not m: | |
| continue | |
| pos_aa = int(m.group(2)) | |
| if pos_aa < 1: | |
| continue | |
| nt_start = (pos_aa - 1) * 3 | |
| nt_end = nt_start + 3 | |
| if nt_end > len(dna): | |
| continue | |
| record.features.append( | |
| SeqFeature( | |
| FeatureLocation(nt_start, nt_end), | |
| type="misc_feature", | |
| qualifiers={"label": [mut], "note": [f"Substitution {mut}"]}, | |
| ) | |
| ) | |
| records.append(record) | |
| buf = io.StringIO() | |
| SeqIO.write(records, buf, "genbank") | |
| return buf.getvalue() | |
| def _xlsx_from_job(job: JobState) -> bytes: | |
| """Write the variant table to a production-quality .xlsx workbook. | |
| Two sheets: | |
| * "Variants" — the table itself, with bold-colored header row, frozen | |
| top row, auto-filter, column widths sized per content type, number | |
| formatting per column (3 decimals for fitness, 1 for GC/Tm, integer | |
| for bp), and horizontally-clamped sequence columns so the long | |
| Mutant_AA_Seq / Optimized_DNA_Seq cells don't visually drown out | |
| everything else. | |
| * "Run info" — key-value pairs of every parameter the engine used, | |
| plus the auto-generated methods paragraph for copy-pasting into a | |
| Materials & Methods section. | |
| Requires ``openpyxl``. Returns raw bytes for the Flask handler to stream. | |
| """ | |
| import openpyxl | |
| from openpyxl.styles import Alignment, Border, Font, PatternFill, Side | |
| from openpyxl.utils import get_column_letter | |
| wb = openpyxl.Workbook() | |
| ws = wb.active | |
| ws.title = "Variants" | |
| variants = job.variants or [] | |
| if not variants: | |
| wb.remove(ws) | |
| ws = wb.create_sheet("Variants") | |
| ws["A1"] = "No variants in this run." | |
| buf = io.BytesIO() | |
| wb.save(buf) | |
| buf.seek(0) | |
| return buf.getvalue() | |
| headers = list(variants[0].keys()) | |
| # Header row — bold white on deep brand background, all caps, slightly | |
| # taller so it reads as a real header band. | |
| header_fill = PatternFill("solid", fgColor="0A5F77") | |
| header_font = Font(name="Inter", size=11, bold=True, color="FFFFFF") | |
| header_align = Alignment(horizontal="left", vertical="center", wrap_text=False) | |
| body_font = Font(name="Inter", size=10) | |
| body_align = Alignment(horizontal="left", vertical="top", wrap_text=False) | |
| num_align = Alignment(horizontal="right", vertical="top") | |
| mono_font = Font(name="JetBrains Mono", size=9) | |
| band_fill = PatternFill("solid", fgColor="F4F6F9") # alternating row color | |
| thin = Side(style="thin", color="E0E5EC") | |
| border = Border(left=thin, right=thin, top=thin, bottom=thin) | |
| # Column geometry — width sized to typical content per column type. | |
| # Number formats keyed to specific column names (rest = text). | |
| COL_WIDTH = { | |
| "Variant_ID": 11, | |
| "Mutations_AA": 28, | |
| "Mutant_AA_Seq": 52, | |
| "Optimized_DNA_Seq": 70, | |
| "Predicted_Fitness_Score": 14, | |
| "Length_bp": 9, | |
| "GC_Percent": 10, | |
| "Primer_Fwd": 32, | |
| "Primer_Fwd_Tm_C": 12, | |
| "Primer_Fwd_GC_Percent": 14, | |
| "Primer_Rev": 32, | |
| "Primer_Rev_Tm_C": 12, | |
| "Primer_Rev_GC_Percent": 14, | |
| "Annealing_Temp_C": 14, | |
| "Restriction_Sites_Found": 14, | |
| "Restriction_Sites_Unresolved": 16, | |
| } | |
| NUM_FORMAT = { | |
| "Predicted_Fitness_Score": "0.000", | |
| "GC_Percent": "0.0", | |
| "Primer_Fwd_Tm_C": "0.0", | |
| "Primer_Fwd_GC_Percent": "0.0", | |
| "Primer_Rev_Tm_C": "0.0", | |
| "Primer_Rev_GC_Percent": "0.0", | |
| "Annealing_Temp_C": "0.0", | |
| "Length_bp": "0", | |
| "Restriction_Sites_Found": "0", | |
| "Restriction_Sites_Unresolved": "0", | |
| } | |
| MONO_COLS = {"Mutations_AA", "Mutant_AA_Seq", "Optimized_DNA_Seq", | |
| "Primer_Fwd", "Primer_Rev", "Variant_ID"} | |
| NUM_COLS = set(NUM_FORMAT.keys()) | |
| # Disclaimer banner row at the very top. Merged across all data | |
| # columns, amber background, bold red text — meant to be the first | |
| # thing the user sees when they open the workbook. Drops the actual | |
| # header row to row 2 and data to row 3 onwards. | |
| disclaimer_fill = PatternFill("solid", fgColor="FEF3C7") | |
| disclaimer_font = Font(name="Inter", size=10, bold=True, color="7F1D1D") | |
| disclaimer_align = Alignment(horizontal="left", vertical="center", wrap_text=True) | |
| ws.cell(row=1, column=1, value=EXPORT_DISCLAIMER) | |
| ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=max(1, len(headers))) | |
| banner = ws.cell(row=1, column=1) | |
| banner.fill = disclaimer_fill | |
| banner.font = disclaimer_font | |
| banner.alignment = disclaimer_align | |
| ws.row_dimensions[1].height = 48 | |
| # Write header row. | |
| for col_idx, col in enumerate(headers, start=1): | |
| cell = ws.cell(row=2, column=col_idx, value=col) | |
| cell.fill = header_fill | |
| cell.font = header_font | |
| cell.alignment = header_align | |
| cell.border = border | |
| ws.row_dimensions[2].height = 22 | |
| # Write data rows with alternating band fill + column-specific formatting. | |
| # Data now starts at row 3 because of the banner + header rows above. | |
| for row_idx, variant in enumerate(variants, start=3): | |
| is_band = (row_idx % 2 == 0) | |
| for col_idx, col in enumerate(headers, start=1): | |
| val = variant.get(col) | |
| cell = ws.cell(row=row_idx, column=col_idx, value=val) | |
| cell.border = border | |
| if is_band: | |
| cell.fill = band_fill | |
| if col in NUM_COLS: | |
| cell.alignment = num_align | |
| cell.number_format = NUM_FORMAT[col] | |
| cell.font = body_font | |
| elif col in MONO_COLS: | |
| cell.alignment = body_align | |
| cell.font = mono_font | |
| else: | |
| cell.alignment = body_align | |
| cell.font = body_font | |
| # Column widths. | |
| for col_idx, col in enumerate(headers, start=1): | |
| width = COL_WIDTH.get(col, 14) | |
| ws.column_dimensions[get_column_letter(col_idx)].width = width | |
| # Freeze the header row + first two columns so Variant_ID + Mutations stay | |
| # in view while horizontally scrolling through primer + sequence columns. | |
| # Now C3 because the banner + header rows live above. | |
| ws.freeze_panes = "C3" | |
| # Auto-filter the data range only (skips the banner row). | |
| from openpyxl.utils import get_column_letter as _gcl | |
| last_col_letter = _gcl(len(headers)) | |
| last_row = 2 + len(variants) | |
| ws.auto_filter.ref = f"A2:{last_col_letter}{last_row}" | |
| # ---- Sheet 2: Run info ------------------------------------------------ | |
| info = wb.create_sheet("Run info") | |
| label_font = Font(name="Inter", size=11, bold=True, color="0A5F77") | |
| value_font = Font(name="Inter", size=11) | |
| methods_font = Font(name="Inter", size=10) | |
| # Disclaimer banner at the top of Run info too. Mirrors the Variants | |
| # sheet so a user who jumps straight to Run info still sees the | |
| # research-use-only framing. | |
| info.cell(row=1, column=1, value=EXPORT_DISCLAIMER) | |
| info.merge_cells(start_row=1, start_column=1, end_row=1, end_column=4) | |
| info_banner = info.cell(row=1, column=1) | |
| info_banner.fill = disclaimer_fill | |
| info_banner.font = disclaimer_font | |
| info_banner.alignment = disclaimer_align | |
| info.row_dimensions[1].height = 48 | |
| INFO_ROW_OFFSET = 2 # data starts at row 3 (banner row 1, blank row 2) | |
| s = job.settings_used or {} | |
| model_label = { | |
| "small": "ESM-2 35M (facebook/esm2_t12_35M_UR50D)", | |
| "medium": "ESM-2 650M (facebook/esm2_t33_650M_UR50D)", | |
| "large": "ESM-2 3B (facebook/esm2_t36_3B_UR50D)", | |
| }.get(s.get("model"), s.get("model", "")) | |
| host_label = { | |
| "e_coli": "Escherichia coli", | |
| "yeast": "Saccharomyces cerevisiae", | |
| "human": "Homo sapiens", | |
| }.get(s.get("host"), s.get("host", "")) | |
| rows = [ | |
| ("Wild-type identifier", job.wt_identifier), | |
| ("Wild-type length", f"{len(job.wt_protein)} aa"), | |
| ("Model", model_label), | |
| ("Percentile cutoff", | |
| f"≥ p{s.get('percentile')} (top {100 - float(s.get('percentile', 85)):.0f}%)"), | |
| ("K variants returned", s.get("k")), | |
| ("Min mutations / variant", s.get("min_mutations")), | |
| ("Max mutations / variant", s.get("max_mutations")), | |
| ("SA restarts", s.get("restarts")), | |
| ("SA steps / restart", s.get("steps_per_restart")), | |
| ("Expression host", host_label), | |
| ("Compute device", s.get("device")), | |
| ("Random seed", s.get("seed") if s.get("seed") is not None else "(random)"), | |
| ("CDS feature picked", s.get("cds_feature") or "(none / single CDS)"), | |
| ("Run started (epoch s)", job.started_at), | |
| ("Wall time (s)", job.elapsed()), | |
| ] | |
| for i, (k, v) in enumerate(rows, start=1 + INFO_ROW_OFFSET): | |
| info.cell(row=i, column=1, value=k).font = label_font | |
| info.cell(row=i, column=2, value=v).font = value_font | |
| # Methods paragraph. Offset accounts for the disclaimer banner block. | |
| info.cell(row=len(rows) + 2 + INFO_ROW_OFFSET, column=1, value="Materials & methods (copy-paste)").font = label_font | |
| methods = ( | |
| f"Variants of {job.wt_identifier} ({len(job.wt_protein)} aa) were designed " | |
| f"in silico with {model_label} (Lin et al., Science 2022) using the " | |
| f"wild-type marginal log-likelihood scoring scheme of Meier et al. " | |
| f"(Adv. Neural Inf. Process. Syst. 2021). Single-point substitutions " | |
| f"in the top {100 - float(s.get('percentile', 85)):.0f}% by ΔLL were " | |
| f"retained as the combinatorial search space. {s.get('k')} multi-mutant " | |
| f"variants with {s.get('min_mutations')}–{s.get('max_mutations')} " | |
| f"simultaneous substitutions were generated by simulated annealing " | |
| f"({s.get('restarts')} restarts × {s.get('steps_per_restart')} steps, " | |
| f"geometric cooling) maximizing cumulative ΔLL with stop-codon and " | |
| f"duplicate-position penalties. Optimized DNA was reverse-translated " | |
| f"using {host_label} codon-usage frequencies and synonymously cleaned " | |
| f"of BsaI, BsmBI, and NotI recognition sites for Golden Gate " | |
| f"compatibility." | |
| ) | |
| methods_row = len(rows) + 3 + INFO_ROW_OFFSET | |
| mcell = info.cell(row=methods_row, column=1, value=methods) | |
| mcell.font = methods_font | |
| mcell.alignment = Alignment(wrap_text=True, vertical="top") | |
| info.merge_cells(start_row=methods_row, start_column=1, end_row=methods_row, end_column=4) | |
| info.row_dimensions[methods_row].height = 140 | |
| info.column_dimensions["A"].width = 32 | |
| info.column_dimensions["B"].width = 48 | |
| info.column_dimensions["C"].width = 16 | |
| info.column_dimensions["D"].width = 16 | |
| buf = io.BytesIO() | |
| wb.save(buf) | |
| buf.seek(0) | |
| return buf.getvalue() | |
| def _json_from_job(job: JobState) -> bytes: | |
| """Compact, programmer-friendly JSON dump of the run. | |
| Includes ``_disclaimer`` and ``_generated_at`` fields at the top of | |
| the payload so any downstream consumer sees the "predicted; verify | |
| before use" annotation alongside the data. | |
| """ | |
| payload = { | |
| "_disclaimer": EXPORT_DISCLAIMER, | |
| "_generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), | |
| "_generator": "TuringDNA", | |
| "wt_identifier": job.wt_identifier, | |
| "wt_protein": job.wt_protein, | |
| "wt_protein_length": len(job.wt_protein), | |
| "variants": job.variants or [], | |
| } | |
| return json.dumps(payload, indent=2).encode("utf-8") | |
| def _tsv_from_job(job: JobState) -> bytes: | |
| """Tab-separated equivalent of the CSV — friendlier for some analysis | |
| pipelines (R, awk, Perl) that hate quoted commas. The disclaimer is | |
| prepended as ``#``-prefixed comment lines; pandas / R / awk users | |
| pass ``comment='#'`` (or equivalent) to skip them, while a vendor | |
| eyeballing the file in a text editor still sees the disclaimer first. | |
| """ | |
| import pandas as pd | |
| df = pd.DataFrame(job.variants or []) | |
| body = df.to_csv(sep="\t", index=False) | |
| header = "\n".join(_disclaimer_lines("# ")) + "\n" | |
| header += f"# Generated by TuringDNA · {time.strftime('%Y-%m-%d %H:%M:%S')}\n" | |
| return (header + body).encode("utf-8") | |
| # Format registry — keyed by the value of ``?format=``. Each entry produces | |
| # (payload, mimetype, file-extension). New formats are a one-line addition. | |
| def _render_download(job: JobState, fmt: str): | |
| if fmt == "csv": | |
| # Excel in many European locales (incl. the user's Georgian Excel) | |
| # treats CSV with ',' delimiter as a single column unless told | |
| # otherwise. We prepend Excel's "sep=," hint line and a UTF-8 BOM so | |
| # double-clicking the file opens it with proper columns everywhere. | |
| # | |
| # Disclaimer placement: appended as a final row after all variant | |
| # data. Putting it BEFORE the sep=, hint breaks Excel's auto- | |
| # detection; putting it ABOVE the header row breaks pandas / | |
| # csv.DictReader. A final row with the disclaimer in column A and | |
| # blanks elsewhere is the format-neutral compromise — every tool | |
| # reads it as one extra row, and a vendor opening the file in | |
| # Excel sees the disclaimer plainly at the bottom of the data. | |
| # | |
| # Audit M6: the previous implementation did | |
| # raw = read_bytes(); decoded = raw.decode('utf-8','replace'); …; decoded.encode('utf-8') | |
| # which is a needless round-trip — and silently corrupted any | |
| # byte that wasn't valid UTF-8 (the 'replace' arg). Now we read | |
| # bytes and work in bytes throughout; the only decoding is the | |
| # first-line column count, done on a bounded prefix. | |
| with open(job.csv_path, "rb") as f: | |
| raw = f.read() | |
| # Read the first line in bytes to count columns without decoding | |
| # the whole file. Sequence columns are pure ASCII so split('\n') | |
| # on bytes is safe here. | |
| first_line_bytes = raw.split(b"\n", 1)[0] if raw else b"" | |
| n_cols = max(1, first_line_bytes.count(b",") + 1) if first_line_bytes else 1 | |
| # The disclaimer row is pure ASCII (EXPORT_DISCLAIMER above is | |
| # constructed from ASCII chars only), so encoding once is safe. | |
| disclaimer_row = ( | |
| '"' + EXPORT_DISCLAIMER.replace('"', '""') + '"' | |
| + ("," * (n_cols - 1)) | |
| + "\r\n" | |
| ).encode("utf-8") | |
| bom = b"\xef\xbb\xbf" | |
| return bom + b"sep=,\r\n" + raw + disclaimer_row, "text/csv", "csv" | |
| if fmt == "tsv": | |
| return _tsv_from_job(job), "text/tab-separated-values", "tsv" | |
| if fmt == "fasta": | |
| return _fasta_from_job(job, of="protein").encode("utf-8"), "text/x-fasta", "fasta" | |
| if fmt == "fasta-dna": | |
| return _fasta_from_job(job, of="dna").encode("utf-8"), "text/x-fasta", "fna" | |
| if fmt in ("gb", "genbank"): | |
| return _genbank_from_job(job).encode("utf-8"), "text/x-genbank", "gb" | |
| if fmt in ("xlsx", "excel"): | |
| return ( | |
| _xlsx_from_job(job), | |
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| "xlsx", | |
| ) | |
| if fmt == "json": | |
| return _json_from_job(job), "application/json", "json" | |
| raise ValueError( | |
| "format must be one of: csv, tsv, fasta, fasta-dna, gb, xlsx, json" | |
| ) | |
| # ----------------------------------------------------------------- CLI entry | |
| def serve(host: Optional[str] = None, port: Optional[int] = None) -> None: | |
| """Run the Flask server. | |
| Honors ``HOST`` and ``PORT`` env vars first (so the same image runs under | |
| Hugging Face Spaces' fixed ``PORT=7860`` contract, Fly.io's ``8080``, | |
| Render's ``10000``, etc.) before falling back to local-dev defaults | |
| (``127.0.0.1:4789``). | |
| """ | |
| env_host = os.environ.get("HOST") | |
| env_port = os.environ.get("PORT") | |
| host = host or env_host or "127.0.0.1" | |
| port = port or (int(env_port) if env_port else 4789) | |
| app = build_app() | |
| logger.info("Digital Evolution Engine listening on http://%s:%s", host, port) | |
| print(f"DEE_READY http://{host}:{port}", flush=True) | |
| app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True) | |
| def build_app() -> Flask: | |
| """Construct the app + run one-time startup side-effects (logging config + | |
| off-target prewarm). This is the WSGI entry point used in production by | |
| gunicorn (``dee.server:build_app()``); ``serve()`` reuses it for local dev so | |
| both paths get identical startup behaviour. | |
| Prewarms the CRISPR off-target kmer indexes in daemon threads so user | |
| requests don't pay the 60-300 s cold-build cost (ecoli ~3-5 s; human/mouse | |
| 60-180 s). A Design click before the build finishes returns empty + a | |
| "building" flag rather than blocking the request past the ~120 s cap. | |
| """ | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s %(levelname)s %(name)s: %(message)s", | |
| ) | |
| app = create_app() | |
| try: | |
| from dee.core import offtarget as _ot | |
| for _org in ("ecoli", "human", "mouse"): | |
| if _ot.is_organism_ready(_org): | |
| logger.info("Prewarming kmer index for %s in background", _org) | |
| _ot.kick_off_build(_org) | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Off-target prewarm failed (will still build on demand): %s", exc) | |
| try: | |
| _start_prior_rebuild_scheduler() | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Prior-rebuild scheduler failed to start: %s", exc) | |
| return app | |
| if __name__ == "__main__": | |
| serve() | |