Spaces:
Running
Running
| """Live Collision IP Radar — v1 (literature, Europe PMC). | |
| Cross-references a freshly generated CRISPR guide / evolved variant against | |
| recent literature (preprints + published papers) so the user is warned when | |
| their design overlaps existing work. Patents are deferred to v2 (need a | |
| Lens.org token). | |
| Design constraints (decided 2026-06-02): | |
| * OPT-IN, METADATA-FIRST. We never receive the user's full novel sequence — | |
| only the gene symbol, the specific mutation set, and (for CRISPR) the | |
| 20-mer guide spacer. Those are what we send to Europe PMC. | |
| * MATCH ON SPECIFIC IDENTIFIERS, not gene+keyword noise. "TP53 AND CRISPR" | |
| returns ~12k papers — meaningless as a collision. The exact 20-mer spacer | |
| (full-text phrase) or the exact mutation set is what makes a hit real: | |
| a novel guide returns 0 hits; a published one returns the papers that | |
| printed it. | |
| * NO definitive infringement / "patented by X" claims. We surface "possible | |
| prior art / overlap — verify" with the citation; the framing is the | |
| frontend's job, this module just returns matches. | |
| * Non-blocking + cached. A 24 h in-process TTL cache (the HF Space is a | |
| single instance — no Redis) means 50 users checking the same standard | |
| TP53 guide hit Europe PMC once, then serve cached. Empty results are | |
| cached too (a clean guide shouldn't re-ping on every view). | |
| Pure stdlib (urllib + json). No new dependencies. | |
| """ | |
| from __future__ import annotations | |
| import datetime as _dt | |
| import hashlib | |
| import json | |
| import os | |
| import re | |
| import threading | |
| import time | |
| import urllib.parse | |
| import urllib.request | |
| from typing import Dict, List, Optional | |
| _PMC = "https://www.ebi.ac.uk/europepmc/webservices/rest/search" | |
| _TIMEOUT = 8 # seconds per Europe PMC call | |
| _CACHE_TTL = 24 * 3600 | |
| _CACHE_MAX = 512 | |
| _MAX_RESULTS = 4 | |
| # A valid CRISPR spacer for searching: pure ACGT, plausible guide length. | |
| _SPACER_RE = re.compile(r"^[ACGT]{17,25}$") | |
| # HGVS-ish protein point mutation, e.g. T47A, S101G, R248* (stop). | |
| _MUT_RE = re.compile(r"^[A-Za-z](\d{1,5})[A-Za-z*]$") | |
| # ── tiny thread-safe TTL cache ───────────────────────────────────────────── | |
| _cache: "dict[str, tuple[float, dict]]" = {} | |
| _cache_lock = threading.Lock() | |
| def _cache_get(key: str) -> Optional[dict]: | |
| with _cache_lock: | |
| hit = _cache.get(key) | |
| if not hit: | |
| return None | |
| expires, val = hit | |
| if expires < time.time(): | |
| _cache.pop(key, None) | |
| return None | |
| return val | |
| def _cache_put(key: str, val: dict) -> None: | |
| with _cache_lock: | |
| if len(_cache) >= _CACHE_MAX: | |
| # drop the oldest-expiring entry — cheap bound, no LRU bookkeeping | |
| oldest = min(_cache, key=lambda k: _cache[k][0]) | |
| _cache.pop(oldest, None) | |
| _cache[key] = (time.time() + _CACHE_TTL, val) | |
| def _cache_clear() -> None: # test hook | |
| with _cache_lock: | |
| _cache.clear() | |
| # ── Europe PMC ───────────────────────────────────────────────────────────── | |
| def _pmc_search(query: str, page_size: int = 5) -> List[dict]: | |
| """Run one Europe PMC query, newest first. Returns the raw result dicts. | |
| Network/parse errors raise — the caller decides how to degrade.""" | |
| qs = urllib.parse.urlencode({ | |
| "query": query, | |
| "format": "json", | |
| "resultType": "lite", | |
| "pageSize": str(page_size), | |
| "sort": "P_PDATE_D desc", | |
| }) | |
| req = urllib.request.Request( | |
| f"{_PMC}?{qs}", | |
| headers={"User-Agent": "TuringDNA-IPRadar/1.0 (research; contact via turingdna.com)"}, | |
| ) | |
| with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: | |
| data = json.loads(resp.read().decode("utf-8", "replace")) | |
| return (data.get("resultList") or {}).get("result") or [] | |
| def _days_ago(date_str: str) -> Optional[int]: | |
| try: | |
| d = _dt.date.fromisoformat(date_str[:10]) | |
| return max(0, (_dt.date.today() - d).days) | |
| except (ValueError, TypeError): | |
| return None | |
| def _source_label(source: str, doi: str) -> str: | |
| if source == "PPR": | |
| # 10.1101 is the Cold Spring Harbor prefix (bioRxiv / medRxiv). | |
| return "bioRxiv / medRxiv" if (doi or "").startswith("10.1101") else "a preprint" | |
| if source == "PAT": | |
| return "a patent" | |
| return "a journal" | |
| def _authors(author_string: str) -> str: | |
| if not author_string: | |
| return "" | |
| first = author_string.split(",", 1)[0].strip().rstrip(".") | |
| return f"{first} et al." if "," in author_string else first | |
| def _url_for(r: dict) -> str: | |
| doi = r.get("doi") | |
| if doi: | |
| return f"https://doi.org/{doi}" | |
| pmid = r.get("pmid") | |
| if pmid: | |
| return f"https://europepmc.org/abstract/MED/{pmid}" | |
| rid, src = r.get("id"), r.get("source") | |
| if rid and src: | |
| return f"https://europepmc.org/article/{src}/{rid}" | |
| return "" | |
| def _shape(r: dict, match_type: str, match_label: str) -> dict: | |
| date = r.get("firstPublicationDate") or "" | |
| return { | |
| "title": (r.get("title") or "").rstrip(". "), | |
| "authors": _authors(r.get("authorString") or ""), | |
| "date": date, | |
| "days_ago": _days_ago(date), | |
| "year": r.get("pubYear") or "", | |
| "source": _source_label(r.get("source") or "", r.get("doi") or ""), | |
| "is_preprint": (r.get("source") == "PPR"), | |
| "url": _url_for(r), | |
| "match_type": match_type, # 'guide_exact' | 'mutation_set' | |
| "match_label": match_label, # human label of WHAT matched | |
| } | |
| def _build_queries(gene_symbol: str, mutations: List[str], spacer: str) -> List[tuple]: | |
| """→ list of (query, match_type, match_label). Only SPECIFIC identifiers — | |
| never a bare gene+keyword (that's noise, not a collision).""" | |
| out = [] | |
| if spacer and _SPACER_RE.match(spacer): | |
| out.append((f'"{spacer}"', "guide_exact", "this exact guide sequence")) | |
| if gene_symbol and mutations: | |
| muts = " AND ".join(f'"{m}"' for m in mutations[:6]) | |
| out.append((f"({gene_symbol}) AND ({muts})", "mutation_set", | |
| f"{gene_symbol} + {', '.join(mutations[:6])}")) | |
| return out | |
| _PATENTSVIEW = "https://search.patentsview.org/api/v1/patent/" | |
| def check_patents(gene_symbol: str = "", max_results: int = 3) -> Dict: | |
| """Recent patents that mention the target gene + genome-editing terms, via | |
| the FREE PatentsView Search API. Needs a (free, registered) env key | |
| PATENTSVIEW_API_KEY; without it we return available=False and the radar | |
| stays literature-only. | |
| This is GENE-LEVEL "related patents in this space" — NOT a precise | |
| sequence-to-claim match (that needs Lens PatSeq, which is paid). The UI | |
| frames it as context to review, never as an infringement finding. | |
| """ | |
| gene_symbol = (gene_symbol or "").strip() | |
| key = os.environ.get("PATENTSVIEW_API_KEY", "").strip() | |
| if not key or not gene_symbol: | |
| return {"available": bool(key), "patents": []} | |
| cache_key = "pat:" + gene_symbol.lower() | |
| cached = _cache_get(cache_key) | |
| if cached is not None: | |
| return cached | |
| q = json.dumps({"_and": [ | |
| {"_text_phrase": {"patent_abstract": gene_symbol}}, | |
| {"_text_any": {"patent_abstract": "CRISPR Cas9 gene editing guide RNA"}}, | |
| ]}) | |
| params = urllib.parse.urlencode({ | |
| "q": q, | |
| "f": json.dumps(["patent_id", "patent_title", "patent_date", | |
| "assignees.assignee_organization"]), | |
| "o": json.dumps({"size": max_results}), | |
| "s": json.dumps([{"patent_date": "desc"}]), | |
| }) | |
| out = {"available": True, "patents": []} | |
| try: | |
| req = urllib.request.Request( | |
| f"{_PATENTSVIEW}?{params}", | |
| headers={"X-Api-Key": key, "User-Agent": "TuringDNA-IPRadar/1.0"}, | |
| ) | |
| with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: | |
| data = json.loads(resp.read().decode("utf-8", "replace")) | |
| for p in (data.get("patents") or [])[:max_results]: | |
| assignees = p.get("assignees") or [] | |
| assignee = (assignees[0] or {}).get("assignee_organization", "") if assignees else "" | |
| pid = p.get("patent_id") or "" | |
| out["patents"].append({ | |
| "title": (p.get("patent_title") or "").rstrip(". "), | |
| "assignee": assignee, | |
| "date": p.get("patent_date") or "", | |
| "year": (p.get("patent_date") or "")[:4], | |
| "url": f"https://patents.google.com/patent/US{pid}" if pid else "", | |
| }) | |
| except Exception: | |
| pass # patents are best-effort; literature still returns | |
| _cache_put(cache_key, out) | |
| return out | |
| def check_literature( | |
| *, | |
| gene_symbol: str = "", | |
| mutations: Optional[List[str]] = None, | |
| spacer: str = "", | |
| mode: str = "knockout", | |
| max_results: int = _MAX_RESULTS, | |
| ) -> Dict: | |
| """Metadata-first literature collision check. Returns | |
| {ok, matches:[...], checked:[labels]} — matches sorted newest-first, | |
| de-duplicated across queries. Cached (incl. empty results).""" | |
| gene_symbol = (gene_symbol or "").strip() | |
| spacer = (spacer or "").strip().upper() | |
| mutations = [m for m in (mutations or []) if _MUT_RE.match(str(m))][:8] | |
| queries = _build_queries(gene_symbol, mutations, spacer) | |
| if not queries: | |
| # Nothing specific enough to be a real collision signal. | |
| return {"ok": True, "matches": [], "checked": [], | |
| "note": "Need an exact guide spacer or a gene + mutation set to scan."} | |
| key = "ipr:" + hashlib.sha1( | |
| json.dumps([q for q, _, _ in queries], sort_keys=True).encode() | |
| ).hexdigest() | |
| cached = _cache_get(key) | |
| if cached is not None: | |
| return cached | |
| matches: List[dict] = [] | |
| seen = set() | |
| checked: List[str] = [] | |
| for query, mtype, mlabel in queries: | |
| checked.append(mlabel) | |
| try: | |
| results = _pmc_search(query) | |
| except Exception: | |
| continue # one source failing shouldn't sink the whole check | |
| for r in results: | |
| dedup = r.get("doi") or r.get("pmid") or r.get("id") | |
| if dedup in seen: | |
| continue | |
| seen.add(dedup) | |
| matches.append(_shape(r, mtype, mlabel)) | |
| if len(matches) >= max_results: | |
| break | |
| if len(matches) >= max_results: | |
| break | |
| matches.sort(key=lambda m: m.get("date") or "", reverse=True) | |
| out = {"ok": True, "matches": matches, "checked": checked} | |
| _cache_put(key, out) | |
| return out | |