From dd733037cbc11f3093ecaecacaf6398c51d5b10c Mon Sep 17 00:00:00 2001 From: AzizbekMuminoff Date: Sat, 19 Sep 2026 18:40:28 -0700 Subject: [PATCH 01/11] feat: add flow cytometry referee agent --- agents/flow-cytometry-referee/README.md | 64 ++++++++++ agents/flow-cytometry-referee/agent.yaml | 60 +++++++++ agents/flow-cytometry-referee/metadata.yaml | 20 +++ .../tools/flow-qc/Dockerfile | 36 ++++++ .../tools/flow-qc/flow_qc_utils.py | 115 ++++++++++++++++++ .../tools/flow-qc/tool.yaml | 35 ++++++ 6 files changed, 330 insertions(+) create mode 100644 agents/flow-cytometry-referee/README.md create mode 100644 agents/flow-cytometry-referee/agent.yaml create mode 100644 agents/flow-cytometry-referee/metadata.yaml create mode 100644 agents/flow-cytometry-referee/tools/flow-qc/Dockerfile create mode 100644 agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py create mode 100644 agents/flow-cytometry-referee/tools/flow-qc/tool.yaml diff --git a/agents/flow-cytometry-referee/README.md b/agents/flow-cytometry-referee/README.md new file mode 100644 index 00000000..eeabfb23 --- /dev/null +++ b/agents/flow-cytometry-referee/README.md @@ -0,0 +1,64 @@ +# Flow Cytometry Referee + +Flow Cytometry Referee reviews standard FCS flow-cytometry files and supporting analysis material for technical +quality and reproducibility. It reports observed measurement and metadata evidence, missing controls, and gating +documentation gaps. It is designed for research QC and is not a clinical, diagnostic, regulatory, or assay-validation tool. + +## Overview + +Researchers often receive event files, a gating figure, and partial panel documentation from different systems. This +agent makes the technical review explicit: it inventories evidence, measures file-level QC facts, and identifies what +must be supplied before a gating workflow can be considered reproducible. + +## Architecture + +The prompt agent uses a parameterized Foundry chat model and the `flow-qc` Python environment. The environment reads +FCS events with `flowio`, calculates deterministic channel and acquisition summaries, and can calculate an optional +Isolation Forest outlier fraction. The agent combines those results with supplied SOPs, gating plans, and control +inventories. The outlier score flags unusual measurement events only; it does not classify cells or samples. + +## Prerequisites + +- An Azure subscription with Microsoft Discovery deployment permissions. +- An Azure AI Foundry project and a chat-model deployment for `{{CHAT-MODEL}}`. +- A container image built from `tools/flow-qc/Dockerfile` and published to the deployment ACR. +- Standard FCS files and, for a complete review, the acquisition template, panel specification, gating plan, and + single-stain, FMO, viability, and relevant biological controls. + +## Configuration + +| Parameter | Description | Example | +| --- | --- | --- | +| `{{CHAT-MODEL}}` | Foundry chat-model deployment | `gpt-4o-deployment` | +| `{{flowQcToolId}}` | Deployed Discovery tool ARM resource ID | `/subscriptions/.../tools/flow-qc` | + +## Usage + +1. Deploy the agent and the `flow-qc` tool through Discovery. +2. Supply one or more FCS files and the available supporting documents. +3. Ask for a technical review, for example: “Review this FCS file and gating plan for compensation evidence, control + coverage, acquisition integrity, and reproducibility gaps.” +4. Review PASS, WARN, NEEDS EVIDENCE, and FAIL findings with a qualified flow-cytometry scientist. + +## Tools + +`flow-qc` provides a Python code environment with `flowio`, NumPy, pandas, scikit-learn, and `flow_qc_utils`. +It can read standard FCS files, summarize event and channel values, inspect compensation-related metadata, evaluate a +time channel when present, and calculate an unsupervised event-outlier fraction. Derived reports belong in the +configured output directory. + +## Known Limitations + +- It does not inspect microscopy images or imaging flow-cytometry imagery. +- It cannot prove compensation is correct from FCS metadata alone. +- It does not create, validate, or optimize gates without a documented gating plan and appropriate controls. +- It must not be used for diagnosis, treatment, patient triage, or regulatory release decisions. +- The unsupervised outlier metric is sensitive to panel design and batch effects and is not a trained classifier. + +## Support + +For issues or questions, open an issue at https://github.com/AzizMuminov/discovery/issues. + +## Contributing + +See [CONTRIBUTING.md](../../CONTRIBUTING.md) for the catalog contribution workflow. diff --git a/agents/flow-cytometry-referee/agent.yaml b/agents/flow-cytometry-referee/agent.yaml new file mode 100644 index 00000000..5d766373 --- /dev/null +++ b/agents/flow-cytometry-referee/agent.yaml @@ -0,0 +1,60 @@ +kind: prompt +name: FlowCytometryReferee +displayName: Flow Cytometry Referee +description: > + An evidence-based technical reviewer for FCS quality, compensation, controls, and proposed gating plans. +model: + id: '{{CHAT-MODEL}}' + options: + temperature: 0 + maxOutputTokens: 4096 +instructions: | + You are the Flow Cytometry Referee, a technical quality-control agent for research flow-cytometry analysis. + + Your job is to evaluate the reproducibility and technical adequacy of an FCS dataset and, when supplied, a + gating plan, panel description, control inventory, or laboratory SOP. You do not diagnose disease, classify + patients, recommend treatment, or claim that a dataset is biologically valid. Your findings concern only the + supplied data and documented analysis process. + + ## Operating rules + - Invoke only one tool at a time and wait for its result before the next tool call. + - For every FCS file, run the flow-qc Python environment before drawing conclusions about event count, + channels, compensation metadata, non-finite values, saturation, time ordering, or outlier score. + - Treat a missing document, control, or metadata field as missing evidence, never as proof of an error. + - Do not infer cell type, disease state, treatment effect, or sample identity from fluorescence values. + - Do not use the unsupervised outlier score as a classifier. It identifies unusual measurement events only. + - State panel- and instrument-specific assumptions explicitly. Ask for the acquisition template, gating plan, + and single-stain/FMO controls when their absence prevents a reliable review. + + ## Review sequence + 1. Inventory the supplied FCS files and supporting material. + 2. Run a QC script with flow_qc_utils.load_fcs and flow_qc_utils.build_qc_report. + 3. Compare available evidence with this minimum technical checklist: + - event count and acquisition-time integrity; + - compensation or spillover metadata and single-stain controls; + - viability, singlet, debris, and relevant fluorescence-minus-one controls; + - documented gate order, parent populations, thresholds, and counts; + - transformations, exclusions, and batch/sample identifiers; + - reproducible output files and analysis software/version. + 4. Return findings grouped as PASS, WARN, NEEDS EVIDENCE, or FAIL. Use FAIL only for a directly observed, + technically invalid condition such as an unreadable FCS file, no events, or non-finite event values that + make the requested analysis impossible. + 5. Produce a concise remediation plan ordered by impact. Preserve the original data; write any derived JSON, + CSV, and figures to the output directory. + + ## Required response format + - Scope and supplied evidence + - QC metrics observed from the FCS file + - Findings, each with severity, evidence, and remediation + - Missing evidence and the exact document or control needed + - Reproducibility checklist + - Limitations + + Never present this report as clinical, regulatory, or diagnostic validation. +discoveryExtensions: + humanInTheLoop: Disabled + tools: + - toolId: '{{flowQcToolId}}' + confirmation: Disabled + disableDataHandlingTools: false + disableDiscoveryInjectedTools: false diff --git a/agents/flow-cytometry-referee/metadata.yaml b/agents/flow-cytometry-referee/metadata.yaml new file mode 100644 index 00000000..2b69b10a --- /dev/null +++ b/agents/flow-cytometry-referee/metadata.yaml @@ -0,0 +1,20 @@ +name: flow-cytometry-referee +type: agent +version: 1.0.0 +associated_tools: + - agents/flow-cytometry-referee/tools/flow-qc +publisher: + name: Aziz Muminov + contact: azizmuminov@users.noreply.github.com + support_url: https://github.com/AzizMuminov/discovery/issues + party: 3p +description: > + Reviews flow-cytometry FCS data and proposed gating plans for technical quality, compensation evidence, + control coverage, and reproducibility. Produces an evidence-backed QC report; it does not diagnose disease + or make clinical decisions. +tags: + - flow-cytometry + - fcs + - quality-control + - single-cell + - bioinformatics diff --git a/agents/flow-cytometry-referee/tools/flow-qc/Dockerfile b/agents/flow-cytometry-referee/tools/flow-qc/Dockerfile new file mode 100644 index 00000000..313ef201 --- /dev/null +++ b/agents/flow-cytometry-referee/tools/flow-qc/Dockerfile @@ -0,0 +1,36 @@ +FROM mcr.microsoft.com/azurelinux/base/python:3.12 + +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV MPLBACKEND=Agg +ENV MPLCONFIGDIR=/tmp/matplotlib +ENV OMP_NUM_THREADS=1 +ENV MKL_NUM_THREADS=1 +ENV OPENBLAS_NUM_THREADS=1 + +RUN tdnf -y update && \ + tdnf -y install ca-certificates shadow-utils && \ + update-ca-trust && \ + tdnf clean all + +RUN pip3 install --no-cache-dir \ + flowio \ + matplotlib \ + numpy \ + pandas \ + scikit-learn \ + scipy + +RUN mkdir -p /app /workdir /input /output /tmp/matplotlib && \ + chmod 755 /app /workdir /input /output + +COPY flow_qc_utils.py /app/flow_qc_utils.py + +RUN groupadd -r appuser && useradd -r -g appuser appuser && \ + chown -R appuser:appuser /app /workdir /input /output /tmp/matplotlib + +USER appuser +WORKDIR /workdir +ENV PYTHONPATH=/app:${PYTHONPATH} + +CMD ["/bin/bash"] diff --git a/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py b/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py new file mode 100644 index 00000000..2c4c8e5c --- /dev/null +++ b/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py @@ -0,0 +1,115 @@ +"""Utilities for technical quality review of standard FCS flow-cytometry files. + +The functions in this module intentionally report measurement and metadata facts. +They do not assign cell types, make clinical predictions, or validate a laboratory assay. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +from flowio import FlowData +from sklearn.ensemble import IsolationForest + + +def load_fcs(path: str | Path) -> tuple[dict[str, Any], pd.DataFrame]: + """Read an FCS file into metadata and an events-by-channel DataFrame.""" + flow_data = FlowData(str(path)) + channel_numbers = sorted(flow_data.channels, key=lambda value: int(value)) + channel_names = [ + flow_data.channels[number].get("PnN") or f"channel_{number}" + for number in channel_numbers + ] + event_count = int(flow_data.event_count) + channel_count = len(channel_names) + events = np.asarray(flow_data.events, dtype=float).reshape(event_count, channel_count) + metadata = {str(key): value for key, value in flow_data.text.items()} + metadata["_channel_ranges"] = { + name: flow_data.channels[number].get("PnR") + for name, number in zip(channel_names, channel_numbers, strict=True) + } + return metadata, pd.DataFrame(events, columns=channel_names) + + +def _compensation_present(metadata: dict[str, Any]) -> bool: + normalized_keys = {key.lower().replace("$", "") for key in metadata} + return any(key in normalized_keys for key in ("spillover", "spill", "compensation", "comp")) + + +def _channel_summary(events: pd.DataFrame, ranges: dict[str, Any]) -> list[dict[str, Any]]: + summaries: list[dict[str, Any]] = [] + for name in events.columns: + values = events[name].to_numpy(dtype=float) + finite = values[np.isfinite(values)] + channel_range = ranges.get(name) + try: + upper_limit = float(channel_range) + except (TypeError, ValueError): + upper_limit = None + saturation_fraction = ( + float(np.mean(finite >= upper_limit)) if upper_limit and finite.size else None + ) + summaries.append( + { + "channel": name, + "finite_fraction": float(np.mean(np.isfinite(values))), + "minimum": float(np.min(finite)) if finite.size else None, + "p01": float(np.percentile(finite, 1)) if finite.size else None, + "median": float(np.median(finite)) if finite.size else None, + "p99": float(np.percentile(finite, 99)) if finite.size else None, + "maximum": float(np.max(finite)) if finite.size else None, + "saturation_fraction": saturation_fraction, + } + ) + return summaries + + +def _outlier_summary(events: pd.DataFrame, random_state: int = 0) -> dict[str, Any]: + """Return an unsupervised event-outlier metric, never a biological classification.""" + numeric = events.select_dtypes(include=[np.number]).replace([np.inf, -np.inf], np.nan).dropna(axis=0) + if len(numeric) < 100 or numeric.shape[1] == 0: + return {"available": False, "reason": "Need at least 100 complete numeric events."} + sample = numeric.iloc[: min(len(numeric), 50_000), : min(numeric.shape[1], 16)] + standardized = (sample - sample.median()) / sample.std(ddof=0).replace(0, 1) + model = IsolationForest(contamination="auto", random_state=random_state, n_estimators=100) + labels = model.fit_predict(standardized) + return { + "available": True, + "events_scored": int(len(sample)), + "channels_scored": list(sample.columns), + "outlier_fraction": float(np.mean(labels == -1)), + "interpretation": "Unusual multichannel measurement events, not cell identities or sample classes.", + } + + +def build_qc_report(metadata: dict[str, Any], events: pd.DataFrame) -> dict[str, Any]: + """Create a JSON-serializable, evidence-only FCS technical QC report.""" + time_columns = [name for name in events.columns if name.lower() in {"time", "acquisition_time"}] + time_integrity: dict[str, Any] = {"available": bool(time_columns)} + if time_columns: + values = events[time_columns[0]].to_numpy(dtype=float) + finite = values[np.isfinite(values)] + time_integrity.update( + { + "channel": time_columns[0], + "nondecreasing_fraction": float(np.mean(np.diff(finite) >= 0)) if len(finite) > 1 else None, + "negative_fraction": float(np.mean(finite < 0)) if len(finite) else None, + } + ) + return { + "scope": "Technical FCS quality checks only; not biological, clinical, or regulatory validation.", + "event_count": int(len(events)), + "channel_count": int(events.shape[1]), + "channels": list(events.columns), + "compensation_metadata_present": _compensation_present(metadata), + "time_integrity": time_integrity, + "channel_summaries": _channel_summary(events, metadata.get("_channel_ranges", {})), + "outlier_summary": _outlier_summary(events), + "limitations": [ + "FCS metadata cannot establish that compensation was correct or that controls were appropriate.", + "Gate quality requires the gating plan and control evidence in addition to event measurements.", + ], + } diff --git a/agents/flow-cytometry-referee/tools/flow-qc/tool.yaml b/agents/flow-cytometry-referee/tools/flow-qc/tool.yaml new file mode 100644 index 00000000..e1479c8d --- /dev/null +++ b/agents/flow-cytometry-referee/tools/flow-qc/tool.yaml @@ -0,0 +1,35 @@ +name: flow-qc +description: > + Python environment for deterministic flow-cytometry FCS quality checks, compensation metadata inspection, + channel summaries, and optional unsupervised event outlier scoring. +version: 1.0.0 +category: Scientific Computing +license: MIT +infra: + - name: worker + infra_type: container + image: + acr: '{name}.azurecr.io/flow-qc:latest' + compute: + min_resources: + cpu: 2 + ram: '8Gi' + storage: '10Gi' + max_resources: + cpu: 8 + ram: '32Gi' + storage: '30Gi' + infiniband: false + recommended_sku: + - Standard_D4s_v6 + - Standard_D8s_v6 + pool_type: static + pool_size: 1 +code_environments: + - language: python3 + command: 'python3 -u "/{{scriptName}}" > {{output_directory}}/logs.out 2>&1; cp -r /workdir/* {{output_directory}}/ 2>/dev/null || true; tail -n 100 {{output_directory}}/logs.out' + description: > + Python environment with flowio, NumPy, pandas, scikit-learn, and flow_qc_utils. It reads standard FCS + files and produces technical QC metrics. It is not a clinical classifier or a substitute for validated + laboratory analysis. + infra_node: worker From 84214dfbd12321e4e73e1584e6adf8d0e9c13c50 Mon Sep 17 00:00:00 2001 From: AzizbekMuminoff Date: Sun, 20 Sep 2026 16:14:21 -0700 Subject: [PATCH 02/11] test: verify FCS channel metadata parsing --- .../tools/flow-qc/flow_qc_utils.py | 4 +- .../tools/flow-qc/test_flow_qc_utils.py | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py diff --git a/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py b/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py index 2c4c8e5c..e16bc3ad 100644 --- a/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py +++ b/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py @@ -20,7 +20,7 @@ def load_fcs(path: str | Path) -> tuple[dict[str, Any], pd.DataFrame]: flow_data = FlowData(str(path)) channel_numbers = sorted(flow_data.channels, key=lambda value: int(value)) channel_names = [ - flow_data.channels[number].get("PnN") or f"channel_{number}" + flow_data.channels[number].get("pnn") or f"channel_{number}" for number in channel_numbers ] event_count = int(flow_data.event_count) @@ -28,7 +28,7 @@ def load_fcs(path: str | Path) -> tuple[dict[str, Any], pd.DataFrame]: events = np.asarray(flow_data.events, dtype=float).reshape(event_count, channel_count) metadata = {str(key): value for key, value in flow_data.text.items()} metadata["_channel_ranges"] = { - name: flow_data.channels[number].get("PnR") + name: flow_data.channels[number].get("pnr") for name, number in zip(channel_names, channel_numbers, strict=True) } return metadata, pd.DataFrame(events, columns=channel_names) diff --git a/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py b/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py new file mode 100644 index 00000000..8f80ea0e --- /dev/null +++ b/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py @@ -0,0 +1,42 @@ +"""End-to-end tests for the flow-cytometry QC helper.""" + +import json +import sys +from pathlib import Path + +import numpy as np +from flowio import create_fcs + +sys.path.insert(0, str(Path(__file__).parent)) +from flow_qc_utils import build_qc_report, load_fcs + + +def test_load_fcs_preserves_channel_names_and_time_qc(tmp_path: Path) -> None: + """A generated standards-compliant FCS file retains names needed by technical checks.""" + destination = tmp_path / "synthetic.fcs" + rng = np.random.default_rng(42) + events = np.column_stack( + ( + rng.normal(50_000, 5_000, 500), + rng.normal(20_000, 3_000, 500), + np.arange(500), + rng.uniform(0, 60_000, 500), + ) + ).astype("float32") + with destination.open("wb") as handle: + create_fcs( + handle, + events.flatten().tolist(), + ["FSC-A", "SSC-A", "Time", "FITC-A"], + metadata_dict={"$SPILLOVER": "synthetic"}, + ) + + metadata, parsed_events = load_fcs(destination) + report = build_qc_report(metadata, parsed_events) + + assert parsed_events.columns.tolist() == ["FSC-A", "SSC-A", "Time", "FITC-A"] + assert report["event_count"] == 500 + assert report["compensation_metadata_present"] is True + assert report["time_integrity"]["available"] is True + assert report["outlier_summary"]["available"] is True + assert json.dumps(report) From 8640e9459d7f62b7c4e93d6aacf370d97f99ee22 Mon Sep 17 00:00:00 2001 From: AzizbekMuminoff Date: Sun, 20 Sep 2026 18:41:14 -0700 Subject: [PATCH 03/11] feat: generate flow cytometry QC dashboard --- agents/flow-cytometry-referee/agent.yaml | 3 +- .../tools/flow-qc/Dockerfile | 2 +- .../tools/flow-qc/flow_qc_utils.py | 66 +++++++++++++++++++ .../tools/flow-qc/test_flow_qc_utils.py | 13 +++- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/agents/flow-cytometry-referee/agent.yaml b/agents/flow-cytometry-referee/agent.yaml index 5d766373..0848bf0a 100644 --- a/agents/flow-cytometry-referee/agent.yaml +++ b/agents/flow-cytometry-referee/agent.yaml @@ -40,7 +40,8 @@ instructions: | technically invalid condition such as an unreadable FCS file, no events, or non-finite event values that make the requested analysis impossible. 5. Produce a concise remediation plan ordered by impact. Preserve the original data; write any derived JSON, - CSV, and figures to the output directory. + CSV, and figures to the output directory. When FCS event data is available, create a technical QC dashboard + with scatter, distribution, acquisition-time, and saturation panels through `write_qc_artifacts`. ## Required response format - Scope and supplied evidence diff --git a/agents/flow-cytometry-referee/tools/flow-qc/Dockerfile b/agents/flow-cytometry-referee/tools/flow-qc/Dockerfile index 313ef201..6f1dfea2 100644 --- a/agents/flow-cytometry-referee/tools/flow-qc/Dockerfile +++ b/agents/flow-cytometry-referee/tools/flow-qc/Dockerfile @@ -31,6 +31,6 @@ RUN groupadd -r appuser && useradd -r -g appuser appuser && \ USER appuser WORKDIR /workdir -ENV PYTHONPATH=/app:${PYTHONPATH} +ENV PYTHONPATH=/app CMD ["/bin/bash"] diff --git a/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py b/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py index e16bc3ad..4a5f4ff8 100644 --- a/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py +++ b/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py @@ -113,3 +113,69 @@ def build_qc_report(metadata: dict[str, Any], events: pd.DataFrame) -> dict[str, "Gate quality requires the gating plan and control evidence in addition to event measurements.", ], } + + +def write_qc_artifacts( + report: dict[str, Any], events: pd.DataFrame, output_dir: str | Path, stem: str = "flow_qc" +) -> dict[str, str]: + """Write a technical QC dashboard and channel-summary CSV, returning their paths.""" + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + target = Path(output_dir) + target.mkdir(parents=True, exist_ok=True) + dashboard = target / f"{stem}_dashboard.png" + summary_csv = target / f"{stem}_channel_summary.csv" + pd.DataFrame(report["channel_summaries"]).to_csv(summary_csv, index=False) + + def first_matching(*terms: str) -> str | None: + return next((name for name in events.columns if any(term in name.lower() for term in terms)), None) + + fsc, ssc, time = first_matching("fsc"), first_matching("ssc"), first_matching("time") + excluded = {name for name in (fsc, ssc, time) if name} + signal = next((name for name in events.columns if name not in excluded), events.columns[0]) + sample = events.iloc[: min(len(events), 20_000)] + fig, axes = plt.subplots(2, 2, figsize=(12, 9)) + + scatter = axes[0, 0] + if fsc and ssc: + scatter.scatter(sample[fsc], sample[ssc], s=1, alpha=0.18, rasterized=True) + scatter.set(xlabel=fsc, ylabel=ssc, title="Forward vs. side scatter") + else: + scatter.text(0.5, 0.5, "FSC/SSC channels not found", ha="center", va="center") + scatter.set_axis_off() + + distribution = axes[0, 1] + values = sample[signal].to_numpy(dtype=float) + values = values[np.isfinite(values)] + if values.size: + distribution.hist(values, bins=80, color="#3478bf", alpha=0.85) + distribution.set(xlabel=signal, ylabel="Events", title="Representative channel distribution") + else: + distribution.text(0.5, 0.5, "No finite values", ha="center", va="center") + distribution.set_axis_off() + + trend = axes[1, 0] + if time: + trend.plot(sample.index, sample[time], linewidth=0.6, color="#4d8c57") + trend.set(xlabel="Event index", ylabel=time, title="Acquisition time trend") + else: + trend.text(0.5, 0.5, "Time channel not found", ha="center", va="center") + trend.set_axis_off() + + saturation = axes[1, 1] + summaries = report["channel_summaries"] + labels = [item["channel"] for item in summaries] + fractions = [item["saturation_fraction"] or 0.0 for item in summaries] + saturation.bar(range(len(labels)), fractions, color="#c45a3c") + saturation.set_xticks(range(len(labels)), labels, rotation=40, ha="right", fontsize=8) + saturation.set(ylabel="Fraction at or above declared range", title="Reported channel saturation") + saturation.set_ylim(0, max(0.01, max(fractions, default=0.0) * 1.15)) + + fig.suptitle("Flow cytometry technical QC dashboard", fontweight="bold") + fig.tight_layout() + fig.savefig(dashboard, dpi=160, bbox_inches="tight") + plt.close(fig) + return {"qc_dashboard": str(dashboard), "channel_summary_csv": str(summary_csv)} diff --git a/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py b/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py index 8f80ea0e..eed63248 100644 --- a/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py +++ b/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py @@ -5,10 +5,11 @@ from pathlib import Path import numpy as np +import pandas as pd from flowio import create_fcs sys.path.insert(0, str(Path(__file__).parent)) -from flow_qc_utils import build_qc_report, load_fcs +from flow_qc_utils import build_qc_report, load_fcs, write_qc_artifacts def test_load_fcs_preserves_channel_names_and_time_qc(tmp_path: Path) -> None: @@ -40,3 +41,13 @@ def test_load_fcs_preserves_channel_names_and_time_qc(tmp_path: Path) -> None: assert report["time_integrity"]["available"] is True assert report["outlier_summary"]["available"] is True assert json.dumps(report) + + +def test_write_qc_artifacts_creates_dashboard_and_csv(tmp_path: Path) -> None: + events = pd.DataFrame( + {"FSC-A": [100, 200, 300], "SSC-A": [20, 30, 40], "Time": [0, 1, 2], "FITC-A": [10, 30, 20]} + ) + report = build_qc_report({"_channel_ranges": {}}, events) + artifacts = write_qc_artifacts(report, events, tmp_path, "fixture") + assert Path(artifacts["qc_dashboard"]).read_bytes().startswith(b"\x89PNG") + assert Path(artifacts["channel_summary_csv"]).is_file() From bc98d8f9ee3557836c91e189b000234c4e015017 Mon Sep 17 00:00:00 2001 From: AzizbekMuminoff Date: Sun, 20 Sep 2026 18:50:38 -0700 Subject: [PATCH 04/11] fix: recover common FCS offset mismatch --- .../tools/flow-qc/flow_qc_utils.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py b/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py index 4a5f4ff8..ea293cf3 100644 --- a/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py +++ b/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py @@ -12,12 +12,22 @@ import numpy as np import pandas as pd from flowio import FlowData +from flowio.exceptions import FCSParsingError from sklearn.ensemble import IsolationForest def load_fcs(path: str | Path) -> tuple[dict[str, Any], pd.DataFrame]: """Read an FCS file into metadata and an events-by-channel DataFrame.""" - flow_data = FlowData(str(path)) + parse_warnings: list[str] = [] + try: + flow_data = FlowData(str(path)) + except FCSParsingError as error: + if "offset" not in str(error).lower(): + raise + flow_data = FlowData(str(path), ignore_offset_error=True) + parse_warnings.append( + "The FCS data offset was inconsistent; parsed with FlowIO's offset-recovery option." + ) channel_numbers = sorted(flow_data.channels, key=lambda value: int(value)) channel_names = [ flow_data.channels[number].get("pnn") or f"channel_{number}" @@ -31,6 +41,7 @@ def load_fcs(path: str | Path) -> tuple[dict[str, Any], pd.DataFrame]: name: flow_data.channels[number].get("pnr") for name, number in zip(channel_names, channel_numbers, strict=True) } + metadata["_parse_warnings"] = parse_warnings return metadata, pd.DataFrame(events, columns=channel_names) @@ -105,6 +116,7 @@ def build_qc_report(metadata: dict[str, Any], events: pd.DataFrame) -> dict[str, "channel_count": int(events.shape[1]), "channels": list(events.columns), "compensation_metadata_present": _compensation_present(metadata), + "parse_warnings": metadata.get("_parse_warnings", []), "time_integrity": time_integrity, "channel_summaries": _channel_summary(events, metadata.get("_channel_ranges", {})), "outlier_summary": _outlier_summary(events), From d4868f8af9cd43ba9a65c55a88eede85d1142137 Mon Sep 17 00:00:00 2001 From: AzizbekMuminoff Date: Sun, 20 Sep 2026 23:55:33 -0700 Subject: [PATCH 05/11] fix: improve flow QC channel semantics --- agents/flow-cytometry-referee/README.md | 14 +- agents/flow-cytometry-referee/agent.yaml | 6 +- .../tools/flow-qc/flow_qc_utils.py | 131 ++++++++++++++---- .../tools/flow-qc/test_flow_qc_utils.py | 17 ++- 4 files changed, 130 insertions(+), 38 deletions(-) diff --git a/agents/flow-cytometry-referee/README.md b/agents/flow-cytometry-referee/README.md index eeabfb23..b9279ac9 100644 --- a/agents/flow-cytometry-referee/README.md +++ b/agents/flow-cytometry-referee/README.md @@ -13,9 +13,10 @@ must be supplied before a gating workflow can be considered reproducible. ## Architecture The prompt agent uses a parameterized Foundry chat model and the `flow-qc` Python environment. The environment reads -FCS events with `flowio`, calculates deterministic channel and acquisition summaries, and can calculate an optional -Isolation Forest outlier fraction. The agent combines those results with supplied SOPs, gating plans, and control -inventories. The outlier score flags unusual measurement events only; it does not classify cells or samples. +FCS events with `flowio`, calculates deterministic channel and acquisition summaries, validates the shape and channel +names of a stored spillover matrix when present, and can calculate an optional exploratory Isolation Forest outlier +fraction. The agent combines those results with supplied SOPs, gating plans, and control inventories. The outlier +score flags unusual measurement events only; it does not classify cells or samples or establish a QC threshold. ## Prerequisites @@ -43,9 +44,10 @@ inventories. The outlier score flags unusual measurement events only; it does no ## Tools `flow-qc` provides a Python code environment with `flowio`, NumPy, pandas, scikit-learn, and `flow_qc_utils`. -It can read standard FCS files, summarize event and channel values, inspect compensation-related metadata, evaluate a -time channel when present, and calculate an unsupervised event-outlier fraction. Derived reports belong in the -configured output directory. +It can read standard FCS files, summarize event and channel values, inspect and validate stored compensation-related +metadata, evaluate acquisition-time continuity, calculate an exploratory event-outlier fraction, and create a +technical QC dashboard. A declared-range-limit fraction is a measurement-range check, not proof of detector +saturation. Derived reports belong in the configured output directory. ## Known Limitations diff --git a/agents/flow-cytometry-referee/agent.yaml b/agents/flow-cytometry-referee/agent.yaml index 0848bf0a..448f339a 100644 --- a/agents/flow-cytometry-referee/agent.yaml +++ b/agents/flow-cytometry-referee/agent.yaml @@ -22,7 +22,8 @@ instructions: | channels, compensation metadata, non-finite values, saturation, time ordering, or outlier score. - Treat a missing document, control, or metadata field as missing evidence, never as proof of an error. - Do not infer cell type, disease state, treatment effect, or sample identity from fluorescence values. - - Do not use the unsupervised outlier score as a classifier. It identifies unusual measurement events only. + - Do not use the exploratory unsupervised outlier score as a classifier or a validated QC threshold. It identifies + unusual measurement events only. - State panel- and instrument-specific assumptions explicitly. Ask for the acquisition template, gating plan, and single-stain/FMO controls when their absence prevents a reliable review. @@ -41,7 +42,8 @@ instructions: | make the requested analysis impossible. 5. Produce a concise remediation plan ordered by impact. Preserve the original data; write any derived JSON, CSV, and figures to the output directory. When FCS event data is available, create a technical QC dashboard - with scatter, distribution, acquisition-time, and saturation panels through `write_qc_artifacts`. + with scatter, fluorescence-distribution, acquisition-time, and declared-range-limit panels through + `write_qc_artifacts`. ## Required response format - Scope and supplied evidence diff --git a/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py b/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py index ea293cf3..cc56322d 100644 --- a/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py +++ b/agents/flow-cytometry-referee/tools/flow-qc/flow_qc_utils.py @@ -41,16 +41,59 @@ def load_fcs(path: str | Path) -> tuple[dict[str, Any], pd.DataFrame]: name: flow_data.channels[number].get("pnr") for name, number in zip(channel_names, channel_numbers, strict=True) } + metadata["_channel_stains"] = { + name: flow_data.channels[number].get("pns") + for name, number in zip(channel_names, channel_numbers, strict=True) + } metadata["_parse_warnings"] = parse_warnings return metadata, pd.DataFrame(events, columns=channel_names) -def _compensation_present(metadata: dict[str, Any]) -> bool: - normalized_keys = {key.lower().replace("$", "") for key in metadata} - return any(key in normalized_keys for key in ("spillover", "spill", "compensation", "comp")) +def _channel_roles(events: pd.DataFrame, stains: dict[str, Any]) -> dict[str, list[str]]: + """Classify measurement channels for QC display, without biological inference.""" + roles = {"scatter": [], "fluorescence": [], "time": [], "index": [], "auxiliary": []} + for name in events.columns: + normalized = name.lower().replace(" ", "") + if normalized in {"time", "acquisition_time"} or normalized.endswith("time"): + roles["time"].append(name) + elif "event" in normalized or normalized.endswith("index"): + roles["index"].append(name) + elif normalized.startswith(("fsc", "ssc")): + roles["scatter"].append(name) + elif normalized.startswith(("width", "pulsewidth")): + roles["auxiliary"].append(name) + else: + roles["fluorescence"].append(name) + return roles + + +def _compensation_summary(metadata: dict[str, Any], events: pd.DataFrame) -> dict[str, Any]: + raw = next( + (value for key, value in metadata.items() if key.lower().replace("$", "") in {"spillover", "spill"}), + None, + ) + if raw is None: + return {"status": "absent", "matrix_channels": []} + tokens = [token.strip() for token in str(raw).split(",")] + try: + dimension = int(tokens[0]) + except (IndexError, ValueError): + return {"status": "present_unparsed", "matrix_channels": []} + channels = tokens[1 : dimension + 1] + values = tokens[dimension + 1 :] + missing = [channel for channel in channels if channel not in events.columns] + valid = len(channels) == dimension and len(values) == dimension * dimension and not missing + return { + "status": "present_valid" if valid else "present_invalid", + "matrix_channels": channels, + "matrix_dimension": dimension, + "channels_missing_from_fcs": missing, + } -def _channel_summary(events: pd.DataFrame, ranges: dict[str, Any]) -> list[dict[str, Any]]: +def _channel_summary( + events: pd.DataFrame, ranges: dict[str, Any], range_check_channels: set[str] +) -> list[dict[str, Any]]: summaries: list[dict[str, Any]] = [] for name in events.columns: values = events[name].to_numpy(dtype=float) @@ -60,8 +103,10 @@ def _channel_summary(events: pd.DataFrame, ranges: dict[str, Any]) -> list[dict[ upper_limit = float(channel_range) except (TypeError, ValueError): upper_limit = None - saturation_fraction = ( - float(np.mean(finite >= upper_limit)) if upper_limit and finite.size else None + at_declared_range_fraction = ( + float(np.mean(finite >= upper_limit)) + if name in range_check_channels and upper_limit and finite.size + else None ) summaries.append( { @@ -72,18 +117,22 @@ def _channel_summary(events: pd.DataFrame, ranges: dict[str, Any]) -> list[dict[ "median": float(np.median(finite)) if finite.size else None, "p99": float(np.percentile(finite, 99)) if finite.size else None, "maximum": float(np.max(finite)) if finite.size else None, - "saturation_fraction": saturation_fraction, + "at_declared_range_fraction": at_declared_range_fraction, } ) return summaries -def _outlier_summary(events: pd.DataFrame, random_state: int = 0) -> dict[str, Any]: +def _outlier_summary( + events: pd.DataFrame, measurement_channels: list[str], random_state: int = 0 +) -> dict[str, Any]: """Return an unsupervised event-outlier metric, never a biological classification.""" - numeric = events.select_dtypes(include=[np.number]).replace([np.inf, -np.inf], np.nan).dropna(axis=0) + numeric = events[measurement_channels].select_dtypes(include=[np.number]) + numeric = numeric.replace([np.inf, -np.inf], np.nan).dropna(axis=0) if len(numeric) < 100 or numeric.shape[1] == 0: return {"available": False, "reason": "Need at least 100 complete numeric events."} - sample = numeric.iloc[: min(len(numeric), 50_000), : min(numeric.shape[1], 16)] + positions = np.linspace(0, len(numeric) - 1, min(len(numeric), 50_000), dtype=int) + sample = numeric.iloc[positions, : min(numeric.shape[1], 16)] standardized = (sample - sample.median()) / sample.std(ddof=0).replace(0, 1) model = IsolationForest(contamination="auto", random_state=random_state, n_estimators=100) labels = model.fit_predict(standardized) @@ -92,34 +141,56 @@ def _outlier_summary(events: pd.DataFrame, random_state: int = 0) -> dict[str, A "events_scored": int(len(sample)), "channels_scored": list(sample.columns), "outlier_fraction": float(np.mean(labels == -1)), - "interpretation": "Unusual multichannel measurement events, not cell identities or sample classes.", + "interpretation": "Exploratory unusual multichannel measurements, not cell identities, sample classes, or a validated QC threshold.", } def build_qc_report(metadata: dict[str, Any], events: pd.DataFrame) -> dict[str, Any]: """Create a JSON-serializable, evidence-only FCS technical QC report.""" - time_columns = [name for name in events.columns if name.lower() in {"time", "acquisition_time"}] + roles = _channel_roles(events, metadata.get("_channel_stains", {})) + time_columns = roles["time"] time_integrity: dict[str, Any] = {"available": bool(time_columns)} if time_columns: values = events[time_columns[0]].to_numpy(dtype=float) finite = values[np.isfinite(values)] + differences = np.diff(finite) + positive_gaps = differences[differences > 0] + if finite.size > 1 and finite[-1] > finite[0]: + counts, _ = np.histogram(finite, bins=20) + nonempty_counts = counts[counts > 0] + rate_bin_count_cv = ( + float(np.std(nonempty_counts) / np.mean(nonempty_counts)) if nonempty_counts.size else None + ) + else: + rate_bin_count_cv = None time_integrity.update( { "channel": time_columns[0], - "nondecreasing_fraction": float(np.mean(np.diff(finite) >= 0)) if len(finite) > 1 else None, + "nondecreasing_fraction": float(np.mean(differences >= 0)) if len(finite) > 1 else None, "negative_fraction": float(np.mean(finite < 0)) if len(finite) else None, + "reset_count": int(np.sum(differences < 0)), + "largest_positive_gap": float(np.max(positive_gaps)) if positive_gaps.size else None, + "median_positive_gap": float(np.median(positive_gaps)) if positive_gaps.size else None, + "rate_bin_count_cv": rate_bin_count_cv, } ) + compensation = _compensation_summary(metadata, events) + range_check_channels = set(roles["scatter"] + roles["fluorescence"]) + outlier_channels = roles["scatter"] + roles["fluorescence"] return { "scope": "Technical FCS quality checks only; not biological, clinical, or regulatory validation.", "event_count": int(len(events)), "channel_count": int(events.shape[1]), "channels": list(events.columns), - "compensation_metadata_present": _compensation_present(metadata), + "compensation_metadata_present": compensation["status"] != "absent", + "compensation": compensation, + "channel_roles": roles, "parse_warnings": metadata.get("_parse_warnings", []), "time_integrity": time_integrity, - "channel_summaries": _channel_summary(events, metadata.get("_channel_ranges", {})), - "outlier_summary": _outlier_summary(events), + "channel_summaries": _channel_summary( + events, metadata.get("_channel_ranges", {}), range_check_channels + ), + "outlier_summary": _outlier_summary(events, outlier_channels), "limitations": [ "FCS metadata cannot establish that compensation was correct or that controls were appropriate.", "Gate quality requires the gating plan and control evidence in addition to event measurements.", @@ -142,12 +213,11 @@ def write_qc_artifacts( summary_csv = target / f"{stem}_channel_summary.csv" pd.DataFrame(report["channel_summaries"]).to_csv(summary_csv, index=False) - def first_matching(*terms: str) -> str | None: - return next((name for name in events.columns if any(term in name.lower() for term in terms)), None) - - fsc, ssc, time = first_matching("fsc"), first_matching("ssc"), first_matching("time") - excluded = {name for name in (fsc, ssc, time) if name} - signal = next((name for name in events.columns if name not in excluded), events.columns[0]) + roles = report["channel_roles"] + fsc = next((name for name in roles["scatter"] if name.lower().startswith("fsc")), None) + ssc = next((name for name in roles["scatter"] if name.lower().startswith("ssc")), None) + time = roles["time"][0] if roles["time"] else None + signal = roles["fluorescence"][0] if roles["fluorescence"] else None sample = events.iloc[: min(len(events), 20_000)] fig, axes = plt.subplots(2, 2, figsize=(12, 9)) @@ -160,13 +230,13 @@ def first_matching(*terms: str) -> str | None: scatter.set_axis_off() distribution = axes[0, 1] - values = sample[signal].to_numpy(dtype=float) + values = sample[signal].to_numpy(dtype=float) if signal else np.array([]) values = values[np.isfinite(values)] - if values.size: + if signal and values.size: distribution.hist(values, bins=80, color="#3478bf", alpha=0.85) - distribution.set(xlabel=signal, ylabel="Events", title="Representative channel distribution") + distribution.set(xlabel=signal, ylabel="Events", title=f"Fluorescence distribution: {signal}") else: - distribution.text(0.5, 0.5, "No finite values", ha="center", va="center") + distribution.text(0.5, 0.5, "Fluorescence channel not found", ha="center", va="center") distribution.set_axis_off() trend = axes[1, 0] @@ -180,10 +250,15 @@ def first_matching(*terms: str) -> str | None: saturation = axes[1, 1] summaries = report["channel_summaries"] labels = [item["channel"] for item in summaries] - fractions = [item["saturation_fraction"] or 0.0 for item in summaries] + summaries = [item for item in summaries if item["channel"] in set(roles["scatter"] + roles["fluorescence"])] + labels = [item["channel"] for item in summaries] + fractions = [item["at_declared_range_fraction"] or 0.0 for item in summaries] saturation.bar(range(len(labels)), fractions, color="#c45a3c") saturation.set_xticks(range(len(labels)), labels, rotation=40, ha="right", fontsize=8) - saturation.set(ylabel="Fraction at or above declared range", title="Reported channel saturation") + saturation.set( + ylabel="Fraction at or above declared range", + title="Measurements at declared range limit", + ) saturation.set_ylim(0, max(0.01, max(fractions, default=0.0) * 1.15)) fig.suptitle("Flow cytometry technical QC dashboard", fontweight="bold") diff --git a/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py b/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py index eed63248..ec006503 100644 --- a/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py +++ b/agents/flow-cytometry-referee/tools/flow-qc/test_flow_qc_utils.py @@ -45,9 +45,22 @@ def test_load_fcs_preserves_channel_names_and_time_qc(tmp_path: Path) -> None: def test_write_qc_artifacts_creates_dashboard_and_csv(tmp_path: Path) -> None: events = pd.DataFrame( - {"FSC-A": [100, 200, 300], "SSC-A": [20, 30, 40], "Time": [0, 1, 2], "FITC-A": [10, 30, 20]} + { + "FSC-A": [100, 200, 300], + "FSC-H": [90, 190, 290], + "SSC-A": [20, 30, 40], + "Time": [0, 1, 2], + "Event #": [1, 2, 3], + "FITC-A": [10, 30, 20], + } + ) + report = build_qc_report( + {"_channel_ranges": {"Event #": 3, "FITC-A": 100}, "_channel_stains": {}}, events ) - report = build_qc_report({"_channel_ranges": {}}, events) artifacts = write_qc_artifacts(report, events, tmp_path, "fixture") assert Path(artifacts["qc_dashboard"]).read_bytes().startswith(b"\x89PNG") assert Path(artifacts["channel_summary_csv"]).is_file() + assert report["channel_roles"]["index"] == ["Event #"] + event_summary = next(item for item in report["channel_summaries"] if item["channel"] == "Event #") + assert event_summary["at_declared_range_fraction"] is None + assert "FSC-H" not in report["channel_roles"]["fluorescence"] From a74ac4ddeaa4fb9448a2fcffa3fd3787428dfe70 Mon Sep 17 00:00:00 2001 From: AzizbekMuminoff Date: Mon, 21 Sep 2026 20:41:11 -0700 Subject: [PATCH 06/11] feat: display generated flow QC dashboard --- agents/flow-cytometry-referee/agent.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/agents/flow-cytometry-referee/agent.yaml b/agents/flow-cytometry-referee/agent.yaml index 448f339a..ef87796d 100644 --- a/agents/flow-cytometry-referee/agent.yaml +++ b/agents/flow-cytometry-referee/agent.yaml @@ -45,6 +45,11 @@ instructions: | with scatter, fluorescence-distribution, acquisition-time, and declared-range-limit panels through `write_qc_artifacts`. + ## Dashboard presentation + - After `write_qc_artifacts` produces `flow_qc_dashboard.png`, inspect that exact artifact before finalizing the review. + - In the final response, embed it immediately after the QC metrics using Markdown: `![Flow cytometry QC dashboard](file:///absolute/path/to/flow_qc_dashboard.png)`. Also provide the CSV and JSON artifact paths as links. + - Do not substitute a generic illustration or describe the dashboard without displaying the generated file. + ## Required response format - Scope and supplied evidence - QC metrics observed from the FCS file From 96451071bc5e2bbd447384cc0a00b52962a82e98 Mon Sep 17 00:00:00 2001 From: AzizbekMuminoff Date: Mon, 21 Sep 2026 20:49:35 -0700 Subject: [PATCH 07/11] fix: embed flow QC dashboard in chat responses --- agents/flow-cytometry-referee/agent.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/flow-cytometry-referee/agent.yaml b/agents/flow-cytometry-referee/agent.yaml index ef87796d..a21dc92c 100644 --- a/agents/flow-cytometry-referee/agent.yaml +++ b/agents/flow-cytometry-referee/agent.yaml @@ -47,7 +47,7 @@ instructions: | ## Dashboard presentation - After `write_qc_artifacts` produces `flow_qc_dashboard.png`, inspect that exact artifact before finalizing the review. - - In the final response, embed it immediately after the QC metrics using Markdown: `![Flow cytometry QC dashboard](file:///absolute/path/to/flow_qc_dashboard.png)`. Also provide the CSV and JSON artifact paths as links. + - In the final response, put the PNG directly after the QC metrics using a workspace-relative Markdown image reference, for example `![Flow cytometry QC dashboard](./samples/real-fcs/BD-FACS-Aria-II-QC/flow_qc_dashboard.png)`. Never use `file:///` because Discovery renders it only as a link. Also provide workspace-relative CSV and JSON links. - Do not substitute a generic illustration or describe the dashboard without displaying the generated file. ## Required response format From d81d09725007939a80a4014711982b4690b24acd Mon Sep 17 00:00:00 2001 From: AzizbekMuminoff Date: Mon, 21 Sep 2026 21:18:01 -0700 Subject: [PATCH 08/11] fix: link flow QC dashboard in chat --- agents/flow-cytometry-referee/agent.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/flow-cytometry-referee/agent.yaml b/agents/flow-cytometry-referee/agent.yaml index a21dc92c..a90084c6 100644 --- a/agents/flow-cytometry-referee/agent.yaml +++ b/agents/flow-cytometry-referee/agent.yaml @@ -47,7 +47,7 @@ instructions: | ## Dashboard presentation - After `write_qc_artifacts` produces `flow_qc_dashboard.png`, inspect that exact artifact before finalizing the review. - - In the final response, put the PNG directly after the QC metrics using a workspace-relative Markdown image reference, for example `![Flow cytometry QC dashboard](./samples/real-fcs/BD-FACS-Aria-II-QC/flow_qc_dashboard.png)`. Never use `file:///` because Discovery renders it only as a link. Also provide workspace-relative CSV and JSON links. + - In the final response, put a clickable workspace-relative Markdown link directly after the QC metrics, for example `[Open generated QC dashboard](./samples/real-fcs/BD-FACS-Aria-II-QC/flow_qc_dashboard.png)`. Do not use image Markdown or `file:///` links because Discovery's chat renderer does not display local images reliably. Also provide workspace-relative CSV and JSON links. - Do not substitute a generic illustration or describe the dashboard without displaying the generated file. ## Required response format From 442a90fdbf94c8c796962e31aff1a78acca5734b Mon Sep 17 00:00:00 2001 From: AzizbekMuminoff Date: Mon, 21 Sep 2026 21:24:06 -0700 Subject: [PATCH 09/11] fix: require explicit QC dashboard link --- agents/flow-cytometry-referee/agent.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/agents/flow-cytometry-referee/agent.yaml b/agents/flow-cytometry-referee/agent.yaml index a90084c6..ca4bf144 100644 --- a/agents/flow-cytometry-referee/agent.yaml +++ b/agents/flow-cytometry-referee/agent.yaml @@ -47,8 +47,13 @@ instructions: | ## Dashboard presentation - After `write_qc_artifacts` produces `flow_qc_dashboard.png`, inspect that exact artifact before finalizing the review. - - In the final response, put a clickable workspace-relative Markdown link directly after the QC metrics, for example `[Open generated QC dashboard](./samples/real-fcs/BD-FACS-Aria-II-QC/flow_qc_dashboard.png)`. Do not use image Markdown or `file:///` links because Discovery's chat renderer does not display local images reliably. Also provide workspace-relative CSV and JSON links. - - Do not substitute a generic illustration or describe the dashboard without displaying the generated file. + - The `Dashboard` section is mandatory whenever `flow_qc_dashboard.png` exists. It must contain a clickable Markdown link, never an image tag, a bare path, or the words “viewed image”. + - Convert the artifact path to a workspace-relative path and use this exact response shape, replacing the path only: + ```markdown + ## Dashboard + [Open generated QC dashboard](./samples/real-fcs/BD-FACS-Aria-II-QC/flow_qc_dashboard.png) + ``` + - Also add equivalent workspace-relative Markdown links for the CSV and JSON artifacts. Do not use `file:///` links because Discovery's chat renderer does not display local images reliably. ## Required response format - Scope and supplied evidence From 839f7f699a4d1b2691894ab271b293d1ef5fa03e Mon Sep 17 00:00:00 2001 From: AzizbekMuminoff Date: Mon, 21 Sep 2026 21:34:39 -0700 Subject: [PATCH 10/11] fix: use native flow QC dashboard artifacts --- agents/flow-cytometry-referee/agent.yaml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/agents/flow-cytometry-referee/agent.yaml b/agents/flow-cytometry-referee/agent.yaml index ca4bf144..34ac8197 100644 --- a/agents/flow-cytometry-referee/agent.yaml +++ b/agents/flow-cytometry-referee/agent.yaml @@ -46,14 +46,11 @@ instructions: | `write_qc_artifacts`. ## Dashboard presentation - - After `write_qc_artifacts` produces `flow_qc_dashboard.png`, inspect that exact artifact before finalizing the review. - - The `Dashboard` section is mandatory whenever `flow_qc_dashboard.png` exists. It must contain a clickable Markdown link, never an image tag, a bare path, or the words “viewed image”. - - Convert the artifact path to a workspace-relative path and use this exact response shape, replacing the path only: - ```markdown - ## Dashboard - [Open generated QC dashboard](./samples/real-fcs/BD-FACS-Aria-II-QC/flow_qc_dashboard.png) - ``` - - Also add equivalent workspace-relative Markdown links for the CSV and JSON artifacts. Do not use `file:///` links because Discovery's chat renderer does not display local images reliably. + - Inspect the generated dashboard through the host's image viewer before finalizing the review. + - Treat dashboard, CSV, and JSON files as tool artifacts. Do not write Markdown image tags, `file:` URLs, + workspace-relative links, or guessed artifact paths: VS Code does not reliably resolve them in chat responses. + - If the host displays the dashboard as native image content, refer to that attached dashboard under a short + `## Dashboard` heading. Otherwise, report the artifact names without inventing a link. ## Required response format - Scope and supplied evidence From c713a675e102b71b2ac37ecb0186fad815f07648 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:00:06 +0000 Subject: [PATCH 11/11] fix: satisfy catalog validator for flow-cytometry-referee metadata Use tags from the catalog tag taxonomy (TAG-001) and a publisher contact on a domain with mail records (POL-019). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XFLpKccL6CTJgiSa9uFAfX --- agents/flow-cytometry-referee/metadata.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/agents/flow-cytometry-referee/metadata.yaml b/agents/flow-cytometry-referee/metadata.yaml index 2b69b10a..e121f152 100644 --- a/agents/flow-cytometry-referee/metadata.yaml +++ b/agents/flow-cytometry-referee/metadata.yaml @@ -5,7 +5,7 @@ associated_tools: - agents/flow-cytometry-referee/tools/flow-qc publisher: name: Aziz Muminov - contact: azizmuminov@users.noreply.github.com + contact: azizbekmuminov52@gmail.com support_url: https://github.com/AzizMuminov/discovery/issues party: 3p description: > @@ -13,8 +13,7 @@ description: > control coverage, and reproducibility. Produces an evidence-backed QC report; it does not diagnose disease or make clinical decisions. tags: - - flow-cytometry - - fcs - - quality-control - single-cell - bioinformatics + - biomedical + - data-analysis