diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f060a0..06e925e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ - **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()` and `EmailBatch.results()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged; the client constructor, `with_options(timeout=...)` and the CLI's `--timeout` keep their names. - SDK: response enums such as `JobStatus` are open, so a value a newer API adds parses instead of failing. Compare against the documented values and treat anything else as unknown. - SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, renames, cancels, deletes and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. `rename()` retitles a run in any status with `ProspectingRunUpdate(title=...)` and returns its `list()` summary; result lists it already saved keep their names. `cancel()` stops a run and keeps it; `delete()` cancels an active run, then removes it from `list()` and `get()` (charges already incurred stay). The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. +- SDK: `client.prospecting.update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` (sync and async) changes a proposed plan's contact engine, company-check engine, search provider (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) before approval, then returns the run with the re-estimated plan at a new `plan_version`. Prospecting `stop_reason` gains `candidate_limit` (candidate cap reached) and `credit_limit` (stopped at the spending limit, results kept); `candidates_exhausted` now means only that the search ran dry. `stop_reason` stays an open string. Runs without a chosen contact engine research on DiscoLike Groove, or find indexed contacts only with no search provider. +- SDK: `client.prospecting.answer_intake(run_id, answers, idempotency_key=..., summary=None)` (sync and async) answers the guided-intake card a run asks before it drafts a plan. `answers` maps a question key (`company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`) to `IntakeAnswer(values=[...], other="...")`; the card's fields are on the `kind="question"` message's `data`. `ProspectingMessageRequest.text` is now optional, so a message can carry `intake` alone. +- SDK: `ProspectingInFlight` gains `progress` (0-100 or `None`) for the running item. ## 0.4.1 (2026-09-23) diff --git a/README.md b/README.md index dd04922..d69e039 100644 --- a/README.md +++ b/README.md @@ -431,7 +431,7 @@ REST starts in `drafting`, then waits at `proposed` for approval. Review the pla ```python from discolike.requests import ( ProspectingApproveRequest, ProspectingBrief, ProspectingGetParams, - ProspectingListParams, ProspectingMessageRequest, ProspectingRunUpdate, + ProspectingListParams, ProspectingMessageRequest, ProspectingPlanSettings, ProspectingRunUpdate, ) run = client.prospecting.start( @@ -453,6 +453,8 @@ page = client.prospecting.get( run.run_id, ProspectingGetParams(offset=0, limit=100, events_after=run.next_event_seq, messages_after=run.next_message_seq), ) +# Pick engines or a credit limit from the plan card's options before approving; approve the returned plan_version. +# run = client.prospecting.update_plan(run.run_id, ProspectingPlanSettings(plan_version=run.plan_version, max_spend_usd=25)) # client.prospecting.rename(run.run_id, ProspectingRunUpdate(title="Logistics ops leaders")) # Any status. # client.prospecting.cancel(run.run_id) # Stop the run; it and its results stay readable. # client.prospecting.delete(run.run_id) # Cancel if active, then remove it from list() and get(). @@ -462,8 +464,12 @@ The async client exposes the same methods with `await`. Starts and messages requ `wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. Inspect `status`, `stop_reason`, and `error`; completion does not guarantee the target was reached. A local timeout stops polling only. Partial results remain available. Use `get()` with event and message cursors to receive the agent's reply after sending a message; `reply_pending` indicates a pending reply. After a "segment these" request on a finished run it stays true past the acknowledgement until the segments message is posted, which can take more than an hour, and clears on its own after about 90 minutes if grouping stops without an outcome. A `needs_input` question can be answered with `message()`. -Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and checks again, then stops with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. +Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and checks again, then stops with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief. A search drifting off target is dropped, a run whose search runs dry finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. + +Intake: a run can pause on a `kind="question"` message whose `data` lists a few option fields (keys `company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`). Answer it with `client.prospecting.answer_intake(run.run_id, {"company_activity": IntakeAnswer(values=["sell"]), "geography": IntakeAnswer(other="Ohio")}, idempotency_key=...)`: `values` are the option values you picked and `other` is free text. `IntakeAnswer` comes from `discolike.requests`. Invalid answers return 422; an answer sent after the card was superseded returns 409. Typing free text with `message()` instead still works. Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 1,000 companies and 1 contact per company. Explicit settings, including explicit defaults, override the text. Targets support 1–10,000 companies and 1–5 contacts per company. Candidate and action caps default to automatic (`0`); explicit maxima are 100,000 candidates and 10,000 actions. Result pages support up to 500 rows; recent-run lists support up to 50. Approved runs expose a stable `saved_query_id` for saved results. +Run controls: `update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan before approval. `contact_integration_id`, `validation_integration_id` and `search_provider_id` take an option id from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`data.contact_engine.options` and so on); omitted fields keep their choice, `search_provider_id="none"` skips web research, and `max_spend_usd` sets the most the run may spend on DiscoLike records and per-request fees, in USD at your plan's rates (`0` removes the limit; a plan with no per-record price is a 422). The plan is re-estimated and posted again at a new `plan_version`, and that is the version to approve. An id outside the plan's options is a 422; a run that is not awaiting approval, or a stale `plan_version`, is a 409. With no engine chosen, contact research runs on DiscoLike Groove when you have a search provider and finds indexed contacts only when you have none. New `stop_reason` values, all plain strings: `candidate_limit` (the run reached its candidate cap; raise `max_candidates` or ask for fewer companies), `credit_limit` (the run stopped at its spending limit and kept its results), and `candidates_exhausted` now means only that the search ran dry. An automatic candidate cap may grow once from the run's measured yield; a `max_candidates` you set never does. Interrupted indexed contact searches are rerun rather than skipped. + Existing processing charges and configured BYOK/BYOS integrations apply. Wizard interpretation, segmentation, and prompt preparation use platform credentials. Agent coordination and independent contact qualification use your contact LLM integration; native contacts use your validation LLM integration or organization default, so this workflow requires a customer LLM even with native extraction. Missing keys and provider errors never fall back to platform keys. Limits bound work, not provider dollar spend. Email finder outcomes are exposed; raw email verification is not a public API. diff --git a/packages/discolike-cli/tests/test_sdk_parity.py b/packages/discolike-cli/tests/test_sdk_parity.py index ba1b1c3..a4a86e5 100644 --- a/packages/discolike-cli/tests/test_sdk_parity.py +++ b/packages/discolike-cli/tests/test_sdk_parity.py @@ -25,6 +25,7 @@ "ContactsCountParams": frozenset({"icp_text"}), "ContactFilters": frozenset({"icp_text"}), "FindEmailBatchRequest": frozenset({"source_query_id", "refs", "round"}), + "ProspectingMessageRequest": frozenset({"intake"}), } # ``discolike bulk`` takes the full vocabulary through --params-file / --param and manages the paging # fields itself; its flags are the handful a volume run needs, not one per SDK field. diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 76052d0..769d3b1 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -128,6 +128,8 @@ Two errors are specific to the native engine: a 400 `ValidationError` when the I `wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`; timeout stops local polling only. Use `message(run_id, ProspectingMessageRequest(text="..."), idempotency_key="...")` to steer or answer a question, and `get(run_id, ProspectingGetParams(events_after=..., messages_after=...))` for new events and replies. `list(ProspectingListParams(limit=20))` lists recent organization runs, up to 50. `rename(run_id, ProspectingRunUpdate(title="..."))` retitles a run in any status and returns its `list()` summary (titles are 1-80 characters after trimming; result lists it already saved keep their names). `cancel(run_id)` stops a run and returns it; `delete(run_id)` cancels an active run, then removes it so `list()` omits it and `get()` raises `NotFoundError` (charges already incurred stay). The async client has the same methods with `await`. Import these request models from `discolike.requests`. +`update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan's `contact_integration_id`, `validation_integration_id`, `search_provider_id` (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) using option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider`, then returns the run with a re-estimated plan at a new `plan_version` to approve. An unknown option id is a 422; a run not awaiting approval or a stale `plan_version` is a 409. `stop_reason` also reports `candidate_limit` and `credit_limit`; `candidates_exhausted` means only that the search ran dry. + Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and continues the run with a notice. Only if the re-pilot fit is still under 20% does it stop with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief in that case. If sharpening itself fails, the run continues on the original criteria. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`). The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. Omit target counts to infer them from the brief; explicit values override the text. Work caps default to automatic (`0`). Partial results and `saved_query_id` remain available after stopping. Large results are split into several saved contact lists rather than being cut off; `saved_query_ids` carries every list for the run in order, with `saved_query_id` always the first entry, and the parts are final once the run reaches a terminal status. Customer integration charges apply; work caps do not cap provider dollar spend. diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index c1a38ee..34b1468 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2915,10 +2915,6 @@ class ProspectingListParams(DiscolikeRequest): ] = None -class ProspectingMessageRequest(DiscolikeRequest): - text: Annotated[str, Field(max_length=4000, min_length=1, title="Text")] - - class ProspectingBrief(DiscolikeRequest): brief: Annotated[str, Field(max_length=4000, min_length=10, title="Brief")] domains: Annotated[list[str] | None, Field(max_length=1000, title="Domains")] = None @@ -2956,6 +2952,42 @@ class ProspectingGetParams(DiscolikeRequest): messages_after: Annotated[int | None, Field(ge=0, title="Messages After")] = 0 +class ProspectingPlanSettings(DiscolikeRequest): + plan_version: Annotated[int, Field(ge=1, title="Plan Version")] + contact_integration_id: Annotated[ + str | None, + Field( + description="Contact research engine: an option id from the plan's contact_engine.", + max_length=128, + title="Contact Integration Id", + ), + ] = None + validation_integration_id: Annotated[ + str | None, + Field( + description="Company check engine: an option id from the plan's company_check_engine.", + max_length=128, + title="Validation Integration Id", + ), + ] = None + search_provider_id: Annotated[ + str | None, + Field( + description="Search provider: an option id from the plan's search_provider; 'none' skips web research.", + max_length=128, + title="Search Provider Id", + ), + ] = None + max_spend_usd: Annotated[ + float | None, + Field( + description="Most the run may spend on DiscoLike records and per-call fees, in USD at your plan's rates, before it stops; 0 removes the limit.", + ge=0.0, + title="Max Spend Usd", + ), + ] = None + + class ProspectingRunUpdate(DiscolikeRequest): title: Annotated[str, Field(max_length=80, min_length=1, title="Title")] @@ -3179,6 +3211,11 @@ class BulkContactMatchQueryItem(DiscolikeRequest): ] = None +class IntakeAnswer(DiscolikeRequest): + values: Annotated[list[str] | None, Field(title="Values")] = None + other: Annotated[str | None, Field(max_length=200, title="Other")] = None + + class BulkContactMatchRequest(DiscolikeRequest): queries: Annotated[ list[BulkContactMatchQueryItem], @@ -3207,3 +3244,15 @@ class ProspectingApproveRequest(DiscolikeRequest): checkpoints: Literal["ask", "auto"] | None = None seed_segments: Annotated[list[int] | None, Field(min_length=1, title="Seed Segments")] = None segment: Annotated[bool | None, Field(title="Segment")] = None + + +class ProspectingMessageRequest(DiscolikeRequest): + text: Annotated[str | None, Field(max_length=4000, min_length=1, title="Text")] = None + intake: Annotated[ + dict[ + Literal["company_activity", "industry", "geography", "company_size", "persona_roles", "list_size"], + IntakeAnswer, + ] + | None, + Field(title="Intake"), + ] = None diff --git a/packages/discolike/src/discolike/requests.py b/packages/discolike/src/discolike/requests.py index 39ef20f..7104691 100644 --- a/packages/discolike/src/discolike/requests.py +++ b/packages/discolike/src/discolike/requests.py @@ -24,6 +24,7 @@ from discolike._generated.requests import DiscoverParams from discolike._generated.requests import FindEmailBatchRequest from discolike._generated.requests import FindEmailRequest +from discolike._generated.requests import IntakeAnswer from discolike._generated.requests import LLMProviderCreateRequest from discolike._generated.requests import LLMProviderUpdateRequest from discolike._generated.requests import MatchBulkParams @@ -33,6 +34,7 @@ from discolike._generated.requests import ProspectingGetParams from discolike._generated.requests import ProspectingListParams from discolike._generated.requests import ProspectingMessageRequest +from discolike._generated.requests import ProspectingPlanSettings from discolike._generated.requests import ProspectingRunUpdate from discolike._generated.requests import QueriesListParams from discolike._generated.requests import SaveResultsRequest @@ -67,6 +69,7 @@ "DiscoverParams", "FindEmailBatchRequest", "FindEmailRequest", + "IntakeAnswer", "LLMProviderCreateRequest", "LLMProviderUpdateRequest", "MatchBulkParams", @@ -76,6 +79,7 @@ "ProspectingGetParams", "ProspectingListParams", "ProspectingMessageRequest", + "ProspectingPlanSettings", "ProspectingRunUpdate", "QueriesListParams", "SaveResultsRequest", diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index d49499e..fe0c1bb 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -13,11 +13,13 @@ from discolike._exceptions import JobTimeoutError from discolike._models import DiscolikeModel +from discolike.requests import IntakeAnswer from discolike.requests import ProspectingApproveRequest from discolike.requests import ProspectingBrief from discolike.requests import ProspectingGetParams from discolike.requests import ProspectingListParams from discolike.requests import ProspectingMessageRequest +from discolike.requests import ProspectingPlanSettings from discolike.requests import ProspectingRunUpdate from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource @@ -33,6 +35,8 @@ Literal["plan", "discover", "validate", "contacts", "generate", "verify", "segment", "seed_segment"] | str ) +IntakeKey = Literal["company_activity", "industry", "geography", "company_size", "persona_roles", "list_size"] + class ProspectingPlan(DiscolikeModel): company_queries: list[dict[str, Any]] = Field(default_factory=list) @@ -84,6 +88,7 @@ class ProspectingInFlight(DiscolikeModel): plan_version: int state: Literal["dispatching", "running"] | str started_at: datetime + progress: int | None = None class ProspectingRunSummary(DiscolikeModel): @@ -162,6 +167,12 @@ class ProspectingRun(DiscolikeModel): ) +def _intake_request(*, answers: dict[IntakeKey, IntakeAnswer], summary: str | None) -> ProspectingMessageRequest: + if summary is None: + return ProspectingMessageRequest(intake=answers) + return ProspectingMessageRequest(text=summary, intake=answers) + + def _key(value: str) -> str: if not value.strip() or len(value) > 128: raise ValueError("idempotency_key must contain 1-128 characters") @@ -214,6 +225,18 @@ def message( ) return ProspectingMessage.model_validate(response.json()) + @api_route("POST", "/prospecting/runs/{run_id}/messages") + def answer_intake( + self, + run_id: str | UUID, + answers: dict[IntakeKey, IntakeAnswer], + *, + idempotency_key: str, + summary: str | None = None, + ) -> ProspectingMessage: + """Answer the intake card on a run; `answers` maps each question key to its picked `values` and/or `other` text.""" + return self.message(run_id, _intake_request(answers=answers, summary=summary), idempotency_key=idempotency_key) + @api_route("POST", "/prospecting/runs") def start(self, request: ProspectingBrief, *, idempotency_key: str) -> ProspectingRun: """Draft a plan for approval; retain the key when retrying this submission.""" @@ -232,6 +255,21 @@ def cancel(self, run_id: str | UUID) -> ProspectingRun: response = self._transport.request("POST", _path(run_id) + "/cancel") return ProspectingRun.model_validate(response.json()) + @api_route("PATCH", "/prospecting/runs/{run_id}/plan") + def update_plan(self, run_id: str | UUID, request: ProspectingPlanSettings) -> ProspectingRun: + """Choose engines and a spending limit for a proposed plan; returns the run with the re-posted plan. + + Fields left unset keep their current choice; ids come from the latest plan message's + contact_engine, company_check_engine and search_provider options, and search_provider_id="none" + skips web research. max_spend_usd (USD for records and per-call fees at the plan's rates) of 0 removes the limit; + engines left unset are re-chosen around the picks. Pass the current plan_version: the plan is + re-estimated at a new plan_version, which is the one to approve. A 422 means an id is not one of the + plan's options or the plan has no per-record price, a 409 that the run is not awaiting approval or + plan_version is stale. + """ + response = self._transport.request("PATCH", _path(run_id) + "/plan", json_body=request.to_wire()) + return ProspectingRun.model_validate(response.json()) + @api_route("PATCH", "/prospecting/runs/{run_id}") def rename(self, run_id: str | UUID, request: ProspectingRunUpdate) -> ProspectingRunSummary: """Rename the run in any status; returns its summary as list() shows it. @@ -307,6 +345,20 @@ async def message( ) return ProspectingMessage.model_validate(response.json()) + @api_route("POST", "/prospecting/runs/{run_id}/messages") + async def answer_intake( + self, + run_id: str | UUID, + answers: dict[IntakeKey, IntakeAnswer], + *, + idempotency_key: str, + summary: str | None = None, + ) -> ProspectingMessage: + """Answer the intake card on a run; `answers` maps each question key to its picked `values` and/or `other` text.""" + return await self.message( + run_id, _intake_request(answers=answers, summary=summary), idempotency_key=idempotency_key + ) + @api_route("POST", "/prospecting/runs") async def start(self, request: ProspectingBrief, *, idempotency_key: str) -> ProspectingRun: response = await self._transport.request( @@ -324,6 +376,21 @@ async def cancel(self, run_id: str | UUID) -> ProspectingRun: response = await self._transport.request("POST", _path(run_id) + "/cancel") return ProspectingRun.model_validate(response.json()) + @api_route("PATCH", "/prospecting/runs/{run_id}/plan") + async def update_plan(self, run_id: str | UUID, request: ProspectingPlanSettings) -> ProspectingRun: + """Choose engines and a spending limit for a proposed plan; returns the run with the re-posted plan. + + Fields left unset keep their current choice; ids come from the latest plan message's + contact_engine, company_check_engine and search_provider options, and search_provider_id="none" + skips web research. max_spend_usd (USD for records and per-call fees at the plan's rates) of 0 removes the limit; + engines left unset are re-chosen around the picks. Pass the current plan_version: the plan is + re-estimated at a new plan_version, which is the one to approve. A 422 means an id is not one of the + plan's options or the plan has no per-record price, a 409 that the run is not awaiting approval or + plan_version is stale. + """ + response = await self._transport.request("PATCH", _path(run_id) + "/plan", json_body=request.to_wire()) + return ProspectingRun.model_validate(response.json()) + @api_route("PATCH", "/prospecting/runs/{run_id}") async def rename(self, run_id: str | UUID, request: ProspectingRunUpdate) -> ProspectingRunSummary: """Rename the run in any status; returns its summary as list() shows it. diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py index 694a1af..5204595 100644 --- a/packages/discolike/tests/test_gen_requests.py +++ b/packages/discolike/tests/test_gen_requests.py @@ -274,5 +274,5 @@ def test_compare_prints_a_diff_and_returns_one_on_drift(gen, capsys) -> None: def test_collect_routes_covers_every_stamped_sync_route(gen) -> None: routes = gen.collect_routes() - assert len(routes) == 56 + assert len(routes) == 57 assert all(not route.class_name.startswith("Async") for route in routes) diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index e4c8e8a..84e3ed2 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -9,13 +9,16 @@ import discolike.resources.prospecting as module from discolike import CHECKPOINT_STOP_REASONS +from discolike import DiscolikeError from discolike import JobTimeoutError from discolike import NotFoundError +from discolike.requests import IntakeAnswer from discolike.requests import ProspectingApproveRequest from discolike.requests import ProspectingBrief from discolike.requests import ProspectingGetParams from discolike.requests import ProspectingListParams from discolike.requests import ProspectingMessageRequest +from discolike.requests import ProspectingPlanSettings from discolike.requests import ProspectingRunUpdate from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -191,6 +194,60 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert json.loads(seen[0].content) == {"title": "Renamed"} +def test_update_plan_patches_only_the_set_fields(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=payload("proposed") | {"plan_version": 2}) + + with make_client(handler) as client: + run = client.prospecting.update_plan( + RUN_ID, ProspectingPlanSettings(plan_version=1, search_provider_id="none", max_spend_usd=0) + ) + assert run.plan_version == 2 + assert [(r.method, r.url.path) for r in seen] == [("PATCH", f"/v1/prospecting/runs/{RUN_ID}/plan")] + assert json.loads(seen[0].content) == {"plan_version": 1, "search_provider_id": "none", "max_spend_usd": 0} + + +@pytest.mark.parametrize("status", [409, 422]) +def test_update_plan_surfaces_rejections(make_client: ClientFactory, status: int) -> None: + with ( + make_client(lambda request: httpx2.Response(status, json={"detail": "rejected"})) as client, + pytest.raises(DiscolikeError), + ): + client.prospecting.update_plan(RUN_ID, ProspectingPlanSettings(plan_version=1, contact_integration_id="x")) + + +def test_update_plan_validates_locally() -> None: + with pytest.raises(ValidationError): + ProspectingPlanSettings(plan_version=1, max_spend_usd=-1) + with pytest.raises(ValidationError): + ProspectingPlanSettings(plan_version=0) + + +async def test_async_update_plan_patches_the_plan(make_async_client: AsyncClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=payload("proposed") | {"plan_version": 3}) + + async with make_async_client(handler) as client: + run = await client.prospecting.update_plan(RUN_ID, ProspectingPlanSettings(plan_version=2, max_spend_usd=25.0)) + assert run.plan_version == 3 + assert [(r.method, r.url.path) for r in seen] == [("PATCH", f"/v1/prospecting/runs/{RUN_ID}/plan")] + assert json.loads(seen[0].content) == {"plan_version": 2, "max_spend_usd": 25.0} + + +@pytest.mark.parametrize("reason", ["candidate_limit", "credit_limit", "a_reason_added_later"]) +def test_stop_reason_stays_an_open_string(make_client: ClientFactory, reason: str) -> None: + with make_client( + lambda request: httpx2.Response(200, json=payload("completed") | {"stop_reason": reason}) + ) as client: + assert client.prospecting.get(RUN_ID).stop_reason == reason + + def test_wait_returns_a_proposed_plan(make_client: ClientFactory) -> None: with make_client(lambda request: httpx2.Response(200, json=payload("proposed"))) as client: assert client.prospecting.wait(RUN_ID).status == "proposed" @@ -429,3 +486,53 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert (run.brief.customer_domains, run.brief.selected_seed_segments) == (customers, [1]) with pytest.raises(ValidationError): ProspectingBrief(brief="Lookalikes of our customers", customer_domains=["acme.com"] * 1001) + + +def test_answer_intake_posts_answers_with_key(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(202, json=message_payload()) + + with make_client(handler) as client: + client.prospecting.answer_intake( + RUN_ID, {"company_activity": IntakeAnswer(values=["sell"])}, idempotency_key="k" + ) + message = client.prospecting.answer_intake( + RUN_ID, + {"company_activity": IntakeAnswer(values=["sell"]), "geography": IntakeAnswer(other="Ohio")}, + idempotency_key="k2", + summary="Sellers in Ohio", + ) + assert seen[0].url.path == f"/v1/prospecting/runs/{RUN_ID}/messages" + assert seen[0].headers["Idempotency-Key"] == "k" + assert json.loads(seen[0].content) == {"intake": {"company_activity": {"values": ["sell"]}}} + assert json.loads(seen[1].content) == { + "text": "Sellers in Ohio", + "intake": {"company_activity": {"values": ["sell"]}, "geography": {"other": "Ohio"}}, + } + assert message.seq == 8 + + +async def test_async_answer_intake(make_async_client: AsyncClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(202, json=message_payload()) + + async with make_async_client(handler) as client: + message = await client.prospecting.answer_intake( + RUN_ID, {"list_size": IntakeAnswer(values=["1000"])}, idempotency_key="async-k" + ) + assert seen[0].headers["Idempotency-Key"] == "async-k" + assert json.loads(seen[0].content) == {"intake": {"list_size": {"values": ["1000"]}}} + assert message.seq == 8 + + +def test_answer_intake_rejects_unknown_key_and_long_other() -> None: + with pytest.raises(ValidationError): + ProspectingMessageRequest.model_validate({"intake": {"bogus": {"values": ["x"]}}}) + with pytest.raises(ValidationError): + IntakeAnswer(other="x" * 201)