"""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