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
176 changes: 118 additions & 58 deletions cyteonto/cyteonto.py

Large diffs are not rendered by default.

194 changes: 103 additions & 91 deletions cyteonto/describe.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,59 +131,66 @@ def _build_prompt(label: str, use_pubmed: bool) -> str:
if use_pubmed
else "Do not call any tools."
)
return dedent(
f"""
Task: produce a structured description of one cell type.

Input label:
{label}

The input label may be a single name, an abbreviation, or multiple
synonyms separated by semicolons. Treat them as referring to the same
cell type. If the label is ambiguous, choose the most likely
interpretation in a standard mammalian reference context and commit
to it throughout the response.

{tool_block}

Fill every field below. Write each field in third person, factual,
present tense. Do not add text outside the structured object. Do not
repeat the label text verbatim inside the other fields unless it is
the only correct wording.

initialLabel
Copy the input label verbatim, including punctuation and
semicolons. Do not normalise case, expand abbreviations, or
strip synonyms.

descriptiveName
One noun phrase, at most 12 words. A precise name that identifies
the cell type without relying on the initial label. Prefer lineage
and location when available (for example "tissue-resident alveolar
macrophage").

function
One to three sentences, at most 60 words total. State the primary
biological role of the cell. Mention key effector mechanisms only
when they are defining for this cell type.

diseaseRelevance
One to three sentences, at most 60 words total. State conditions
in which this cell type is pathologically involved, depleted,
expanded, or is a therapeutic target. If the cell type has no
well-established disease relevance, write "Not established".

developmentalStage
One sentence, at most 30 words. State the lineage, progenitor, or
developmental window the cell belongs to. Use "Terminally
differentiated" when applicable. Use "Not established" when the
stage is unknown.

Do not include marker genes, tissue lists, species ranges, or
citations inside any field. Do not hedge with phrases such as
"it is believed that" or "some studies suggest". State facts directly.
"""
).strip()
return (
dedent(
"""
Task: produce a structured description of one cell type.

Input label:
{label}

The input label may be a single name, an abbreviation, or multiple
synonyms separated by semicolons. Treat them as referring to the same
cell type. If the label is ambiguous, choose the most likely
interpretation in a standard mammalian reference context and commit
to it throughout the response.

{tool_block}

Fill every field below. Write each field in third person, factual,
present tense. Do not add text outside the structured object. Do not
repeat the label text verbatim inside the other fields unless it is
the only correct wording.

initialLabel
Copy the input label verbatim, including punctuation and
semicolons. Do not normalise case, expand abbreviations, or
strip synonyms.

descriptiveName
One noun phrase, at most 12 words. A precise name that identifies
the cell type without relying on the initial label. Prefer lineage
and location when available (for example "tissue-resident alveolar
macrophage").

function
One to three sentences, at most 60 words total. State the primary
biological role of the cell. Mention key effector mechanisms only
when they are defining for this cell type.

diseaseRelevance
One to three sentences, at most 60 words total. State conditions
in which this cell type is pathologically involved, depleted,
expanded, or is a therapeutic target. If the cell type has no
well-established disease relevance, write "Not established".

developmentalStage
One sentence, at most 30 words. State the lineage, progenitor, or
developmental window the cell belongs to. Use "Terminally
differentiated" when applicable. Use "Not established" when the
stage is unknown.

Do not include marker genes, tissue lists, species ranges, or
citations inside any field. Do not hedge with phrases such as
"it is believed that" or "some studies suggest". State facts directly.
"""
)
.format(
label=label,
tool_block=tool_block,
)
.strip()
)


def _build_descriptor_agent(
Expand Down Expand Up @@ -381,51 +388,56 @@ def _normalize_decomposition(
return LabelDecomposition.single(label)
if not output.isCompound:
return LabelDecomposition.single(label)
cleaned = [p.strip() for p in output.parts if p and p.strip()]
cleaned = [p.strip().lower() for p in output.parts if p and p.strip()]
if len(cleaned) < 2:
return LabelDecomposition.single(label)
return LabelDecomposition(initialLabel=label, isCompound=True, parts=cleaned)


def _build_decompose_prompt(label: str) -> str:
return dedent(
f"""
Task: decide whether a cell-type annotation label refers to one cell
type or multiple distinct cell types.

Input label:
{label}

Context: labels come from single-cell RNA-seq cluster annotations.
Some labels name a single cell type; others name a mixture such as a
doublet or mixed population.

Rules:
- Semicolon-separated text lists synonyms for one cell type. Do not
split those.
- Commas and hyphens inside a single cell-type name do not indicate
multiple types.
- When the label names multiple distinct cell types, set isCompound to
true and list each type in parts as a short sanitized name suitable
for ontology lookup. Remove mixture qualifiers such as "doublet",
"mixed population", or "contamination" from part names.
- When the label names one cell type, set isCompound to false and set
parts to a list containing only the original label verbatim.

initialLabel
Copy the input label exactly as given.

isCompound
true when the label names multiple distinct cell types; false
otherwise.

parts
When isCompound is false, must contain exactly one element equal
to initialLabel.
When isCompound is true, must contain two or more sanitized
cell-type names.
return (
dedent(
"""
Task: decide whether a cell-type annotation label refers to one cell
type or multiple distinct cell types.

Input label:
{label}

Context: labels come from single-cell RNA-seq cluster annotations.
Some labels name a single cell type; others name a mixture such as a
doublet or mixed population.

Rules:
- Semicolon-separated text lists synonyms for one cell type. Do not
split those.
- Commas and hyphens inside a single cell-type name do not indicate
multiple types.
- When the label names multiple distinct cell types, set isCompound to
true and list each type in parts as a short sanitized name suitable
for ontology lookup. Remove mixture qualifiers such as "doublet",
"mixed population", or "contamination" from part names.
- When isCompound is true, return every part name in lowercase.
- When the label names one cell type, set isCompound to false and set
parts to a list containing only the original label verbatim.

initialLabel
Copy the input label exactly as given.

isCompound
true when the label names multiple distinct cell types; false
otherwise.

parts
When isCompound is false, must contain exactly one element equal
to initialLabel.
When isCompound is true, must contain two or more sanitized
cell-type names.
"""
).strip()
)
.format(label=label)
.strip()
)


def _build_decompose_agent(base_agent: Agent, reasoning: bool = False) -> Agent:
Expand Down
43 changes: 37 additions & 6 deletions cyteonto/ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,39 @@ def load(self) -> bool:
return True
try:
df = pd.read_csv(self.csv_path)
if "label_normalized" not in df.columns:
df["label_normalized"] = df["label"].astype(str).str.lower()
else:
df["label_normalized"] = df["label_normalized"].astype(str).str.lower()
dup_mask = df.duplicated(
subset=["ontology_id", "label_normalized"], keep=False
)
if dup_mask.any():
for (oid, norm), grp in df[dup_mask].groupby(
["ontology_id", "label_normalized"]
):
originals = grp["label"].astype(str).tolist()
logger.warning(
f"Normalized label collision for {oid} {norm!r}: "
f"original labels {originals}; keeping {originals[0]!r}"
)
before = len(df)
df = df.drop_duplicates(
subset=["ontology_id", "label_normalized"], keep="first"
)
dropped = before - len(df)
if dropped:
logger.info(
f"Dropped {dropped} duplicate ontology rows "
"(same ontology_id and label_normalized)"
)
self._df = df
for _, row in df.iterrows():
label = str(row["label"])
norm = str(row["label_normalized"])
original = str(row["label"])
oid = str(row["ontology_id"])
self._id_to_labels.setdefault(oid, []).append(label)
self._label_to_id.setdefault(label, oid)
self._id_to_labels.setdefault(oid, []).append(original)
self._label_to_id.setdefault(norm, oid)
self._loaded = True
logger.info(f"Loaded {len(df)} ontology mappings from {self.csv_path}")
return True
Expand All @@ -49,7 +76,7 @@ def df(self) -> pd.DataFrame:
def label_to_id(self, label: str) -> str | None:
if not self._loaded:
self.load()
return self._label_to_id.get(label)
return self._label_to_id.get(label.lower())

def labels_for_id(self, ontology_id: str) -> list[str]:
if not self._loaded:
Expand All @@ -61,8 +88,12 @@ def ids_and_joined_labels(self) -> tuple[list[str], list[str]]:
if not self._loaded:
self.load()
assert self._df is not None
grouped = self._df.groupby("ontology_id")["label"].apply(";".join).reset_index()
return grouped["ontology_id"].tolist(), grouped["label"].tolist()
grouped = (
self._df.groupby("ontology_id")["label_normalized"]
.apply(lambda s: ";".join(dict.fromkeys(s.astype(str))))
.reset_index()
)
return grouped["ontology_id"].tolist(), grouped["label_normalized"].tolist()


class OntologySimilarity:
Expand Down
14 changes: 14 additions & 0 deletions cyteonto/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class PathConfig:
Layout produced under ``data_dir``::

cell_ontology/cell_to_cell_ontology.csv
cell_ontology/cell_to_cell_ontology_enriched.csv
cell_ontology/cl.owl
embedding/cell_ontology/embeddings_<llmKey>_<embdKey>.npz
embedding/descriptions/descriptions_<llmKey>.json
Expand All @@ -43,6 +44,18 @@ def __init__(
def ontology_csv(self) -> Path:
return self.data_dir / "cell_ontology" / "cell_to_cell_ontology.csv"

@property
def ontology_enriched_csv(self) -> Path:
return self.data_dir / "cell_ontology" / "cell_to_cell_ontology_enriched.csv"

@property
def ontology_mapping_csv(self) -> Path:
"""CSV used for lookups: enriched file when present, else shipped original."""
enriched = self.ontology_enriched_csv
if enriched.exists():
return enriched
return self.ontology_csv

@property
def ontology_owl(self) -> Path:
return self.data_dir / "cell_ontology" / "cl.owl"
Expand Down Expand Up @@ -113,5 +126,6 @@ def run_kind_dirs(self, run_id: str, kind: str) -> list[Path]:
def core_files_present(self) -> dict[str, bool]:
return {
"ontology_csv": self.ontology_csv.exists(),
"ontology_enriched_csv": self.ontology_enriched_csv.exists(),
"ontology_owl": self.ontology_owl.exists(),
}
Loading
Loading