"""What is this thing I just pasted? — answered locally first, and honestly. The audit calls this "the single most common first move a scientist makes" (#5, High). Someone has a sequence from a collaborator, a supplementary table or a sequencing run, and the first question is not "improve it" but "what am I even looking at?". THE DISTINCTION THIS MODULE IS BUILT AROUND ------------------------------------------- **Characterizing is not identifying.** Everything here is computed from the residues themselves against catalogues already in this repo: it can say "this is 1,341 bp of DNA, one clean reading frame, carrying a T7 promoter and a C-terminal 6xHis, and its 3' junction matches pET-28a(+)". That is a real, checkable, useful answer and it costs nothing. What it can NOT say is "this is human MC1R". Naming a sequence requires comparing it to every known sequence, which means BLAST — and BLAST is the one operation in this engine that sends the user's actual residues outside the Space. So `identified` is **always False** here, and the result names the tool that would change that. An agent that says "this is insulin" because it found a His-tag is exactly the failure this module exists to prevent. That ordering is also the privacy-correct one. Local analysis answers most of the question for free; BLAST is offered as a deliberate, consented step rather than being the reflex. REUSE, DON'T REINVENT --------------------- The audit's single most common finding is capability that already exists in the repo with nothing pointing at it. So this module computes almost nothing of its own: * `sequence.classify_sequence` — the three-way DNA/protein/ambiguous call * `plasmid.find_motifs` — the curated motif catalogue (promoters, terminators, tags, loxP/FRT/att sites) * `plasmid.find_orfs` — linear-time ORF scan on both strands * `edits.is_coding` / `translate` — the strict CDS test and the codon table * `accession.classify` — is the paste actually an identifier? * `vectors` — the 61-backbone catalogue and its junctions The protein tag table is **derived by translating the DNA motifs already in `plasmid._MOTIFS`** rather than typed out here. One source of truth, and no sequence in this file was written from recall. """ from __future__ import annotations import re from typing import Any, Dict, List, Optional from dee.core import accession as _acc from dee.core import edits as _edits from dee.core import plasmid as _plasmid from dee.core import sequence as _seq from dee.core import vectors as _vectors # Below this there is not enough sequence to say anything, and a 6-mer matches # something by chance in every database on earth. MIN_LEN = 12 # Shortest ORF worth reporting. Below ~30 aa an "ORF" in random DNA is noise: # a stop codon appears roughly every 21 codons by chance. MIN_ORF_AA = 30 # A vector junction shorter than this is usually just a restriction site, which # is shared by hundreds of backbones and proves nothing. Most of the 61 # catalogue entries record only a 6-bp site, so only a minority can ever # produce a junction call — which is the honest outcome, not a shortfall. MIN_JUNCTION = 16 # Distinct residues below which a "protein" looks synthetic or mis-pasted. LOW_COMPLEXITY_BELOW = 8 LOW_COMPLEXITY_MIN_LEN = 60 _FASTA_HEADER = re.compile(r"^\s*>([^\n\r]*)", re.M) _GENBANK = re.compile(r"^\s*LOCUS\s+\S", re.M) def _tag_peptides() -> List[Dict[str, str]]: """Protein-level tags, derived from the repo's own DNA motif catalogue. Translating `plasmid._MOTIFS` keeps a single source of truth and means no peptide here was typed from memory — if the DNA catalogue is corrected, this follows automatically. """ out: List[Dict[str, str]] = [] seen = set() for name, motif, ftype in _plasmid._MOTIFS: if ftype != "tag" or len(motif) % 3: continue pep = _edits.translate(motif) if not pep or "X" in pep or "*" in pep: continue if pep in seen: continue seen.add(pep) out.append({"name": name, "peptide": pep}) return out _TAGS = _tag_peptides() def _clean_residues(text: str) -> str: """Strip FASTA headers, digits and whitespace; keep letters and stops.""" body = re.sub(r"^\s*>[^\n]*$", "", text or "", flags=re.M) return re.sub(r"[^A-Za-z*]", "", body).upper() def _read_paste(text: str) -> Dict[str, Any]: """Work out what SHAPE the paste is before worrying what it contains.""" raw = (text or "").strip() if not raw: return {"format": "empty", "header": None, "residues": ""} if _GENBANK.search(raw): return {"format": "genbank", "header": None, "residues": _clean_residues(raw)} m = _FASTA_HEADER.search(raw) if m: return {"format": "fasta", "header": m.group(1).strip()[:200], "residues": _clean_residues(raw)} # A bare token with no residue-ish bulk may be an identifier, not a sequence. token = raw.split()[0] if raw.split() else "" if len(raw.split()) == 1 and _acc.looks_like_accession(token): return {"format": "accession", "header": None, "residues": "", "token": token} return {"format": "raw", "header": None, "residues": _clean_residues(raw)} def _header_accession(header: str) -> Optional[Dict[str, str]]: """An accession quoted in a FASTA header is a CLAIM, not evidence.""" for token in re.split(r"[|\s,;]+", header or ""): token = token.strip() if len(token) >= 4 and _acc.looks_like_accession(token): family, molecule = _acc.classify(token) return {"accession": token, "family": family, "molecule": molecule} return None def _vector_junctions(dna: str) -> List[Dict[str, Any]]: """Catalogue backbones whose cloning junction appears in this sequence. Hits are grouped by end, because a junction identifies a FAMILY, not a vector: the whole pET series shares one C-terminal His junction, and the catalogue happens to record it at different lengths for different entries. A longer recorded junction is a longer catalogue string, not better evidence — naming the longest match alone would invent a distinction the sequence does not support. """ found: Dict[str, List[Dict[str, Any]]] = {} try: cat = _vectors._load() except Exception: # pragma: no cover return [] up = dna.upper() for rec in cat.values(): for field in ("flanking_5p_max", "flanking_3p_max"): junction = (rec.get(field) or "").upper() if len(junction) < MIN_JUNCTION: continue for strand, needle in ((1, junction), (-1, _plasmid.revcomp(junction))): idx = up.find(needle) if idx < 0: continue end = "5'" if field.startswith("flanking_5p") else "3'" found.setdefault(end, []).append({ "vector": rec.get("name") or rec.get("id"), "start": idx, "strand": strand, "matched_bp": len(junction), }) break out: List[Dict[str, Any]] = [] for end, hits in sorted(found.items()): hits.sort(key=lambda h: (-h["matched_bp"], str(h["vector"]))) names = [h["vector"] for h in hits] out.append({ "end": end, "vectors": names, "start": min(h["start"] for h in hits), "longest_match_bp": max(h["matched_bp"] for h in hits), "distinguishing": len(names) == 1, "note": None if len(names) == 1 else ( f"{len(names)} catalogue backbones share this {end} junction. " f"The match says which FAMILY the construct came from, not " f"which vector — do not pick one."), }) return out def _analyse_dna(dna: str) -> Dict[str, Any]: ambiguous = sum(1 for c in dna if c not in "ACGT") out: Dict[str, Any] = { "gc_percent": _plasmid.gc_percent(dna), "ambiguity_codes": ambiguous, "multiple_of_three": len(dna) % 3 == 0, "is_clean_cds": _edits.is_coding(dna), } orfs = _plasmid.find_orfs(dna, min_aa=MIN_ORF_AA, max_orfs=200) if orfs: longest = max(orfs, key=lambda o: o["end"] - o["start"]) span = longest["end"] - longest["start"] out["orfs"] = len(orfs) out["longest_orf"] = { "aa": span // 3 - 1, # the terminal stop is not a residue "start": longest["start"], "end": longest["end"], "strand": longest["strand"], "covers_percent": round(100.0 * span / len(dna), 1), } else: out["orfs"] = 0 out["longest_orf"] = None out["features"] = [ {"name": f["name"], "type": f["type"], "start": f["start"], "end": f["end"], "strand": f["strand"]} for f in _plasmid.find_motifs(dna) ] out["vector_junctions"] = _vector_junctions(dna) return out def _analyse_protein(protein: str) -> Dict[str, Any]: body = protein.rstrip("*") distinct = len(set(body)) out: Dict[str, Any] = { "starts_with_methionine": body.startswith("M"), "internal_stop": "*" in body, "unknown_residues": body.count("X"), "distinct_residues": distinct, } out["tags"] = [ {"name": t["name"], "peptide": t["peptide"], "start": body.find(t["peptide"])} for t in _TAGS if t["peptide"] in body ] # A real protein uses most of the alphabet. Very low diversity means either # a synthetic construct (poly-A linker, His run) or DNA pasted by mistake. out["low_complexity"] = (len(body) >= LOW_COMPLEXITY_MIN_LEN and distinct < LOW_COMPLEXITY_BELOW) return out def _summary(molecule: str, length: int, detail: Dict[str, Any]) -> str: """One line a bench scientist would actually say out loud.""" if molecule == "protein": bits = [f"{length} aa protein"] if detail.get("tags"): bits.append(", ".join(t["name"] for t in detail["tags"])) # Worth surfacing unprompted: a premature stop means the translation # is truncated, and every downstream number would be about a protein # the cell never makes. if detail.get("internal_stop"): bits.append("contains an internal stop — this translation is truncated") if detail.get("unknown_residues"): bits.append(f"{detail['unknown_residues']} unknown (X) residues") if detail.get("low_complexity"): bits.append("unusually low residue diversity") return " · ".join(bits) unit = "nt RNA" if molecule == "rna" else "bp DNA" bits = [f"{length} {unit}", f"{detail.get('gc_percent')}% GC"] if detail.get("is_clean_cds"): bits.append("a clean CDS end to end") elif detail.get("longest_orf"): orf = detail["longest_orf"] bits.append(f"longest ORF {orf['aa']} aa ({orf['covers_percent']}% of it)") else: bits.append("no ORF of 30 aa or more") named = sorted({f["name"] for f in detail.get("features") or []}) if named: bits.append(", ".join(named[:4]) + ("…" if len(named) > 4 else "")) for j in (detail.get("vector_junctions") or [])[:1]: names = j["vectors"] bits.append(f"{j['end']} junction matches {names[0]}" if j["distinguishing"] else (f"{j['end']} junction shared by {len(names)} catalogue " f"backbones ({names[0]}, {names[1]}…)")) return " · ".join(bits) def identify(text: str) -> Dict[str, Any]: """Characterize a pasted sequence locally. Never claims to have named it.""" paste = _read_paste(text) if paste["format"] == "empty": return {"ok": False, "kind": "empty", "error": "Nothing to identify.", "next": "Paste a sequence, or an accession to fetch one."} if paste["format"] == "accession": token = paste["token"] family, molecule = _acc.classify(token) return { "ok": True, "kind": "accession", "identified": False, "input_format": "accession", "accession": token, "family": family, "molecule": molecule, "summary": (f"'{token}' is a {family} identifier" + (f" for {molecule}" if molecule else "") + ", not a sequence."), "next": (f"Call fetch_sequence with '{token}' to retrieve it. " f"An accession names the sequence already — there is " f"nothing to identify."), } residues = paste["residues"] if len(residues) < MIN_LEN: return {"ok": False, "kind": "too_short", "error": f"Only {len(residues)} residues; {MIN_LEN} is the " f"minimum worth analysing.", "next": "Short oligos are better handled by design_primers."} molecule = _seq.classify_sequence(residues) if molecule == "dna" and "U" in residues and "T" not in residues: molecule = "rna" if molecule == "ambiguous": return { "ok": True, "kind": "ambiguous", "identified": False, "input_format": paste["format"], "molecule": "ambiguous", "length": len(residues), "summary": (f"{len(residues)} residues of A/C/G/T/U/N only — at " f"this length that reads equally well as a short " f"peptide or a DNA fragment."), "next": ("Ask the user which it is before analysing further. " "Guessing here silently misroutes the whole job."), } detail = (_analyse_protein(residues) if molecule == "protein" else _analyse_dna(residues)) claim = _header_accession(paste["header"]) if paste.get("header") else None result: Dict[str, Any] = { "ok": True, "kind": "characterized", "input_format": paste["format"], "molecule": molecule, "length": len(residues), "summary": _summary(molecule, len(residues), detail), "detail": detail, # The whole point. Local analysis describes; it does not name. "identified": False, "to_identify_it": ( "Nothing above names this sequence — it is computed from the " "residues against local catalogues. To find out WHAT it is, call " "blast_sequence, which searches NCBI. That sends the sequence " "outside this Space and the user is asked to approve it first."), "caveat": ( "Motif and junction matches are exact string matches against a " "curated list, so a hit is real but an absence means only 'not in " "the list' — the catalogue is small and deliberately conservative."), } if paste.get("header"): result["header_claim"] = { "text": paste["header"], "accession": claim, "note": ("This came from the FASTA header, which is whatever the " "person who wrote the file typed. Treat it as a claim to " "check, not as evidence — fetch the accession and compare " "if it matters."), } return result