From fca47be7a73105aa9caace3d9162b35fe2b1c048 Mon Sep 17 00:00:00 2001 From: Jan Scheffler Date: Fri, 10 Jul 2026 16:58:51 +0200 Subject: [PATCH] fix: deal name search param + document verified filter formats - Deal name search: q_keywords is silently ignored by /opportunities/search; q_opportunity_name actually filters. DEAL_SEARCH_FILTERS now allows q_opportunity_name (and rejects q_keywords). +test. - Enriched search_contacts/accounts/deals/people docstrings with the empirically-verified accepted formats (seniority enums, 'min,max' employee ranges, location formats, dict ranges, email-status values, canonical linkedin_url, deal name param, sort fields). - Added a Search-filter formats note to CLAUDE.md quirks. All formats were live-verified (each filter measurably reduces results). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 9 ++++ src/qodev_apollo_api/client.py | 76 ++++++++++++++++++++++++++++++---- tests/test_client.py | 14 +++++++ 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 576d83b..44f5440 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,6 +115,15 @@ Apollo stores notes in ProseMirror JSON format. This library automatically conve - This once caused a wrong company to be attached to a deal + a duplicate account. - `search_accounts` guards against it: it validates `**filters` against `ACCOUNT_SEARCH_FILTERS` (`q_organization_name`, `account_stage_ids`, `account_label_ids`, `sort_by_field`, `sort_ascending`) and raises `ValueError` on unknown keys. Same silent-drop risk applies to the other `search_*` methods — pass only documented filters. +**Search-filter formats (empirically verified — a wrong format is silently ignored or matches nothing, never an error):** +- **Deal name search is `q_opportunity_name`, NOT `q_keywords`.** Apollo silently ignores `q_keywords` on `/opportunities/search` (a nonsense keyword returns *every* deal). `q_opportunity_name` is in `DEAL_SEARCH_FILTERS`; `q_keywords` is not (passing it raises). +- **`organization_num_employees_ranges`** (people): `"min,max"` **comma** strings, e.g. `["1,10"]`, `["1000,5000"]`. A dash (`"1-10"`) is silently ignored (returns baseline). +- **`person_seniorities`** (people): lowercase enums — `owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern`. Uppercase / free text match 0 rows. +- **`person_locations` / `organization_locations`**: country name, 2-letter code (`"US"` == `"United States"`), or `"City, State, Country"`. +- **`revenue_range` / `organization_num_jobs_range`**: dict `{"min": int, "max": int}`. **`contact_email_status`**: `verified | unverified | likely to engage | unavailable`. +- **`contacts` `linkedin_url`**: must be Apollo's canonical form `http://www.linkedin.com/in/` (http, lowercased, url-encoded) or it matches nothing. +- Full per-filter formats live in each `search_*` docstring. + **`opportunities/update_roles` needs the role type NESTED under a `role` array:** - Correct per-entry shape: `{"contact_id": …, "is_primary": …, "role": [{"opportunity_contact_role_type_id": …, "is_primary": …}]}`. - Sending `opportunity_contact_role_type_id` flat on the entry (no `role` key) makes Apollo 422 with `undefined method 'map' for nil`. diff --git a/src/qodev_apollo_api/client.py b/src/qodev_apollo_api/client.py index 934dc2c..88994eb 100644 --- a/src/qodev_apollo_api/client.py +++ b/src/qodev_apollo_api/client.py @@ -83,7 +83,10 @@ ) DEAL_SEARCH_FILTERS = frozenset( { - "q_keywords", + # Deal *name* search is q_opportunity_name — NOT q_keywords, which Apollo + # silently ignores for /opportunities/search (verified: a nonsense keyword + # still returned every deal). + "q_opportunity_name", "opportunity_stage_ids", "sort_by_field", "sort_ascending", @@ -290,7 +293,18 @@ async def search_contacts( Args: page: Page number (default 1) limit: Results per page (default 100, max 100) - **filters: Additional filters (q_keywords, contact_stage_ids, linkedin_url, etc.) + **filters: Contact search filters (see ``CONTACT_SEARCH_FILTERS``): + + - ``q_keywords`` (str): free-text over name / title / company / email. + - ``contact_stage_ids`` (list[str]): stage IDs (see ``get_contact_stages``). + - ``contact_label_ids`` (list[str]): label / list IDs. + - ``linkedin_url`` (str): must be Apollo's **canonical** form — + ``http://www.linkedin.com/in/`` (http, lowercased, + url-encoded). A near-miss silently matches nothing. + - ``sort_by_field`` (str): ``contact_last_activity_date`` | + ``contact_email_last_opened_at`` | ``contact_email_last_clicked_at`` | + ``contact_created_at`` | ``contact_updated_at``. + - ``sort_ascending`` (bool). Returns: Paginated response with Contact items @@ -448,10 +462,14 @@ async def search_accounts( Args: page: Page number (default 1) limit: Results per page (default 100, max 100) - **filters: Search filters. Must be keys Apollo actually supports - (see ``ACCOUNT_SEARCH_FILTERS``): ``q_organization_name``, - ``account_stage_ids``, ``account_label_ids``, ``sort_by_field``, - ``sort_ascending``. + **filters: Account search filters (see ``ACCOUNT_SEARCH_FILTERS``): + + - ``q_organization_name`` (str): company-name keyword. + - ``account_stage_ids`` (list[str]): account stage IDs. + - ``account_label_ids`` (list[str]): label / list IDs. + - ``sort_by_field`` (str): ``account_last_activity_date`` | + ``account_created_at`` | ``account_updated_at``. + - ``sort_ascending`` (bool). Returns: Paginated response with Account items @@ -500,7 +518,15 @@ async def search_deals( Args: page: Page number (default 1) limit: Results per page (default 100, max 100) - **filters: Additional filters (opportunity_stage_ids, q_keywords, etc.) + **filters: Deal search filters (see ``DEAL_SEARCH_FILTERS``): + + - ``q_opportunity_name`` (str): deal-name keyword. **Use this, not + ``q_keywords``** — Apollo silently ignores ``q_keywords`` on + ``/opportunities/search`` (it returns every deal). + - ``opportunity_stage_ids`` (list[str]): deal stage IDs (see + ``list_all_stages``). + - ``sort_by_field`` (str): ``amount`` | ``is_closed`` | ``is_won``. + - ``sort_ascending`` (bool). Returns: Paginated response with Deal items @@ -755,8 +781,40 @@ async def search_people(self, **filters) -> dict: require a separate enrichment/reveal step and consume credits). Args: - **filters: Search filters (q_keywords, person_titles, person_seniorities, - person_locations, q_organization_domains_list, etc.). + **filters: People search filters (see ``PEOPLE_SEARCH_FILTERS``). + Formats matter — a wrong format is silently ignored or matches + nothing rather than erroring: + + - ``q_keywords`` (str): free text. + - ``person_titles`` (list[str]): job titles, e.g. ``["CEO", "VP Sales"]``. + ``include_similar_titles`` (bool): broaden to related titles. + - ``person_seniorities`` (list[str]): **lowercase enums** — + ``owner, founder, c_suite, partner, vp, head, director, manager, + senior, entry, intern``. Uppercase / free text (``"VP"``, + ``"vice president"``) match **0** rows. + - ``person_locations`` / ``organization_locations`` (list[str]): + person / company location. Accepts a country name, a 2-letter + country code (``"US"`` == ``"United States"``), or + ``"City, State, Country"``. + - ``organization_num_employees_ranges`` (list[str]): company-size + buckets as **"min,max"** strings, e.g. ``["1,10"]``, + ``["1000,5000"]``. A dash (``"1-10"``) is **silently ignored**. + - ``q_organization_domains_list`` (list[str]): company domains, + e.g. ``["acme.com"]``. ``organization_ids`` (list[str]): Apollo org IDs. + - ``contact_email_status`` (list[str]): ``verified``, ``unverified``, + ``likely to engage``, ``unavailable``. + - ``revenue_range`` / ``organization_num_jobs_range`` (dict): + ``{"min": int, "max": int}``. + - ``organization_job_posted_at_range`` (dict): ``{"min": "YYYY-MM-DD", + "max": "YYYY-MM-DD"}``. + - ``currently_using_any_of_technology_uids`` / + ``currently_using_all_of_technology_uids`` / + ``currently_not_using_any_of_technology_uids`` (list[str]): tech + UIDs, e.g. ``["salesforce"]``. + - ``q_organization_job_titles`` (list[str]), + ``organization_job_locations`` (list[str]). + - ``page`` / ``per_page`` (int): pagination (this method has no + explicit page/limit args — pass them as filters). Returns: Raw Apollo response dict: ``people`` (list) and ``total_entries`` (int). diff --git a/tests/test_client.py b/tests/test_client.py index 4e49653..e11d3de 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -368,6 +368,20 @@ async def test_search_deals(client: ApolloClient): assert isinstance(result.items[0], Deal) assert result.items[0].name == "Big Deal" + +async def test_search_deals_name_filter_is_q_opportunity_name(client: ApolloClient): + """Deal name search is ``q_opportunity_name``; ``q_keywords`` is silently ignored + by Apollo for /opportunities/search, so it's not an accepted filter (raises).""" + client._client.request.return_value = _make_response( + {"opportunities": [], "pagination": {"total_entries": 0}} + ) + + await client.search_deals(q_opportunity_name="NORRIQ") + assert client._client.request.call_args[1]["json"]["q_opportunity_name"] == "NORRIQ" + + with pytest.raises(ValueError, match="Unknown deal search filter"): + await client.search_deals(q_keywords="NORRIQ") + assert client._client.request.call_args[0] == ("POST", "/opportunities/search")