diff --git a/chorus/core/interval.py b/chorus/core/interval.py index a15a547..883b098 100644 --- a/chorus/core/interval.py +++ b/chorus/core/interval.py @@ -667,10 +667,15 @@ def _alig2cigar(alignment: Alignment) -> list[CigarEntry]: c = CigarInsertion(seq=query[querypos:querypos+ilength(l)]) elif g == CigarDeletion.cigar_symbol: c = CigarDeletion(length=ilength(l)) - elif g == CigarNotEqual.cigar_symbol: + elif g == CigarNotEqual.cigar_symbol: c = CigarNotEqual(seq=query[querypos:querypos+ilength(l)]) else: - raise Exception('') + # Was a bare Exception('') — meaningless if a user ever + # hit it. Known CIGAR symbols are ``= M I D X``; anything + # else is a parser error. v26 P1 #13. + raise IntervalException( + f"Unknown CIGAR symbol {g!r} (expected one of '=MIDX')" + ) if c.consumes_query: querypos += len(c) cigar_groups.append(c) diff --git a/chorus/mcp/server.py b/chorus/mcp/server.py index 115953a..96d3fbc 100644 --- a/chorus/mcp/server.py +++ b/chorus/mcp/server.py @@ -128,8 +128,14 @@ def _safe_tool(fn): Wraps the function body only; does not interfere with FastMCP's registration (apply *inside* ``@mcp.tool()``). + + Set ``CHORUS_MCP_DEBUG=1`` to include ``"traceback": ...`` in the + returned dict — useful when debugging a tool call through an MCP + client that only prints the JSON reply (v26 P1 #18). """ import functools + import os + import traceback as _traceback @functools.wraps(fn) def wrapper(*args, **kwargs): @@ -137,11 +143,14 @@ def wrapper(*args, **kwargs): return fn(*args, **kwargs) except Exception as exc: logger.exception("MCP tool %s failed", fn.__name__) - return { + payload = { "error": str(exc) or type(exc).__name__, "error_type": type(exc).__name__, "tool": fn.__name__, } + if os.environ.get("CHORUS_MCP_DEBUG", "").lower() in ("1", "true", "yes"): + payload["traceback"] = _traceback.format_exc() + return payload return wrapper diff --git a/chorus/oracles/borzoi_source/templates/predict_template.py b/chorus/oracles/borzoi_source/templates/predict_template.py index 4d168dd..3884499 100644 --- a/chorus/oracles/borzoi_source/templates/predict_template.py +++ b/chorus/oracles/borzoi_source/templates/predict_template.py @@ -32,7 +32,14 @@ meta = get_metadata() track_indices = meta.id2index(args['assay_ids']) if any(map(lambda x: x is None, track_indices)): - raise Exception(f"Some assay IDs not found in metadata: {args['assay_ids']}") + missing = [a for a, i in zip(args['assay_ids'], track_indices) if i is None] + # Use ValueError (not bare Exception) so the env-runner surfaces a + # recognisable error type. v26 P1 #12. + raise ValueError( + f"Borzoi track identifier(s) not found in metadata: {missing}. " + f"Use oracle.get_track_info() to list valid identifiers, or " + f"oracle.get_track_info(pattern) to search (e.g. 'DNASE:K562')." + ) # Extract predictions for selected tracks selected_predictions = pred[:, track_indices] result = selected_predictions.tolist() \ No newline at end of file diff --git a/chorus/oracles/enformer_source/templates/predict_template.py b/chorus/oracles/enformer_source/templates/predict_template.py index f9193b8..85dff0e 100644 --- a/chorus/oracles/enformer_source/templates/predict_template.py +++ b/chorus/oracles/enformer_source/templates/predict_template.py @@ -47,7 +47,14 @@ meta = get_metadata() track_indices = meta.id2index(args['assay_ids']) if any(map(lambda x: x is None, track_indices)): - raise Exception(f"Some assay IDs not found in metadata: {args['assay_ids']}") + missing = [a for a, i in zip(args['assay_ids'], track_indices) if i is None] + # Use ValueError (not bare Exception) so the env-runner surfaces a + # recognisable error type through its subprocess wrapper. v26 P1 #12. + raise ValueError( + f"Enformer track identifier(s) not found in metadata: {missing}. " + f"Use oracle.get_track_info() to list valid identifiers, or " + f"oracle.get_track_info(pattern) to search (e.g. 'DNASE:K562')." + ) # Extract predictions for selected tracks selected_predictions = human_predictions[:, track_indices] result = selected_predictions.tolist() \ No newline at end of file diff --git a/tests/test_prediction_methods.py b/tests/test_prediction_methods.py index 9ffd955..e2eb4a7 100644 --- a/tests/test_prediction_methods.py +++ b/tests/test_prediction_methods.py @@ -410,6 +410,52 @@ def test_chorus_device_env_var_forces_cpu(self, monkeypatch): oracle = MockOracle(reference_fasta=str(self.fasta_path)) assert oracle.device is None + def test_unknown_track_id_gives_actionable_error(self): + """Invalid assay_id must raise InvalidAssayError with a pointer + to list_tracks / get_track_info — not silently substitute track 0 + (previous behaviour corrupted predictions) or surface a cryptic + TypeError('NoneType' is not subscriptable). + + Regression for v26 P0 finding (fix in 96fc28d). Exercises the + Enformer direct-load code path via a stubbed metadata object. + """ + import types + from chorus.core.exceptions import InvalidAssayError + + try: + from chorus.oracles import enformer as enformer_mod + except ImportError: + pytest.skip("enformer module not importable") + + # Fake metadata that misses every lookup. + fake_meta = types.SimpleNamespace( + get_track_by_identifier=lambda x: None, + get_tracks_by_description=lambda x: [], + ) + stub = type("Stub", (), {})() + + from chorus.oracles.enformer_source import enformer_metadata as meta_mod + orig = meta_mod.get_metadata + meta_mod.get_metadata = lambda: fake_meta + try: + # _validate_assay_ids is the user-facing gate called by + # OracleBase.predict() before _get_assay_indices. Both bad + # ENCFF IDs and bad descriptions must raise before reaching + # the model. + with pytest.raises(InvalidAssayError, match="(list_tracks|search_tracks)"): + enformer_mod.EnformerOracle._validate_assay_ids( + stub, ["ENCFF999BADID"] + ) + with pytest.raises(InvalidAssayError, match="(list_tracks|search_tracks)"): + enformer_mod.EnformerOracle._validate_assay_ids( + stub, ["totally made up track name"] + ) + # Empty input must not raise. + enformer_mod.EnformerOracle._validate_assay_ids(stub, None) + enformer_mod.EnformerOracle._validate_assay_ids(stub, []) + finally: + meta_mod.get_metadata = orig + def test_error_handling_model_not_loaded(self): """Test error when model not loaded.""" unloaded_oracle = MockOracle(reference_fasta=str(self.fasta_path))