diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b5fb7f..5d76494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - CLI: `contacts search`, `count` and `discover` keep `--email-validated/--no-email-validated` so existing scripts keep running. `--email-validated` is deprecated and treated as `--has-email` (an explicit `--has-email` or `--no-has-email` takes precedence) and prints a deprecation warning on stderr; `--no-email-validated` warns and has no effect. Neither sends `email_validated`. `bulk contacts` keeps its `email_validated` CSV column, empty unless the contact was verified. - SDK (note for maintainers): `email_validated` was removed from `discolike/_generated/requests.py` by hand, ahead of the platform deploy that marks it deprecated in the OpenAPI spec (the generator drops deprecated fields). Until the deployed spec carries that flag, `scripts/gen_requests.py --check` reports the three fields as a diff and `scripts/check_contract.py` reports them as undeclared params; that is the pending release, not drift. - SDK (note for maintainers): `scripts/check_contract.py` now skips a query parameter marked deprecated inside its `schema`, which is where FastAPI puts a deprecated model field. `scripts/gen_requests.py` already dropped those, so the two scripts disagreed on GET routes. +- CLI: `prospecting plan RUN_ID --plan-version N` changes a proposed plan before approval, wrapping `update_plan()`: `--contact-integration-id`, `--validation-integration-id`, `--search-provider-id` (`none` skips web research), `--target-companies`, `--contacts-per-company`, `--deliverable`, `--goal`, `--max-spend-usd` and `--max-provider-spend-usd` (`0` removes a limit), sending only the flags passed. When an organization has several AI or search providers and none is the default, the plan leaves that engine at `selected: null` and `approve` returns 409 until one is picked; `plan` is how a CLI user picks it. The run comes back at a new `plan_version`, which is the one to approve. ## 0.5.0 (2026-10-06) diff --git a/packages/discolike-cli/README.md b/packages/discolike-cli/README.md index 44aa9ec..d2f58ad 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -55,8 +55,10 @@ discolike bulk contacts --domains-file companies.csv --per-company 10 --summary discolike prospecting start --brief "Find 100 US logistics companies and 3 operations directors each" --idempotency-key logistics-1 discolike prospecting start --brief "Find lookalikes of our customers and their CTOs" --customers-file customers.csv --idempotency-key seeded-1 discolike prospecting wait RUN_ID -# Review the proposed plan, then approve the exact version you saw: -discolike prospecting approve RUN_ID --plan-version 1 +# Review the proposed plan; change its engines or size if needed (returns a new plan_version): +discolike prospecting plan RUN_ID --plan-version 1 --contact-integration-id LLM_ID --search-provider-id SEARCH_ID +# Then approve the exact version you saw: +discolike prospecting approve RUN_ID --plan-version 2 discolike prospecting list --limit 20 discolike prospecting message RUN_ID --text "Make it 250 companies" --idempotency-key logistics-edit-1 discolike prospecting status RUN_ID --events-after 12 --messages-after 8 --limit 100 @@ -65,6 +67,8 @@ discolike prospecting cancel RUN_ID `wait` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. A timeout stops polling only. Inspect status and stop reason; completion does not guarantee full coverage. Message replies arrive through `status --messages-after`; follow `next_message_seq` and `reply_pending`. A "segment these" request keeps `reply_pending` true until the segments message is posted, which can take more than an hour. If grouping stops without an outcome, the flag clears on its own after about 90 minutes. +`plan` changes a proposed plan before approval and sends only the flags you pass: `--contact-integration-id`, `--validation-integration-id` and `--search-provider-id` take option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`--search-provider-id none` skips web research), and `--target-companies`, `--contacts-per-company`, `--deliverable`, `--goal`, `--max-spend-usd` and `--max-provider-spend-usd` (`0` removes a limit) resize or re-scope it. When your organization has several AI or search providers and none is the default, the plan shows that engine with `selected: null` and `approve` returns 409 until `plan` picks one. The run comes back re-estimated at a new `plan_version`; approve that one. + `start` and `approve` default to pausing at checkpoints, like the web chat: a pilot check on large lists (`pilot`), a search drifting off target (`tail_quality`), candidates running out short of the target (`short`), the target being met (`target_reached`), another round of companies when the ones checked yield too few contacts (`top_up`), and, on wave runs, the researched contacts found before their emails are looked up (`contacts_review`, asked with `--auto` too; contacts already in DiscoLike's database get their lookups during research, which approving the plan starts; picking "Find emails" by number sends the question's `seq`, which the API needs to accept it). Pass `--auto` to never pause (except at a `top_up` round that would pass a spending limit); a poor pilot is then sharpened once and the run continues with a notice, stopping with `pilot_failed` only if the re-pilot fit is still under 20%. If sharpening itself fails, the run continues on the original criteria. On a terminal, `wait` shows the question, any sample companies and numbered replies at a checkpoint, sends your pick or your own text, and keeps waiting. Without a terminal, or with `--no-input`, it prints the run on stdout, a `needs_input` envelope (`message`, `stop_reason`, `suggested_replies`, `sample`) on stderr, and exits 7; answer with `prospecting message --text ""` (add `--question-seq` with the question's `seq` to answer only that question) and run `wait` again. Omit `--target-companies` and `--contacts-per-company` to infer counts from the brief (fallback 1,000 and 3; up to 10 contacts per company). Explicit values override the text. `--deliverable accounts` returns the checked companies only, with no people; `--goal companies` takes `--target-companies` matching companies and works only those instead of adding rounds until the contact target is met. Both default to `leads`. `--max-candidates` and `--max-actions` are automatic when omitted or `0`; their maxima are 100,000 and 10,000. Targets allow up to 10,000 companies, status pages up to 500 rows, and lists up to 50 runs. Work caps do not cap provider charges. diff --git a/packages/discolike-cli/src/discolike_cli/prospecting.py b/packages/discolike-cli/src/discolike_cli/prospecting.py index 334e9ad..e017a0c 100644 --- a/packages/discolike-cli/src/discolike_cli/prospecting.py +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -19,6 +19,7 @@ from discolike.requests import ProspectingGetParams from discolike.requests import ProspectingListParams from discolike.requests import ProspectingMessageRequest +from discolike.requests import ProspectingPlanSettings from discolike.resources.prospecting import ProspectingMessage from discolike.resources.prospecting import ProspectingRun from discolike_cli._inputs import read_domains_file @@ -43,6 +44,18 @@ SEED_SEGMENT_HELP = ( "A customer segment id from the plan card to expand (repeatable); omitted keeps the card's selection." ) +PLAN_DELIVERABLE_HELP = "leads finds people at the companies; accounts returns the checked companies only." +PLAN_GOAL_HELP = ( + "leads keeps adding rounds until the target is met; companies works only --target-companies matching companies." +) +SEARCH_PROVIDER_HELP = "An option id from the plan's search_provider; none skips web research." +SPEND_HELP_SUFFIX = "0 removes the limit." +PLAN_HELP = ( + "Change a proposed plan's engines, size, deliverable or spending limits before approving it. Flags left out " + "keep their current choice; engine ids come from the latest plan message's contact_engine, " + "company_check_engine and search_provider options, and an engine shown with selected null must be picked " + "here before approve. The run comes back re-estimated at a new plan_version: approve that one." +) RESULTS_SEGMENT_HELP = "Group the finished results into segments; omitted keeps the brief's setting." QUESTION_SEQ_HELP = ( "The seq of the question this suggested reply answers; if a newer question was posted since, it answers none." @@ -322,7 +335,10 @@ def approve_command( seed_segment: list[int] | None = typer.Option(None, "--seed-segment", help=SEED_SEGMENT_HELP), segment: bool | None = typer.Option(None, "--segment/--no-segment", help=RESULTS_SEGMENT_HELP), ) -> None: - """Approve the reviewed plan version and start research.""" + """Approve the reviewed plan version and start research. + + A plan with an engine left unselected (selected null) returns 409 until `prospecting plan` picks one. + """ from discolike_cli.main import get_client emit( @@ -342,6 +358,65 @@ def approve_command( ) +@app.command("plan", help=PLAN_HELP) +@handle_errors +def plan_command( + ctx: typer.Context, + run_id: str = typer.Argument(...), + plan_version: int = typer.Option(..., "--plan-version", min=1, help="The plan version you reviewed."), + contact_integration_id: str | None = typer.Option( + None, "--contact-integration-id", help="An option id from the plan's contact_engine." + ), + validation_integration_id: str | None = typer.Option( + None, "--validation-integration-id", help="An option id from the plan's company_check_engine." + ), + search_provider_id: str | None = typer.Option(None, "--search-provider-id", help=SEARCH_PROVIDER_HELP), + target_companies: int | None = typer.Option( + None, "--target-companies", min=1, max=10000, help="Qualified companies to aim for." + ), + contacts_per_company: int | None = typer.Option( + None, "--contacts-per-company", min=1, max=10, help="Most contacts to find at each company." + ), + deliverable: str | None = typer.Option(None, "--deliverable", help=PLAN_DELIVERABLE_HELP), + goal: str | None = typer.Option(None, "--goal", help=PLAN_GOAL_HELP), + max_spend_usd: float | None = typer.Option( + None, + "--max-spend-usd", + min=0, + help=f"USD limit on DiscoLike records and per-call fees at your plan's rates; {SPEND_HELP_SUFFIX}", + ), + max_provider_spend_usd: float | None = typer.Option( + None, + "--max-provider-spend-usd", + min=0, + help=f"USD limit on your own AI and search provider keys; {SPEND_HELP_SUFFIX}", + ), +) -> None: + from discolike_cli.main import get_client + + emit( + get_client(ctx).prospecting.update_plan( + run_id, + build_request( + ProspectingPlanSettings, + _merge_params( + None, + plan_version=plan_version, + contact_integration_id=contact_integration_id, + validation_integration_id=validation_integration_id, + search_provider_id=search_provider_id, + target_companies=target_companies, + contacts_per_company=contacts_per_company, + deliverable=deliverable, + goal=goal, + max_spend_usd=max_spend_usd, + max_provider_spend_usd=max_provider_spend_usd, + ), + ), + ) + ) + + @app.command("message") @handle_errors def message_command( diff --git a/packages/discolike-cli/tests/test_prospecting_cli.py b/packages/discolike-cli/tests/test_prospecting_cli.py index 5012f77..094c58a 100644 --- a/packages/discolike-cli/tests/test_prospecting_cli.py +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -189,6 +189,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: "arguments", [ ["approve", RUN_ID], + ["plan", RUN_ID], + ["plan", RUN_ID, "--plan-version", "1", "--contacts-per-company", "11"], + ["plan", RUN_ID, "--plan-version", "1", "--deliverable", "people"], + ["plan", RUN_ID, "--plan-version", "1", "--max-spend-usd", "-1"], ["message", RUN_ID, "--text", "Continue"], ["message", RUN_ID, "--text", "Continue", "--idempotency-key", "k", "--question-seq", "0"], ["list", "--limit", "51"], @@ -455,3 +459,91 @@ def handler(request: httpx2.Request) -> httpx2.Response: {"plan_version": 1, "checkpoints": "ask", "seed_segments": [1, 2], "segment": True}, {"plan_version": 1, "checkpoints": "ask"}, ] + + +def test_plan_sends_only_the_settings_passed(install_build_client: Callable[[Handler], None]) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=run_payload("proposed") | {"plan_version": 4}) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "plan", + RUN_ID, + "--plan-version", + "3", + "--contact-integration-id", + "llm-1", + "--search-provider-id", + "none", + ], + ) + assert result.exit_code == 0, result.output + assert seen[0].method == "PATCH" + assert seen[0].url.path.endswith(f"/prospecting/runs/{RUN_ID}/plan") + assert json.loads(seen[0].content) == { + "plan_version": 3, + "contact_integration_id": "llm-1", + "search_provider_id": "none", + } + printed = json.loads(result.stdout) + assert (printed["run_id"], printed["plan_version"]) == (RUN_ID, 4) + + +def test_plan_forwards_every_setting(install_build_client: Callable[[Handler], None]) -> None: + bodies = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(200, json=run_payload("proposed") | {"plan_version": 3}) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "plan", + RUN_ID, + "--plan-version", + "2", + "--contact-integration-id", + "llm-1", + "--validation-integration-id", + "llm-2", + "--search-provider-id", + "search-1", + "--target-companies", + "250", + "--contacts-per-company", + "5", + "--deliverable", + "accounts", + "--goal", + "companies", + "--max-spend-usd", + "0", + "--max-provider-spend-usd", + "12.5", + ], + ) + assert result.exit_code == 0, result.output + assert bodies == [ + { + "plan_version": 2, + "contact_integration_id": "llm-1", + "validation_integration_id": "llm-2", + "search_provider_id": "search-1", + "target_companies": 250, + "contacts_per_company": 5, + "deliverable": "accounts", + "goal": "companies", + "max_spend_usd": 0.0, + "max_provider_spend_usd": 12.5, + } + ] + assert json.loads(result.stdout)["plan_version"] == 3