Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions bin/chat-chainlit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)),
Expand All @@ -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()


Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
154 changes: 146 additions & 8 deletions src/analysis/gene_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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()
)


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)])
Expand All @@ -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
Expand All @@ -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*.)"
)

Expand Down Expand Up @@ -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."
)
31 changes: 31 additions & 0 deletions src/analysis/proposals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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()
Loading
Loading