diff --git a/bin/chat-chainlit.py b/bin/chat-chainlit.py index 1c07b81..bd020e9 100644 --- a/bin/chat-chainlit.py +++ b/bin/chat-chainlit.py @@ -288,7 +288,9 @@ async def remove_buttons(proposal: Proposal) -> None: await cl.Action(name=name, payload={}, id=action_id).remove() -async def propose_gene_list(message: cl.Message, identifiers: list[str]) -> None: +async def propose_gene_list( + message: cl.Message, identifiers: list[str], *, earlier: bool = False +) -> None: """Offer to analyse the list; the buttons answer later, without blocking. Not `AskActionMessage`: that disables the message box until the reader @@ -306,7 +308,13 @@ async def propose_gene_list(message: cl.Message, identifiers: list[str]) -> None session_id(), proposal_id, Proposal( - text=message.content, + # Declined, this goes to the model: name the genes when they came + # from an earlier message, or "the list I gave you" means nothing. + text=( + f"{message.content}\n\n(The genes: {', '.join(identifiers)})" + if earlier + else message.content + ), message_id=message.id, identifiers=tuple(identifiers), actions=((run.name, run.id), (decline.name, decline.id)), @@ -315,7 +323,8 @@ async def propose_gene_list(message: cl.Message, identifiers: list[str]) -> None for old in evicted: await remove_buttons(old) await cl.Message( - content=gene_list.describe_proposal(identifiers), actions=[run, decline] + content=gene_list.describe_proposal(identifiers, earlier=earlier), + actions=[run, decline], ).send() @@ -353,6 +362,7 @@ async def body() -> None: @cl.action_callback("gene_list_run") async def on_gene_list_run(action: cl.Action) -> None: + proposals.take_invited(session_id()) proposal = await take_proposal(action.payload.get("id")) if proposal is None: await cl.Message(content=gene_list.EXPIRED).send() @@ -364,6 +374,7 @@ async def on_gene_list_run(action: cl.Action) -> None: @cl.action_callback("gene_list_no") async def on_gene_list_no(action: cl.Action) -> None: + proposals.take_invited(session_id()) proposal = await take_proposal(action.payload.get("id")) if proposal is None: # After a restart the offer is gone, and so is the question's text. @@ -501,6 +512,7 @@ async def main(message: cl.Message) -> None: # First, before any early return: a "yes" means the offer just made, so # any other message -- rate limited, an attachment -- ends that meaning. latest = proposals.take_latest(session_id()) + invited = proposals.take_invited(session_id()) if await message_rate_limited(config): return @@ -532,9 +544,28 @@ async def main(message: cl.Message) -> None: # Before the GSA check, because the request that prompted this said "gsa". # It only proposes; a question that merely looks like a request is one # click from being answered. - identifiers = gene_list.gene_list_request(message.content or "") - if identifiers is not None: - await propose_gene_list(message, identifiers) + text = message.content or "" + # Off the event loop: it is the one pure-Python pass over a message of + # up to 60K characters, and every session shares the loop. + reading = await asyncio.to_thread(gene_list.read_message, text, invited=invited) + if reading.listed is not None: + # Any list the reader sends -- offered or not -- is the one a later + # "analyse those genes" means. + proposals.remember_list(session_id(), reading.listed) + if reading.offer is not None: + await propose_gene_list(message, reading.offer) + return + if reading.refers_back: + # "analyze the gene list that I gave you": the list is in an earlier + # message. Offered by name, so the reader sees which list it means. + earlier = proposals.last_list(session_id()) + if earlier is None: + # Otherwise the model answers, and sends them to the website's + # GSA form -- which takes a matrix, not a list. + await cl.Message(content=gene_list.NO_LIST_YET).send() + proposals.invite(session_id()) + return + await propose_gene_list(message, earlier, earlier=True) return # Asked in words rather than by attaching a file. The answer path is @@ -543,6 +574,8 @@ async def main(message: cl.Message) -> None: # because the same answer path serves the search page, which cannot. if asks_to_run_gsa(message.content or ""): await cl.Message(content=HOW_TO_RUN_GSA).send() + # Its last line invites a gene list in the next message. + proposals.invite(session_id()) return await answer_with_model(message.content, message.id) diff --git a/src/analysis/gene_list.py b/src/analysis/gene_list.py index 8349bbd..e2db6be 100644 --- a/src/analysis/gene_list.py +++ b/src/analysis/gene_list.py @@ -50,6 +50,16 @@ r"|enrichment\s+(for|on)|(enrichment|ora|gsea|analysis)\s+please)\b", re.IGNORECASE, ) +#: Handing a list over: a request and an analysis term at once, because in +#: a chat that analyses lists, "here is my gene list TP53, ERBB3 and JAX9" +#: means "analyse these". Reported as missed on 2026-09-29, straight after +#: the chat had said "include the genes in your message". +_HANDED_OVER = re.compile( + r"\b(here(\s+is|\s+are|\s*'s|s)\s+(my|the|a|our)\s+(gene\s+list|list\s+of\s+genes|genes|list)" + r"|(my|our)\s+(gene\s+list|genes|list\s+of\s+genes)\s*(is|are|:)" + r"|these\s+are\s+(my|our|the)\s+genes|gene\s+list\s*:)", + re.IGNORECASE, +) #: A question about something, not a request to compute it. _ABOUT = re.compile( r"\b(explain\w*|why|how|describe|what\s+(does|is|are|would|do)|difference" @@ -79,7 +89,9 @@ #: Shaped like identifiers, but of something else. _OTHER_ACCESSIONS = re.compile( r"\A(chr[0-9XYM]|rs\d|GS[EM]\d|hg\d|GRCh|v\d|pH\d|Q\d\Z|R-[A-Z]{3}-\d" - r"|log\d|COVID|SARS|PMID|HEK\d|MCF\d|HCT\d" + r"|log\d|COVID|SARS|PMID|HEK\d|MCF\d|HCT\d|U2OS|HepG2|A549\Z|K562\Z" + # Sample and group labels: Sample1, rep2, day3, t0, ctrl1. + r"|(sample|rep|replicate|day|week|condition|group|batch|patient|donor|ctrl|t)\d+\Z" # Protein variants: G12D, V600E, L858R, T790M. r"|[A-Z]\d{2,4}[A-Z]\Z)", re.IGNORECASE, @@ -94,7 +106,10 @@ RUN WITH GENE GENES LIST HELP HOW WHAT WHY THESE THIS THAT MY OUR ME IT THEM ON OF IN TO AN IS ARE ALL SOME ANALYSIS ANALYSE ANALYZE ENRICHMENT PATHWAY PATHWAYS PROTEINS PROTEIN IDENTIFIERS FOLLOWING HERE THANK - PERFORM SUBMIT MAP FIND DO EXECUTE ETC""".split() + PERFORM SUBMIT MAP FIND DO EXECUTE ETC + CONTROL CONTROLS TREATED UNTREATED TREATMENT CTRL TRT WT KO KD OE MOCK + VEHICLE DMSO SAMPLE SAMPLES GENEID GENESYMBOL SYMBOL HELA JURKAT + YES NO NOPE SURE LATER GREAT""".split() ) @@ -216,7 +231,9 @@ def _accepted(run: _Run, *, shouting: bool, pair_ok: bool, question: bool) -> li return [token for token, _ in tokens] -def identifiers_in(text: str, *, shouting: bool = False) -> list[str]: +def identifiers_in( + text: str, *, shouting: bool = False, anywhere: bool = False +) -> list[str]: """The identifiers in the lists in a message, in order, each once. A list is two or more identifier-shaped tokens joined the same way @@ -274,7 +291,10 @@ def close() -> None: run = None marked = any(c in gap for c in ":?\n") opens = ( - match.start() == 0 or marked or previous_token.upper() in _OPENER_WORDS + anywhere + or match.start() == 0 + or marked + or previous_token.upper() in _OPENER_WORDS ) if strength is not None and opens: run = _Run(opened_by_mark=marked, tokens=[(token, strength)]) @@ -290,20 +310,131 @@ def close() -> None: return unique +def _looks_like_genes(found: list[str]) -> bool: + """Enough to stand as a list without a request around it: three or more, + or at least one that is gene-shaped rather than a plain word. "yes, + great" and "control, treated" are replies, not lists.""" + return len(found) >= 3 or any(_strength(t) in ("strong", "caps") for t in found) + + def gene_list_request(text: str) -> list[str] | None: """The identifiers to propose analysing, if this message asks for it.""" if len(text) > MAX_MESSAGE_CHARS: return None - request = _REQUEST.search(text) - if not (request and _ANALYSIS_TERMS.search(text)) or _ABOUT.search(text): + request = _REQUEST.search(text) or _HANDED_OVER.search(text) + handed_over = _HANDED_OVER.search(text) is not None + if not (request and (handed_over or _ANALYSIS_TERMS.search(text))) or _ABOUT.search( + text + ): return None # Typed in capitals, every word looks like a symbol; then only tokens # with a digit, or in the accession formats, count. found = identifiers_in(text, shouting=request.group().isupper()) + if len(found) < MIN_IDENTIFIERS and handed_over: + # "my genes are TP53, MDM2, CDKN1A": the list follows the phrase, + # which opens it as a colon would. ("are" does not open lists in + # general: "...where the controls are WT, KO".) + match = _HANDED_OVER.search(text) + if match is not None: + # Opened as "for" would, not as a colon: a comma list of any case + # is read, but space-separated words are not -- "my genes are + # highly expressed in muscle" is prose (held-out set 5). + found = identifiers_in("for " + text[match.end() :]) return found if len(found) >= MIN_IDENTIFIERS else None -def describe_proposal(identifiers: list[str]) -> str: +#: Pointing back at a list from an earlier message: "can you analyze the +#: gene list that I gave you" (reported 2026-09-29, answered by the model). +#: It must name a gene list -- "analyse the pathways above" or "run it again" +#: point back at something else. +_REFERS_BACK = re.compile( + r"\b((the|my|that|this|our)\s+(gene\s+list|list\s+of\s+genes|list|genes)" + r"|(those|these|them|the)\s+genes|gene\s+list)\b", + re.IGNORECASE, +) +#: Pointing back at something that is not a gene list: a matrix, a file, a +#: GSA -- which the GSA how-to answers -- or the results. +_REFERS_ELSEWHERE = re.compile( + r"\b(matri(x|ces)|files?|upload\w*|attach\w*|expression|samples?|pathways" + r"|results?|website|gsa|gsea|reactome\s*gsa|counts?)\b", + re.IGNORECASE, +) + + +def refers_back(text: str) -> bool: + """A request to analyse a gene list given in an earlier message.""" + if len(text) > MAX_MESSAGE_CHARS or _ABOUT.search(text): + return False + return bool( + _REQUEST.search(text) + and _ANALYSIS_TERMS.search(text) + and _REFERS_BACK.search(text) + and not _REFERS_ELSEWHERE.search(text) + and len(identifiers_in(text)) < MIN_IDENTIFIERS + ) + + +def listed(text: str) -> list[str] | None: + """A list the reader sent, remembered in case they ask about it later.""" + if len(text) > MAX_MESSAGE_CHARS: + return None + # Anywhere: "What do TP53, MDM2 and CDKN1A have in common?" has no word + # that opens a list, but it is the list "analyse those" will mean. + found = identifiers_in(text, anywhere=True) + # Three or more, one of them gene-shaped: "hmm, interesting" and + # "PD-1 PD-L1 checkpoint blockade" are not the list a reader means. + if len(found) >= 3 and any(_strength(t) in ("strong", "caps") for t in found): + return found[:MAX_SUBMITTED_IDENTIFIERS] + return None + + +def answer_to_invitation(text: str) -> list[str] | None: + """The identifiers in a reply to "send me your genes", however phrased. + + Straight after the chat has told the reader to include their genes in a + message, a message that lists two or more is that message -- no verb or + analysis term needed. Still only proposes, and a question about the + genes ("how do TP53 and MDM2 interact?") is still a question. + """ + if len(text) > MAX_MESSAGE_CHARS or _ABOUT.search(text): + return None + found = identifiers_in(text) + if len(found) >= MIN_IDENTIFIERS and _looks_like_genes(found): + return found + return None + + +@dataclass(frozen=True) +class Reading: + """What one message means for the gene-list flow, decided in one place.""" + + #: Identifiers in this message to offer an analysis of. + offer: list[str] | None = None + #: It asks about a gene list from an earlier message. + refers_back: bool = False + #: A list it mentions, to remember for a later "analyse those". + listed: list[str] | None = None + + +def read_message(text: str, *, invited: bool) -> Reading: + """Every gene-list decision about a message. + + Pure, so the handler's choices can be tested, and run off the event + loop by the handler: on a 60K message the separate calls took up to a + second between them. + """ + offer = gene_list_request(text) + if offer is None and invited: + # Just told "include the genes in your message": a list is the reply. + offer = answer_to_invitation(text) + if offer is not None: + return Reading(offer=offer, listed=offer[:MAX_SUBMITTED_IDENTIFIERS]) + if refers_back(text): + return Reading(refers_back=True) + return Reading(listed=listed(text)) + + +def describe_proposal(identifiers: list[str], *, earlier: bool = False) -> str: """What the chat is about to submit, shown before it does.""" shown = ", ".join(f"`{i}`" for i in identifiers[:MAX_PROPOSED_LISTED]) more = len(identifiers) - MAX_PROPOSED_LISTED @@ -320,7 +451,8 @@ def describe_proposal(identifiers: list[str]) -> str: "than chance would put there. (A gene set analysis with ReactomeGSA " "needs expression measurements for each sample — attach a matrix " "with 📎 if you have one.)\n\n" - f"I read **{len(identifiers)} identifiers** in your message: {shown}.{limit}\n\n" + f"I read **{len(identifiers)} identifiers** in your " + f"{'earlier ' if earlier else ''}message: {shown}.{limit}\n\n" "Run the analysis on these? (Or just type *yes*.)" ) @@ -453,3 +585,9 @@ def describe_overrepresentation( ) FAILED_TO_ANSWER = "Something went wrong answering that. Please try again." + +NO_LIST_YET = ( + "I don't have a gene list from you in this conversation yet. Paste the " + "genes in your next message — for example *TP53, ERBB2, RUNX2* — and " + "I'll offer to run an over-representation analysis on them." +) diff --git a/src/analysis/proposals.py b/src/analysis/proposals.py index 1192fb6..015ba47 100644 --- a/src/analysis/proposals.py +++ b/src/analysis/proposals.py @@ -14,6 +14,8 @@ from collections import OrderedDict from dataclasses import dataclass, field +from analysis.client import MAX_SUBMITTED_IDENTIFIERS + MAX_PER_SESSION = 5 MAX_SESSIONS = 2000 @@ -33,6 +35,10 @@ class _Session: offers: OrderedDict[str, Proposal] = field(default_factory=OrderedDict) #: The offer a typed "yes" means: the one just made, and only that. latest: str | None = None + #: The chat has just told the reader to send their genes. + invited: bool = False + #: The last list the reader sent, for "analyse the list I gave you". + last_list: tuple[str, ...] | None = None @dataclass @@ -85,5 +91,30 @@ def take_latest(self, session_id: str) -> str | None: latest, session.latest = session.latest, None return latest + def remember_list(self, session_id: str, identifiers: list[str]) -> None: + # Bounded like a submission: 2,000 sessions of 10K strings is not. + self._session(session_id).last_list = tuple( + identifiers[:MAX_SUBMITTED_IDENTIFIERS] + ) + + def last_list(self, session_id: str) -> list[str] | None: + session = self._sessions.get(session_id) + if session is None or session.last_list is None: + return None + return list(session.last_list) + + def invite(self, session_id: str) -> None: + """The chat asked for a gene list; the next message may be one.""" + self._session(session_id).invited = True + + def take_invited(self, session_id: str) -> bool: + """Whether this message answers that invitation -- and forget it, so + only the message straight after counts.""" + session = self._sessions.get(session_id) + if session is None: + return False + invited, session.invited = session.invited, False + return invited + proposals = ProposalStore() diff --git a/tests/analysis/gene_list_phrases.py b/tests/analysis/gene_list_phrases.py index 6f0d153..b40dc69 100644 --- a/tests/analysis/gene_list_phrases.py +++ b/tests/analysis/gene_list_phrases.py @@ -19,7 +19,14 @@ - THIRD_REVIEW_*: that review's fresh set, then tuned against (4 question misfires, all two genes in a question, fixed by one rule). - HELD_OUT_4_*: written after round three's fixes, never tuned against: - 9/10 requests exact, 0/12 questions offered. The miss is a KNOWN_LIMIT. + 9/10 requests exact, 0/12 questions offered. The miss was pinned as a + known limit, then fixed by the hand-over phrasing (2026-09-29); it is in + TUNED_LATER now, since a fix aimed at a case no longer measures it. +- HELD_OUT_5: the three routes added 2026-09-29 (hand-over, reply to an + invitation, pointing back), written after round four of review and + measured once, untuned: 13/16. Two misses were then fixed ("Here's my + list:", and prose after "my genes are" read as genes) so those moved to + TUNED_LATER; the third is a KNOWN_LIMIT. A positive is exact: the identifiers read, in order. A request that reads the wrong list is a failure even if it fires. @@ -600,5 +607,38 @@ "gene\tlog2FC\tpadj\nTP53\t2.1\t0.001\nMDM2\t1.5\t0.01\nCDKN1A\t3.2\t0.0001\nplease run an enrichment analysis", None, ), - ("find pathways for my genes: Pax6, Sox1, Nes, Otx2", None), ] + +#: Cases a later fix was aimed at: they pass, and prove nothing about a rate. +TUNED_LATER: list[tuple[str, list[str]]] = [ + ( + "find pathways for my genes: Pax6, Sox1, Nes, Otx2", + ["Pax6", "Sox1", "Nes", "Otx2"], + ), + ( + "Here's my list: SPP1, COL1A1, RUNX2, BGLAP", + ["SPP1", "COL1A1", "RUNX2", "BGLAP"], + ), +] + +#: (message, invited, offer expected, points back expected) +HELD_OUT_5: list[tuple[str, bool, list[str] | None, bool]] = [ + ("my gene list is ATG5, ATG7, BECN1", False, ["ATG5", "ATG7", "BECN1"], False), + ("these are the genes: nr4a1, fos, egr1", False, ["nr4a1", "fos", "egr1"], False), + ("My genes are highly expressed in muscle", False, None, False), + ("PTEN, PIK3CA, AKT1", True, ["PTEN", "PIK3CA", "AKT1"], False), + ("SOX9\nCOL2A1\nACAN", True, ["SOX9", "COL2A1", "ACAN"], False), + ("mock, mock, infected, infected", True, None, False), + ("I only have fold changes, is that ok?", True, None, False), + ("okay thanks, I'll try", True, None, False), + ("Tumour, Normal", True, None, False), + ("please run the enrichment on the genes I sent", False, None, True), + ("could you analyse that gene list now", False, None, True), + ("analyse the pathways in that result", False, None, False), + ("rerun the analysis with the other file", False, None, False), + ("can you run GSEA on my expression matrix from before", False, None, False), +] + +#: Wrong, pinned: a pair joined by "and" is not read as a reply to an +#: invitation (the pair rule is for questions; this one is not a question). +KNOWN_LIMITS_INVITED: list[tuple[str, list[str] | None]] = [("BRCA1 and BRCA2", None)] diff --git a/tests/analysis/test_gene_list.py b/tests/analysis/test_gene_list.py index f1568ea..cb600f4 100644 --- a/tests/analysis/test_gene_list.py +++ b/tests/analysis/test_gene_list.py @@ -5,6 +5,7 @@ """ import asyncio +import re import time from collections.abc import Callable @@ -16,11 +17,15 @@ from analysis.client import MAX_SUBMITTED_IDENTIFIERS from analysis.gene_list import ( MAX_PROPOSED_LISTED, + answer_to_invitation, confirms, describe_overrepresentation, describe_proposal, gene_list_request, identifiers_in, + listed, + read_message, + refers_back, ) # The message that prompted this, verbatim. @@ -29,8 +34,17 @@ "ERBB2 and RUNX2" ) +#: Reported 2026-09-29: sent straight after the chat said "include the genes +#: in your message", and answered by the model instead. +HANDED_OVER = "here is my gene list TP53, ERBB3 and JAX9" + REQUESTS = [ (ASKED, ["TP53", "ERBB2", "RUNX2"]), + (HANDED_OVER, ["TP53", "ERBB3", "JAX9"]), + ("my genes are TP53, MDM2, CDKN1A", ["TP53", "MDM2", "CDKN1A"]), + ("Here are my genes:\nTP53\nMDM2", ["TP53", "MDM2"]), + ("these are my genes: egfr, kras, braf", ["egfr", "kras", "braf"]), + ("gene list: SOX2, POU5F1, NANOG", ["SOX2", "POU5F1", "NANOG"]), ("perform ORA on\nTP53\nMDM2\nCDKN1A\n", ["TP53", "MDM2", "CDKN1A"]), ("run a GSEA with genes MYC, MAX", ["MYC", "MAX"]), ("do a pathway analysis for TP53, TP53, tp53, MDM2", ["TP53", "MDM2"]), @@ -46,6 +60,7 @@ *phrases.HELD_OUT_3_REQUESTS, *phrases.THIRD_REVIEW_REQUESTS, *phrases.HELD_OUT_4_REQUESTS, + *phrases.TUNED_LATER, *( (phrases.TRAILING_BASE + tail, ["TP53", "MDM2", "CDKN1A"]) for tail in phrases.TRAILING @@ -362,3 +377,230 @@ def test_the_link_is_to_the_service_that_holds_the_token( assert analysis_client.pathway_browser_url("abc%3D") == ( "https://beta.reactome.org/PathwayBrowser/#/DTAB=AN&ANALYSIS=abc%3D" ) + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("TP53, ERBB3, JAX9", ["TP53", "ERBB3", "JAX9"]), + (HANDED_OVER, ["TP53", "ERBB3", "JAX9"]), + ("TP53\nMDM2\nCDKN1A", ["TP53", "MDM2", "CDKN1A"]), + ("ok: egfr, kras, braf", ["egfr", "kras", "braf"]), + ], +) +def test_after_an_invitation_a_bare_list_is_the_reply( + text: str, expected: list[str] +) -> None: + assert answer_to_invitation(text) == expected + + +@pytest.mark.parametrize( + "text", + [ + "how do TP53 and MDM2 interact?", + "thanks", + "TP53", + "what does the 20 MB limit mean?", + "Can you explain the difference between TP53, MDM2 and CDKN1A?", + ], +) +def test_after_an_invitation_other_replies_are_not_lists(text: str) -> None: + assert answer_to_invitation(text) is None + + +def test_a_bare_list_without_an_invitation_is_left_alone() -> None: + # The invitation is what makes a bare list a request. + assert gene_list_request("TP53, ERBB3, JAX9") is None + + +#: Reported 2026-09-29, after the list had been sent in an earlier message. +REFERS_BACK = "can you analyze the gene list that I gave you" + + +@pytest.mark.parametrize( + "text", + [ + REFERS_BACK, + "please run an enrichment on those genes", + "run ORA on the list I sent earlier", + "analyse my gene list", + ], +) +def test_a_request_about_an_earlier_list_refers_back(text: str) -> None: + assert refers_back(text) + + +@pytest.mark.parametrize( + "text", + [ + "what is an enrichment analysis?", # a question + "can you explain the list of pathways above?", # about, not a request + "run ORA on TP53, MDM2", # the list is here, not earlier + "tell me about TP53", + "", + # "them" could be anything; a gene list must be named. + "can you do a pathway analysis of them?", + # Pointing back at something that is not a gene list (review, round 4). + "analyse the pathways above", + "analyze the results above", + "can you perform the analysis the website mentioned", + "run a gene set analysis on my expression data I sent before", + "analyze them with GSEA instead", + "run GSA on it again with the samples I listed", + "analyse the file I uploaded earlier", + # A gene list is named, but it is a file or a GSA -- the guard for these. + "analyse the genes in the file I uploaded", + "run GSEA on those genes", + "run a GSA on the gene list from my expression matrix", + ], +) +def test_other_messages_do_not(text: str) -> None: + assert not refers_back(text) + + +def test_a_list_is_remembered_from_any_message() -> None: + assert listed("What do TP53, MDM2 and CDKN1A have in common?") == [ + "TP53", + "MDM2", + "CDKN1A", + ] + assert listed("What does TP53 do?") is None + + +def test_an_offer_of_an_earlier_list_says_so() -> None: + assert "in your earlier message" in describe_proposal( + ["TP53", "MDM2"], earlier=True + ) + assert "in your message" in describe_proposal(["TP53", "MDM2"]) + + +def test_asking_for_a_list_when_none_was_sent_invites_one_that_would_work() -> None: + # The example in the reply, sent as the next message, must be offered. + from analysis.gene_list import NO_LIST_YET + + example = re.search(r"\*([^*]+)\*", NO_LIST_YET) + assert example is not None + assert answer_to_invitation(example.group(1)) == ["TP53", "ERBB2", "RUNX2"] + + +@pytest.mark.parametrize( + "text", + [ + # The how-to's own step-2 example, typed straight after it. + "control, control, treated, treated", + "yes, great", + "sure, sounds good", + "nope, later", + "ctrl, trt", + "WT, KO", + "day0, day3, day7", + "Sample1, Sample2, Sample3", + "Rep1 Rep2 Rep3", + "HeLa, U2OS cells", + "Treated, Untreated", + "GeneSymbol, Sample1, Sample2", + ], +) +def test_after_an_invitation_replies_that_are_not_genes_are_not_offered( + text: str, +) -> None: + assert answer_to_invitation(text) is None + + +@pytest.mark.parametrize( + "text", + [ + "hmm, interesting", + "I see, makes sense", + "great, now tell me more", + "What is PD-1 PD-L1 checkpoint blockade", + "SARS-CoV-2 ACE2 TMPRSS2 entry pathway", + ], +) +def test_chat_is_not_remembered_as_a_list(text: str) -> None: + assert listed(text) is None + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("my genes are TP53, MDM2, CDKN1A", ["TP53", "MDM2", "CDKN1A"]), + ("my genes are egfr, kras, braf", ["egfr", "kras", "braf"]), + ], +) +def test_a_hand_over_reads_the_list_after_the_phrase( + text: str, expected: list[str] +) -> None: + assert gene_list_request(text) == expected + + +def test_are_does_not_open_a_list_in_general() -> None: + # Review, round 4: "are" opening lists made mid-sentence pairs requests. + assert ( + gene_list_request("run an analysis on RNA-seq where the controls are WT, KO") + is None + ) + + +# --- the whole decision, as the handler takes it ------------------------------ + + +def test_reading_the_reported_first_conversation() -> None: + how_to = read_message("can we run a gsa experiment", invited=False) + assert how_to.offer is None + assert how_to.refers_back is False + handed = read_message(HANDED_OVER, invited=True) + assert handed.offer == ["TP53", "ERBB3", "JAX9"] + assert handed.listed == ["TP53", "ERBB3", "JAX9"] + + +def test_reading_the_reported_second_conversation() -> None: + asked = read_message("What do TP53, ERBB3 and MDM2 have in common?", invited=False) + assert asked.offer is None + assert asked.listed == ["TP53", "ERBB3", "MDM2"] + back = read_message(REFERS_BACK, invited=False) + assert back.refers_back is True + assert back.offer is None + + +def test_an_invitation_only_widens_what_is_offered() -> None: + assert read_message("TP53, ERBB3, MDM2", invited=False).offer is None + assert read_message("TP53, ERBB3, MDM2", invited=True).offer == [ + "TP53", + "ERBB3", + "MDM2", + ] + assert ( + read_message("control, control, treated, treated", invited=True).offer is None + ) + + +def test_a_matrix_request_is_left_to_the_gsa_how_to() -> None: + reading = read_message( + "run a gene set analysis on my expression data I sent before", invited=False + ) + assert reading.offer is None + assert reading.refers_back is False + + +@pytest.mark.parametrize(("text", "invited", "offer", "back"), phrases.HELD_OUT_5) +def test_the_new_routes_on_a_held_out_set( + text: str, invited: bool, offer: list[str] | None, back: bool +) -> None: + reading = read_message(text, invited=invited) + assert reading.offer == offer + assert reading.refers_back == back + + +@pytest.mark.parametrize(("text", "current"), phrases.KNOWN_LIMITS_INVITED) +def test_known_limits_after_an_invitation(text: str, current: list[str] | None) -> None: + assert read_message(text, invited=True).offer == current + + +@pytest.mark.parametrize("invited", [False, True]) +def test_reading_a_whole_message_is_fast(invited: bool) -> None: + # Off the event loop, but still one worker thread per message. + for text in ("a b " * 15_000, "run analysis on them " + "a " * 29_000): + started = time.perf_counter() + read_message(text, invited=invited) + assert time.perf_counter() - started < 2.0 diff --git a/tests/analysis/test_proposals.py b/tests/analysis/test_proposals.py index ab8c15d..f3d5bf6 100644 --- a/tests/analysis/test_proposals.py +++ b/tests/analysis/test_proposals.py @@ -84,3 +84,27 @@ def test_nothing_to_take_is_none_not_an_error() -> None: assert store.take("unknown", "p") is None assert store.take("unknown", None) is None assert store.take_latest("unknown") is None + + +def test_an_invitation_counts_for_the_next_message_only() -> None: + store = ProposalStore() + assert store.take_invited("s") is False + store.invite("s") + assert store.take_invited("s") is True + assert store.take_invited("s") is False + + +def test_invitations_are_per_session() -> None: + store = ProposalStore() + store.invite("s1") + assert store.take_invited("s2") is False + assert store.take_invited("s1") is True + + +def test_the_last_list_is_kept_per_session() -> None: + store = ProposalStore() + assert store.last_list("s") is None + store.remember_list("s", ["TP53", "MDM2"]) + store.remember_list("s", ["EGFR", "KRAS"]) + assert store.last_list("s") == ["EGFR", "KRAS"] + assert store.last_list("other") is None