diff --git a/changelog.d/1c6z.fixed.md b/changelog.d/1c6z.fixed.md new file mode 100644 index 000000000..c2c679418 --- /dev/null +++ b/changelog.d/1c6z.fixed.md @@ -0,0 +1 @@ +**MCP `edgar_fund` silently picked one BDC when a name matched several.** "Golub" returned GOLUB CAPITAL BDC though four Golub BDCs score 99.4-100. A name now resolves only on one match equal to it (ignoring a suffix like Corp or Inc., so "Ares Capital" is still ARES CAPITAL CORP) or a clear top hit; otherwise the tool returns `AMBIGUOUS_BDC` with up to five candidates and their CIKs. (bead edgartools-1c6z) diff --git a/edgar/ai/mcp/tools/fund.py b/edgar/ai/mcp/tools/fund.py index 915a8b3ea..3fee9ea12 100644 --- a/edgar/ai/mcp/tools/fund.py +++ b/edgar/ai/mcp/tools/fund.py @@ -7,6 +7,7 @@ from __future__ import annotations import logging +import re from decimal import Decimal from typing import Any, Optional @@ -496,6 +497,37 @@ async def _bdc_search(query: str, limit: int) -> Any: return error(str(e), suggestions=get_error_suggestions(e)) +_MAX_AMBIGUOUS_BDCS = 5 +_BDC_NAME_SUFFIXES = {"corp", "corporation", "inc", "incorporated", "co", "company", "llc", "lp", "l.p", "ltd"} + + +def _bdc_name_key(name: str) -> str: + """A name without case, punctuation or a trailing corporate suffix: "ARES CAPITAL CORP" -> "ares capital".""" + words = re.sub(r"[^\w\s.]", " ", str(name).casefold()).split() + while len(words) > 1 and words[-1].rstrip(".") in _BDC_NAME_SUFFIXES: + words.pop() + return " ".join(words) + + +def _pick_bdc_search_winner(query: str, rows: list[dict]) -> Optional[int]: + """The CIK a name search may resolve to without asking, from score-ordered rows, or None. + + Every whole-word hit scores ~100 ("Golub" matches four Golub BDCs at 99.4-100), + so the top hit alone proves nothing. One name equal to the query, ignoring a + corporate suffix, wins ("Ares Capital" is ARES CAPITAL CORP, not its 99.3-scoring + sibling funds). Otherwise the top hit needs >= 95 with the runner-up under 90. + """ + wanted = _bdc_name_key(query) + exact = [row for row in rows if _bdc_name_key(row["name"]) == wanted] + if len(exact) == 1: + return int(exact[0]["cik"]) + top = float(rows[0]["score"]) + runner_up = float(rows[1]["score"]) if len(rows) > 1 else None + if top >= 95 and (runner_up is None or runner_up < 90): + return int(rows[0]["cik"]) + return None + + def _bdc_investment_record(inv) -> dict[str, Any]: """One PortfolioInvestment as a record. Every key is always present; a missing value is None, never 0, so an absent figure cannot read as a zero position.""" @@ -537,11 +569,21 @@ async def _bdc_portfolio(identifier: str, limit: int) -> Any: except (ValueError, TypeError): pass - # Try search as fallback + # Try search as fallback; it must identify one BDC, never guess between several if bdc is None: - search_results = find_bdc(identifier, top_n=1) - if not search_results.empty: - bdc = search_results[0] + rows = find_bdc(identifier, top_n=6).results.to_dict("records") + if rows: + winner = _pick_bdc_search_winner(identifier, rows) + if winner is None: + return error( + f"'{identifier}' matches more than one BDC; pass a ticker or CIK", + suggestions=[ + f"{row['name']}: identifier='{int(row['cik'])}'" + for row in rows[:_MAX_AMBIGUOUS_BDCS] + ], + error_code="AMBIGUOUS_BDC", + ) + bdc = bdcs.get_by_cik(winner) if bdc is None: return error( diff --git a/tests/bdc/test_bdc_mcp_portfolio.py b/tests/bdc/test_bdc_mcp_portfolio.py index b85e5fe0d..bc3f37284 100644 --- a/tests/bdc/test_bdc_mcp_portfolio.py +++ b/tests/bdc/test_bdc_mcp_portfolio.py @@ -81,3 +81,67 @@ async def test_every_record_has_the_same_keys_and_a_missing_figure_is_none(): # Bolder Panther's cost is not tagged: None, never a zero position assert records[1]["cost"] is None assert records[1]["principal_amount"] is None + + +# --------------------------------------------------------------------------- +# Ambiguous names (edgartools-1c6z). Scores are find_bdc's on 2026-10-01: every +# whole-word hit lands near 100, so the top hit alone identified nothing and +# "Golub" silently became GOLUB CAPITAL BDC. +# --------------------------------------------------------------------------- + +def _rows(*hits): + return [{"cik": cik, "ticker": None, "name": name, "state": None, "is_active": True, "score": score} + for cik, name, score in hits] + + +GOLUB = _rows((1476765, "GOLUB CAPITAL BDC, Inc.", 100.0), (1901612, "Golub Capital BDC 4, Inc.", 99.8), + (1868878, "GOLUB CAPITAL DIRECT LENDING CORP", 99.4), (1930087, "Golub Capital Private Credit Fund", 99.4)) +ARES = _rows((1287750, "ARES CAPITAL CORP", 100.0), (1918712, "ARES STRATEGIC INCOME FUND", 99.3), + (1990049, "Ares Core Infrastructure Fund", 99.3)) +MAIN_STREET = _rows((1396440, "Main Street Capital CORP", 100.0), (1999999, "Phillip Street BDC LLC", 70.6)) + + +@pytest.mark.fast +@pytest.mark.parametrize("query, rows, expected", [ + ("Golub", GOLUB, None), + ("Ares Capital", ARES, 1287750), # equal to ARES CAPITAL CORP once the suffix goes + ("ares capital corp.", ARES, 1287750), + ("Ares", ARES, None), # three Ares funds; nothing says which + ("Main Street", MAIN_STREET, 1396440), # clear winner: 100 against 70.6 + ("Golub Capital BDC", GOLUB, 1476765), # "Inc." dropped; BDC 4 differs by its "4" +]) +def test_a_name_resolves_only_when_it_identifies_one_bdc(query, rows, expected): + from edgar.ai.mcp.tools.fund import _pick_bdc_search_winner + assert _pick_bdc_search_winner(query, rows) == expected + + +@pytest.mark.fast +def test_a_single_weak_hit_is_not_used(): + from edgar.ai.mcp.tools.fund import _pick_bdc_search_winner + assert _pick_bdc_search_winner("Phillip", _rows((1999999, "Phillip Street BDC LLC", 72.0))) is None + + +@pytest.mark.fast +@pytest.mark.asyncio +async def test_an_ambiguous_name_lists_the_candidates_instead_of_picking_one(): + import pandas as pd + + from edgar.ai.mcp.tools.fund import _bdc_portfolio + + bdcs = MagicMock() + bdcs.get_by_ticker.return_value = None + search = MagicMock() + search.results = pd.DataFrame(GOLUB) + with patch("edgar.bdc.reference.get_bdc_list", return_value=bdcs), \ + patch("edgar.bdc.search.find_bdc", return_value=search): + response = await _bdc_portfolio(identifier="Golub", limit=5) + + assert not response.success + assert response.error_code == "AMBIGUOUS_BDC" + assert response.suggestions == [ + "GOLUB CAPITAL BDC, Inc.: identifier='1476765'", + "Golub Capital BDC 4, Inc.: identifier='1901612'", + "GOLUB CAPITAL DIRECT LENDING CORP: identifier='1868878'", + "Golub Capital Private Credit Fund: identifier='1930087'", + ] + bdcs.portfolio_investments.assert_not_called()