"""dataset planning + metadata-inspection tools.""" # ruff: noqa: F403, F405, F722 (star re-export from ._base; F722 = Annotated false-positive) from ._base import * # noqa: F401 from ._base import ( # noqa: F401 _build_loading_plan, _classify_metadata_values, _col_semantics, _detect_intent, _flatten_single_column_summary, _load_metadata_df, _workflow_for_intent, ) @dataset_mcp.tool def dataset_plan_analysis( dataset_id: Annotated[ str, "The dataset identifier (e.g. 'gse71729_moffitt'). " "Call dataset_list_available() first if unsure. " "Used to populate recommended tool arguments from the manifest.", ], user_question: Annotated[ str, "The user's analysis question in plain language. Examples: " "'Compare Classical vs Basal tumors for TF activity', " "'Which samples have high TGFb pathway activity?', " "'Is HIF1A activity associated with survival?'", ], ) -> dict: """ Return a recommended workflow for a dataset and a plain-language question. Call this when: - The user states a biological question and you need to choose which tools to run and in what order. - You want to surface required inputs, assumptions, and unimplemented steps before starting. - You need to flag that part of a requested analysis is not yet available. This tool does NOT run any analysis — it returns a plan only. Execute the plan by calling the listed tools in order. Detected intent classes ----------------------- metadata_summary Keywords: how many, count, distribution, breakdown, value counts, what groups, what subtypes → dataset_interpret_metadata → dataset_count_metadata_values compare_groups Keywords: compare, differ, vs, versus, higher/lower in, upregulated, differential → dataset_describe → load → validate_contrast → decoupler_differential_expression → enrichment tools score_samples Keywords: activity, score, which samples, sample-level, per sample, pathway activity, TF activity → dataset_score_bulk_samples → dataset_compare_activity_by_group (optional) survival Keywords: survival, prognosis, KM, Cox, time to event → dataset_score_bulk_samples → survival (not implemented) correlate_continuous Keywords: correlate, associated with, continuous, covariate → dataset_score_bulk_samples → associate_activity_with_covariate (not implemented) unknown No keyword match → generic orientation steps + warning """ manifest: Any = None manifest_warning: str | None = None try: manifest = load_manifest(dataset_id) except KeyError: available = [d["dataset_id"] for d in list_available_datasets()] manifest_warning = ( f"Dataset '{dataset_id}' not found in registry. " f"Available datasets: {available}. " "Plan uses generic placeholders (no manifest-specific args)." ) intent, confidence, matched_kw = _detect_intent(user_question) workflow = _workflow_for_intent(intent, dataset_id, manifest) if manifest_warning: workflow["warnings"] = [manifest_warning] + workflow["warnings"] return { "dataset_id": dataset_id, "user_question": user_question, "detected_intent": intent, "confidence": confidence, "matched_keywords": matched_kw, "recommended_tools": workflow["steps"], "required_inputs": workflow["required_inputs"], "assumptions": workflow["assumptions"], "warnings": workflow["warnings"], "refusal_conditions": workflow["refusal_conditions"], } # --------------------------------------------------------------------------- # Metadata interpretation and dynamic counting tools # --------------------------------------------------------------------------- @dataset_mcp.tool def dataset_interpret_metadata( adata_path: Annotated[ str, "Path to a loaded h5ad file containing the dataset's obs metadata.", ], dataset_id: Annotated[ str, "Dataset identifier from the registry (e.g. 'gse71729_moffitt').", ], ) -> dict: """ Return the semantic interpretation guide for each metadata column declared in the manifest. Call this when: - A user asks what columns or groups are in the dataset. - You need to understand what values are biologically meaningful vs missing. - You need to know which columns may be used for grouping and which are technical-only. - You need to surface refusal rules before proceeding with analysis. This tool reads column semantics from the manifest — it does NOT compute counts. Use dataset_count_metadata_values to compute actual value counts from the loaded data. """ try: manifest = load_manifest(dataset_id) except KeyError: return {"error": f"Dataset '{dataset_id}' not found in registry."} if not manifest.metadata_columns: return { "dataset_id": dataset_id, "message": "No metadata_columns defined in this manifest. " "Use dataset_list_valid_sample_groups to survey the data directly.", "metadata_columns": {}, } columns_guide = {} for col_name, col_def in manifest.metadata_columns.items(): if hasattr(col_def, "to_dict"): defn = col_def.to_dict() else: defn = dict(col_def) columns_guide[col_name] = { "role": defn.get("role"), "biological_grouping_allowed": defn.get("biological_grouping_allowed", True), "check_column": defn.get("decoded_column") or col_name, "source_column": defn.get("source_column"), "allowed_values": defn.get("allowed_values", []), "missing_values": defn.get("missing_values", []), "empty_value_meaning": defn.get("empty_value_meaning", ""), "code_map": defn.get("code_map", {}), "interpretation_warning": defn.get("interpretation_warning", ""), "reporting_rules": defn.get("reporting_rules", []), "refusal_rules": defn.get("refusal_rules", []), } return { "dataset_id": dataset_id, "adata_path": adata_path, "dataset_reporting_rules": manifest.reporting_rules, "dataset_refusal_rules": manifest.refusal_rules, "metadata_columns": columns_guide, "next_step": ( "Call dataset_count_metadata_values to compute actual value counts " "from the loaded data applying these semantics." ), } @dataset_mcp.tool def dataset_count_metadata_values( adata_path: Annotated[ str, "Path to a loaded h5ad file. Counts are computed from this file's obs.", ], dataset_id: Annotated[ str, "Dataset identifier used to load manifest semantics.", ], column: Annotated[ str | None, "Specific column name to count. If None, counts all metadata_columns " "declared in the manifest.", ] = None, ) -> dict: """ Compute value counts dynamically from the loaded AnnData obs, applying the manifest's missing-value semantics to classify each value as annotated, missing, or unexpected. Call this when: - A user asks how many Classical or Basal samples there are. - You need to show a subtype breakdown or group distribution. - You need to know how many samples lack annotation before subsetting. Counts are computed from the actual data — they are NOT read from the manifest. The manifest provides semantics (which values are missing, which are allowed) to interpret the raw counts. """ from collections import Counter as _Counter from pathlib import Path import scanpy as sc from src.core.data_io import resolve_to_local_path # Resolve local paths AND remote URLs (e.g. private pdac-research-data h5ads) # to a readable local file, authenticating via HF_TOKEN for private # huggingface.co /resolve/ URLs. Without this, an https URL was treated as a # local path (Path.exists() == False) and wrongly reported "File not found". try: local_path, is_temp = resolve_to_local_path(adata_path) except Exception as e: return {"error": f"Could not resolve {adata_path}: {e}"} if not is_temp and not Path(local_path).exists(): return {"error": f"File not found: {adata_path}"} try: adata = sc.read_h5ad(local_path) except Exception as e: return {"error": f"Could not load h5ad: {e}"} finally: if is_temp: try: Path(local_path).unlink() except OSError: pass try: manifest = load_manifest(dataset_id) except KeyError: return {"error": f"Dataset '{dataset_id}' not found in registry."} # Determine which columns to count if manifest.metadata_columns: cols_to_count = ( {column: manifest.metadata_columns[column]} if column and column in manifest.metadata_columns else manifest.metadata_columns ) else: # No manifest semantics — fall back to counting all obs columns cols_to_count = {c: {} for c in adata.obs.columns} summaries = {} for col_name, col_def in cols_to_count.items(): sem = _col_semantics(col_def, col_name) missing_set = sem["missing_set"] allowed_set = sem["allowed_set"] bio_ok = sem["bio_ok"] check_col = sem["check_col"] interp_warn = sem["interp_warn"] col_refusal_rules = sem["refusal_rules"] if check_col not in adata.obs.columns: summaries[col_name] = { "error": f"Column '{check_col}' not found in obs. " f"Available: {list(adata.obs.columns)}", } continue raw_counts = _Counter(adata.obs[check_col].astype(str).tolist()) annotated, missing, unexpected = _classify_metadata_values( raw_counts, missing_set, allowed_set ) total_annotated = sum(annotated.values()) total_missing = sum(missing.values()) total_unexpected = sum(unexpected.values()) total = sum(raw_counts.values()) summaries[col_name] = { "column": check_col, "biological_grouping_allowed": bio_ok, "total": total, "total_annotated": total_annotated, "total_missing": total_missing, "total_unexpected": total_unexpected, "annotated": dict(sorted(annotated.items(), key=lambda x: -x[1])), "missing": dict(sorted(missing.items(), key=lambda x: -x[1])), "unexpected": dict(sorted(unexpected.items(), key=lambda x: -x[1])), "interpretation_warning": interp_warn.strip() if interp_warn else None, "prohibited_inferences": col_refusal_rules, } result: dict = { "dataset_id": dataset_id, "adata_path": adata_path, "n_total_samples": adata.n_obs, "column_summaries": summaries, "dataset_refusal_rules": list(manifest.refusal_rules or []), "next_step": ( "Use subset_query from the manifest's default_contrasts to isolate " "the relevant sample group before running differential expression." ), } # When a single column was requested, add a flattened top-level summary # so callers don't have to navigate column_summaries[col_name]. if column and column in summaries: result.update( _flatten_single_column_summary( summaries[column], cols_to_count.get(column, {}), manifest, column, adata_path, dataset_id, ) ) return result @dataset_mcp.tool def dataset_crosstab_metadata_values( adata_path: Annotated[ str, "Path to a loaded h5ad file.", ], dataset_id: Annotated[ str, "Dataset identifier used to load manifest semantics.", ], row_column: Annotated[ str, "Obs column to use as crosstab rows (e.g. 'tumor_subtype').", ], col_column: Annotated[ str, "Obs column to use as crosstab columns (e.g. 'stroma_subtype').", ], exclude_missing: Annotated[ bool, "If True, drop rows/columns whose values are in the manifest's " "missing_values list. Default True.", ] = True, ) -> dict: """ Cross-tabulate two metadata columns from the loaded AnnData, applying manifest missing-value semantics to optionally exclude unannotated samples. Call this when: - A user asks how tumor subtype and stroma subtype co-occur. - You need a two-way breakdown of sample groups. - You want to check whether two columns are confounded. """ from pathlib import Path import pandas as pd import scanpy as sc from src.core.data_io import resolve_to_local_path # Resolve local paths AND remote URLs (incl. private HF repos via HF_TOKEN). try: local_path, is_temp = resolve_to_local_path(adata_path) except Exception as e: return {"error": f"Could not resolve {adata_path}: {e}"} if not is_temp and not Path(local_path).exists(): return {"error": f"File not found: {adata_path}"} try: adata = sc.read_h5ad(local_path) except Exception as e: return {"error": f"Could not load h5ad: {e}"} finally: if is_temp: try: Path(local_path).unlink() except OSError: pass try: manifest = load_manifest(dataset_id) except KeyError: return {"error": f"Dataset '{dataset_id}' not found in registry."} def _resolve_col(col_name: str) -> tuple[str, set]: """Return (actual_obs_col, missing_values_set).""" col_def = manifest.metadata_columns.get(col_name, {}) if hasattr(col_def, "decoded_column"): obs_col = col_def.decoded_column or col_name missing = set(col_def.missing_values) else: obs_col = col_def.get("decoded_column") or col_name missing = set(col_def.get("missing_values") or []) return obs_col, missing row_obs, row_missing = _resolve_col(row_column) col_obs, col_missing = _resolve_col(col_column) for obs_col in (row_obs, col_obs): if obs_col not in adata.obs.columns: return { "error": f"Column '{obs_col}' not found in obs. " f"Available: {list(adata.obs.columns)}" } obs_df = adata.obs[[row_obs, col_obs]].copy() obs_df[row_obs] = obs_df[row_obs].astype(str) obs_df[col_obs] = obs_df[col_obs].astype(str) if exclude_missing: all_missing = (row_missing | {"nan", "None", ""}) | (col_missing | {"nan", "None", ""}) obs_df = obs_df[~obs_df[row_obs].isin(all_missing) & ~obs_df[col_obs].isin(all_missing)] crosstab = pd.crosstab(obs_df[row_obs], obs_df[col_obs]) return { "dataset_id": dataset_id, "row_column": row_obs, "col_column": col_obs, "exclude_missing": exclude_missing, "n_samples_included": int(len(obs_df)), "n_samples_total": adata.n_obs, "crosstab": { str(row): {str(col): int(val) for col, val in row_data.items()} for row, row_data in crosstab.to_dict(orient="index").items() }, "row_totals": {str(k): int(v) for k, v in crosstab.sum(axis=1).items()}, "col_totals": {str(k): int(v) for k, v in crosstab.sum(axis=0).items()}, } @dataset_mcp.tool def dataset_validate_manifest_against_data( dataset_id: Annotated[ str, "The registered dataset_id to validate (e.g. 'gse71729_moffitt'). " "Call dataset_list_available() first if unsure.", ], adata_path: Annotated[ str, "Path to a loaded .h5ad file for this dataset. " "Load with decoupler_load_geo_series_matrix (or equivalent) first.", ], ) -> dict: """ Validate a loaded dataset against its manifest declaration. Call this tool: - After loading a dataset for the first time, before running any analysis. - When adding a new dataset to confirm the manifest is correct. - When you see unexpected metadata values or data type errors during analysis. Five checks are run: data_level — detected expression type vs. manifest declaration. feature_id_type — var.index format (gene symbols, Ensembl, probe IDs). metadata_columns — declared columns present; values within allowed set. group_columns — each group column exists with ≥2 usable groups. default_contrasts — each contrast has ≥3 samples per group after subset. Interpreting the result: - overall_valid=True, n_warnings=0 → proceed directly to analysis. - overall_valid=True, n_warnings>0 → safe to proceed; review warnings first. - overall_valid=False → fix errors before running DE or enrichment. Next steps: - If valid: call dataset_plan_analysis(dataset_id, user_question) to start. - If errors: read the errors list and update the manifest or re-load the data. - If metadata decoder warning: re-run decoupler_load_geo_series_matrix with correct condition_column to trigger numeric decoding. """ from pathlib import Path as _Path import numpy as np import scanpy as sc from src.core.data_io import resolve_to_local_path from src.workflows.manifest_data_validation import validate_manifest_against_data # Resolve local paths AND remote URLs (incl. private HF repos via HF_TOKEN). try: local_path, _is_temp = resolve_to_local_path(adata_path) except Exception as e: return {"error": f"Could not resolve {adata_path}: {e}"} try: adata = sc.read_h5ad(local_path) except Exception as e: return {"error": f"Could not load h5ad at '{adata_path}': {e}"} finally: if _is_temp: try: _Path(local_path).unlink() except OSError: pass try: manifest = load_manifest(dataset_id) except KeyError as e: return {"error": str(e)} # Sample expression matrix (cap at 50 000 values for speed) X = adata.X if hasattr(X, "toarray"): X = X.toarray() X_flat = X.flatten() if len(X_flat) > 50_000: rng = np.random.default_rng(0) X_flat = rng.choice(X_flat, size=50_000, replace=False) var_index = list(adata.var.index) obs_df = adata.obs.copy() return validate_manifest_against_data(X_flat, var_index, obs_df, manifest)