Skip to content

Commit 3243a07

Browse files
committed
feat(geo_qc): AI-powered GEO metadata exploration
1 parent ec8c148 commit 3243a07

11 files changed

Lines changed: 351 additions & 128 deletions

File tree

DESCRIPTION

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,16 @@ Imports:
2121
rentrez,
2222
xml2,
2323
utils
24-
Suggests:
24+
Suggests:
2525
BiocGenerics,
2626
Biobase,
2727
stats,
2828
dplyr,
2929
stringr,
3030
R.utils,
31+
ellmer,
32+
duckdb,
33+
querychat,
3134
testthat (>= 3.0.0),
3235
knitr,
3336
rmarkdown

NAMESPACE

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ export("rcd_type<-")
2323
export(accession)
2424
export(columns)
2525
export(datatable)
26+
export(geo_chat)
2627
export(geo_gtype)
2728
export(geo_matrix)
2829
export(geo_meta)
30+
export(geo_qc)
2931
export(geo_search)
32+
export(geo_shiny)
3033
export(geo_show)
3134
export(geo_soft)
3235
export(geo_suppl)

R/geo-querychat.R

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#' Chat with GEO metadata using natural language
2+
#'
3+
#' Create a [`QueryChat`][querychat::QueryChat] object for exploring GEO
4+
#' metadata with an LLM. Use `geo_qc()` to create the chat object, `geo_shiny()`
5+
#' to launch the Shiny app, and `geo_chat()` to start a console chat.
6+
#'
7+
#' @inheritParams querychat::querychat
8+
#' @param data_source A data.frame or a database connection containing GEO
9+
#' metadata, typically from [geo_meta()] or [geo_search()].
10+
#' @inheritDotParams querychat::querychat -extra_instructions -prompt_template
11+
#' @param instructions Optional single string with additional instructions to
12+
#' append to the default GEO metadata assistant instructions.
13+
#'
14+
#' @return A [`QueryChat`][querychat::QueryChat] object configured with
15+
#' `data_source`, an LLM client, and GEO-specific instructions.
16+
#'
17+
#' @details
18+
#' `geo_qc()` intentionally does not serialize all rows or build a large data
19+
#' prompt. Instead, it delegates schema summarization, SQL querying, and
20+
#' dashboard filtering to [`QueryChat`][querychat::QueryChat].
21+
#'
22+
#' The three exported helpers differ only in how far they take the
23+
#' [`QueryChat`][querychat::QueryChat] workflow:
24+
#'
25+
#' * `geo_qc()` creates and returns the `QueryChat` object. Use it when you want
26+
#' to inspect the generated prompt, customize the object, embed it in another
27+
#' Shiny app, or launch the app/console later with `qc$app()` or
28+
#' `qc$console()`.
29+
#' * `geo_shiny()` creates the same `QueryChat` object and immediately launches
30+
#' its Shiny app. Use it for interactive browser-based filtering and
31+
#' exploration.
32+
#' * `geo_chat()` creates the same `QueryChat` object and immediately starts
33+
#' its console chat. Use it for command-line exploration without opening a
34+
#' Shiny app.
35+
#'
36+
#' The default instructions guide the assistant to query and filter GEO
37+
#' metadata, identify relevant studies, generate reproducible R code when
38+
#' asked, preserve explicit accession IDs, and explain GEO accession types
39+
#' (`GSE`, `GSM`, `GPL`, and `GDS`) when useful.
40+
#'
41+
#' The first argument is the LLM client. Use `client = NULL` or pass `NULL` as
42+
#' the first positional argument to let `querychat` choose a client from its
43+
#' options or environment variables. Additional context such as
44+
#' `data_description`, `greeting`, `tools`, `categorical_threshold`, and
45+
#' `cleanup` can be passed through `...` to [querychat::querychat()].
46+
#' `prompt_template` is intentionally not forwarded because `geo_qc()` supplies
47+
#' GEO-specific instructions through `extra_instructions`.
48+
#'
49+
#' @examples
50+
#' if (requireNamespace("querychat", quietly = TRUE) &&
51+
#' requireNamespace("duckdb", quietly = TRUE)) {
52+
#' records <- data.frame(
53+
#' Accession = c("GSE1", "GSE2"),
54+
#' Title = c("human diabetes study", "mouse liver study"),
55+
#' Type = c("Expression profiling by array", "RNA-seq"),
56+
#' Samples = c(12L, 8L)
57+
#' )
58+
#' qc <- geo_qc(NULL, records, table_name = "geo_records", cleanup = TRUE)
59+
#' qc$cleanup()
60+
#' }
61+
#' @seealso [geo_meta()], [geo_search()], [QueryChat][querychat::QueryChat],
62+
#' [ellmer::chat_openai()]
63+
#' @export
64+
geo_qc <- function(client, data_source, table_name = NULL, ...,
65+
instructions = NULL) {
66+
rlang::check_installed("querychat")
67+
assert_string(instructions, allow_null = TRUE)
68+
extra_instructions <- geo_qc_instructions(instructions)
69+
if (is.null(table_name)) {
70+
if (is.data.frame(data_source) ||
71+
inherits(data_source, "tbl_sql")) {
72+
table_name <- deparse1(substitute(data_source))
73+
}
74+
}
75+
querychat::querychat(
76+
data_source = data_source,
77+
table_name = table_name,
78+
client = client,
79+
extra_instructions = extra_instructions,
80+
...,
81+
prompt_template = NULL
82+
)
83+
}
84+
85+
#' @export
86+
#' @rdname geo_qc
87+
geo_shiny <- function(...) {
88+
qc <- geo_qc(...)
89+
qc$app()
90+
}
91+
92+
#' @export
93+
#' @rdname geo_qc
94+
geo_chat <- function(...) {
95+
qc <- geo_qc(...)
96+
qc$console()
97+
}
98+
99+
geo_qc_instructions <- function(instructions = NULL) {
100+
prompt <- paste(
101+
"You are a bioinformatics helper for GEO (Gene Expression Omnibus) metadata.",
102+
"Use the querychat data context and tools to query and filter the metadata.",
103+
"Help identify relevant studies, samples, platforms, and datasets.",
104+
"Generate clear R code when the user asks for reproducible filtering or analysis steps.",
105+
"When generating R code, use backticks around non-syntactic column names such as names containing spaces, punctuation, or slashes.",
106+
"Explain GEO accession types when useful: GSE for series, GSM for samples, GPL for platforms, and GDS for curated datasets.",
107+
"When answering from the metadata, prefer explicit accession IDs and avoid guessing beyond the available data.",
108+
sep = "\n"
109+
)
110+
if (is.null(instructions)) {
111+
return(prompt)
112+
}
113+
paste(
114+
prompt,
115+
"Additional instructions:", instructions,
116+
sep = "\n\n"
117+
)
118+
}

_pkgdown.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ navbar:
1313
href: articles/geo-search.html
1414
- text: GEO metadata
1515
href: articles/geometadb.html
16+
- text: Chat with GEO metadata
17+
href: articles/geo-querychat.html
1618
- text: -------
1719
- text: GEO SOFT FIle
1820
href: articles/geo-soft.html
@@ -31,6 +33,7 @@ reference:
3133
contents:
3234
- geo_search
3335
- geo_meta
36+
- geo_qc
3437
- geo_soft
3538
- geo_matrix
3639
- geo_suppl

configure

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/usr/bin/env sh
1+
#!/bin/sh
22

33
# ~/.cargo/bin to PATH to address such cases. Note that is not always available
44
# (e.g. or on Ubuntu with Rust installed via APT).

configure.win

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#!/bin/sh
2+
13
# Even when `cargo` is on `PATH`, `rustc` might not in some cases. This adds
24
# ~/.cargo/bin to PATH to address such cases. Note that is not always available
35
# (e.g. or on Ubuntu with Rust installed via APT).

man/geo_qc.Rd

Lines changed: 129 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/rust/src/geo/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ pub enum GEOParseError {
1212
#[error("'entity' must be provided before building")]
1313
RequireEntity,
1414

15+
#[error("'format' must be provided before building")]
16+
RequireFormat,
17+
1518
#[error("{gtype} never own {format} file.")]
1619
UnavailableFTPFormat {
1720
gtype: GEOType,

src/rust/src/geo/resolver/ftp.rs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ pub struct GEOFTPResolver {
1616
impl GEOFTPResolver {
1717
#[allow(dead_code)]
1818
#[inline]
19-
pub fn new(entity: GEOEntity) -> Self {
20-
Self::builder().entity(entity).build().unwrap()
19+
pub fn new(entity: GEOEntity, format: GEOFTPFormat) -> Result<Self, GEOParseError> {
20+
let mut builder = Self::builder();
21+
builder.entity(entity);
22+
builder.format(format);
23+
builder.build()
2124
}
2225

2326
#[allow(dead_code)]
@@ -175,14 +178,10 @@ impl GEOFTPResolverBuilder {
175178
.as_ref()
176179
.map_or_else(|| Err(GEOParseError::RequireEntity), |v| Ok(v.clone()))?;
177180

178-
// Default to SOFT files for all GEO types except GSM, which only provides SUPPL files.
179181
let format = if let Some(ref f) = self.format {
180182
f.clone()
181183
} else {
182-
match entity.gtype() {
183-
GEOType::Samples => GEOFTPFormat::Suppl,
184-
_ => GEOFTPFormat::SOFT,
185-
}
184+
return Err(GEOParseError::RequireFormat);
186185
};
187186

188187
// check format is valid
@@ -203,9 +202,9 @@ impl GEOFTPResolverBuilder {
203202
| GEOFTPFormat::Suppl,
204203
)
205204
| (GEOType::Samples, GEOFTPFormat::Suppl) => {}
206-
_ => {
205+
(gtype, _) => {
207206
return Err(GEOParseError::UnavailableFTPFormat {
208-
gtype: entity.gtype().clone(),
207+
gtype: gtype.clone(),
209208
format,
210209
});
211210
}

0 commit comments

Comments
 (0)