diff --git a/CHANGELOG.md b/CHANGELOG.md index 36bad91..7ad405e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- SDK: `validate_icp` can run without an LLM key. Pass `integration_id=NATIVE_ICP_ENGINE` (exported from `discolike`, the string `"native-icp"`) to score with DiscoLike's own ICP-fit model instead of your BYOK LLM: no LLM cost, no LLM key, and no web search on that run. The same sentinel works on `discogen.process` for a prompt that already carries the validation structure. Two new errors are specific to it: a 400 `ValidationError` when the ICP text does not yield a Mandatory / Reject if / Nice-to-have prompt, and a 503 `ServerError` when no ICP-fit engine is available. Task lifecycle, polling and statuses are unchanged. +- SDK: `Job` / `AsyncJob` returned by `validate_icp`, `discogen.process` and `discogen.process_personas` carry `column_name` from the submit response. The engine picks the columns — an LLM validation returns `Fit` / `Confidence` / `Reasoning`, the native model `ICP Fit` (`Yes` / `No` at a 0.50 threshold on the score) / `ICP Score` (the calibrated probability, 0.00-1.00 as a string) / `Reasoning (always null)` — so read it rather than hardcoding either set. It is `None` on a job reattached with `discogen.job(task_id)`. +- CLI: `validate-icp --integration-id native-icp` and `discogen run --integration-id native-icp` select the native ICP-fit model; `discolike validate-icp --help` documents both column sets. +- **Breaking:** the ICP-fit verdict is title case on every surface. An LLM `validate_icp` run's `Fit` column now returns `Yes` / `No` instead of `yes` / `no`, matching the native engine's `ICP Fit` column, DiscoGen and the app. Code that matches the verdict on the exact string `"yes"` has to be updated; compare case-insensitively to survive either. - SDK: `contacts.generate` can run without an LLM key. Pass `integration_id=NATIVE_ENGINE` (exported from `discolike`) for DiscoLike Groove, DiscoLike's native extractor, or omit `integration_id` to get your default LLM integration and native when you have none. The native engine returns every person the search surfaces with a title and does not validate titles against `icp_text`; a search provider is still required. `JobStatus` gains `title_validation` (`"llm"` or `"none"`, `None` on non-ContaGen jobs) so you can tell which engine ran. - SDK: discover and count take `sub_industry` / `negate_sub_industry` (217 second-level labels scoped to their parent industry category; a bare label like `ROOFING` adds `CONSTRUCTION` to `category` server-side) and a multi-shape geo filter: `geo` (`lat,lon` or `lat,lon,radius`), `bbox` (`min_lat,min_lon,max_lat,max_lon`, longitudes may wrap the antimeridian) and `lat` / `lon` / `radius` (`50km`, `30mi`, or a bare number meaning kilometres; defaults to 50km). `geo` and `bbox` are lists, every circle and box is OR'd with the others and with the `lat`/`lon` centre, and a query carries at most 10 shapes. Passing a single `bbox` or `geo` string still works and is sent as a one-element list. The SDK applies the platform's own shape rules before the request leaves the client: a box is four finite numbers with `-90 <= min_lat < max_lat <= 90`, longitudes within ±180 and `min_lon` different from `max_lon`; a circle is a valid coordinate pair with an optional radius above 0 and at most 1000km. - SDK: `CompanyProfile` gains `sub_industry` (label:confidence, `None` when the domain was never scored against its category's sub-labels), `latitude`, `longitude` and `geo_precision`. The coordinates come back under their full names, and a response still using the pre-rename `lat` / `lon` keys parses into them, so the SDK works against an API on either side of that release. The `lat` / `lon` discover and count filters keep their names. diff --git a/README.md b/README.md index 75f00e1..37ff589 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,29 @@ print(result.title_validation) # "none" on the native engine, "llm" on a BYOK r The native engine returns every person the search surfaces with a title and does not validate titles against `icp_text`, so filter them yourself when that matters. Omit `integration_id` to use your default LLM integration, or native when you have none. A search provider is required either way. +### ICP validation without an LLM key + +`validate_icp` runs your ICP text against each domain on your own LLM provider key, or on DiscoLike's own ICP-fit model. Pass `NATIVE_ICP_ENGINE` for the latter — no LLM key, no LLM cost, and no web search on that run: + +```python +from discolike import NATIVE_ICP_ENGINE +from discolike.requests import ValidateIcpRequest + +job = client.validate_icp( + ValidateIcpRequest( + icp_text="Cybersecurity for SMBs in North America, 50-500 employees", + domains=["gusto.com", "rippling.com"], + integration_id=NATIVE_ICP_ENGINE, + ) +) +print(job.column_name) # ["ICP Fit", "ICP Score", "Reasoning"] +result = job.wait() +``` + +The engine decides the result columns, so read `job.column_name` instead of hardcoding them: an LLM run returns `Fit` / `Confidence` / `Reasoning`, the native model `ICP Fit` / `ICP Score` / `Reasoning (always null)`. `ICP Fit` is `Yes` or `No` at a 0.50 threshold on `ICP Score`, the calibrated probability as a 0.00-1.00 string. The native model returns only that score, so `Reasoning` is always `null` — the column is there to keep the set the same shape as an LLM run, not to carry an explanation. `integration_id="native-icp"` also works on `discogen.process` for a prompt that already carries the validation structure. + +Two errors are specific to the native engine: a 400 `ValidationError` when the ICP text does not yield a Mandatory / Reject if / Nice-to-have prompt, and a 503 `ServerError` when no ICP-fit engine is available. Task lifecycle, polling and statuses are the same either way. + ### Error handling All errors inherit from `DiscolikeError`: diff --git a/packages/discolike-cli/src/discolike_cli/_help.py b/packages/discolike-cli/src/discolike_cli/_help.py index fecb5b3..7a9913e 100644 --- a/packages/discolike-cli/src/discolike_cli/_help.py +++ b/packages/discolike-cli/src/discolike_cli/_help.py @@ -160,8 +160,13 @@ def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: Output (success, exit 0): {_JOB_SUBMITTED} With --wait: the results object keyed by domain: - {{: {{"Fit": "yes"|"no", "Confidence": "high"|"medium"|"low", "Reasoning": }}, ...}} + {{: {{"Fit": "Yes"|"No", "Confidence": "high"|"medium"|"low", "Reasoning": }}, ...}} Runs on the account's own LLM provider key. + With --integration-id native-icp: DiscoLike's own ICP-fit model, no LLM key or cost and no + web search. Keys become "ICP Fit" ("Yes"|"No" at a 0.50 threshold on the score), "ICP Score" + (the calibrated probability, 0.00-1.00 as a string) and "Reasoning", always null there: the + model returns a score, not an explanation. A 400 when the ICP text yields no Mandatory / + Reject if / Nice-to-have prompt, a 503 when no ICP-fit engine is available. {_JOB_ERRORS} """, diff --git a/packages/discolike-cli/src/discolike_cli/discogen.py b/packages/discolike-cli/src/discolike_cli/discogen.py index a092997..2203954 100644 --- a/packages/discolike-cli/src/discolike_cli/discogen.py +++ b/packages/discolike-cli/src/discolike_cli/discogen.py @@ -28,7 +28,10 @@ "segment jobs 'segment', contact bulk-match 'contactmatch')." ) QUERY_HELP = "Research query to run." -INTEGRATION_ID_HELP = "Integration ID to use for the run." +INTEGRATION_ID_HELP = ( + "Integration ID to use for the run, or 'native-icp' to score an ICP validation prompt with " + "DiscoLike's own model at no LLM cost." +) WEB_SEARCH_HELP = "Toggle web search during research." CONTEXT_MODE_HELP = "Context mode; see docs.discolike.com." INCLUDE_X_SEARCH_HELP = "Toggle including X search in the research." diff --git a/packages/discolike-cli/src/discolike_cli/enrich.py b/packages/discolike-cli/src/discolike_cli/enrich.py index 0c56c84..89cd238 100644 --- a/packages/discolike-cli/src/discolike_cli/enrich.py +++ b/packages/discolike-cli/src/discolike_cli/enrich.py @@ -21,6 +21,10 @@ WAIT_HELP = "Block until the job finishes, streaming progress to stderr." TIMEOUT_HELP = "Max seconds to wait with --wait." QUERY_ID_HELP = "Saved query ID whose domains are included alongside the file/--domain ones (repeatable)." +INTEGRATION_ID_HELP = ( + "Integration ID to use for the validation, or 'native-icp' to score with DiscoLike's own " + "ICP-fit model at no LLM cost (no LLM key and no web search on that run)." +) @handle_errors @@ -35,9 +39,7 @@ def validate_icp_command( help="CSV with a 'domain' column, or one domain per line (instead of --domain).", ), context_mode: str | None = typer.Option(None, "--context-mode", help="Context mode; see docs.discolike.com."), - integration_id: str | None = typer.Option( - None, "--integration-id", help="Integration ID to use for the validation." - ), + integration_id: str | None = typer.Option(None, "--integration-id", help=INTEGRATION_ID_HELP), web_search: bool | None = typer.Option( None, "--web-search/--no-web-search", help="Toggle web search during validation." ), diff --git a/packages/discolike-cli/tests/test_enrich_cli.py b/packages/discolike-cli/tests/test_enrich_cli.py index 1cb3a3c..dccec9a 100644 --- a/packages/discolike-cli/tests/test_enrich_cli.py +++ b/packages/discolike-cli/tests/test_enrich_cli.py @@ -271,3 +271,23 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert result.exit_code == 0, result.output assert captured[0].url.params.get_list("query_id") == ["q1"] assert captured[1].url.params.get_list("query_id") == ["q2"] + + +def test_validate_icp_sends_native_icp_sentinel(install_build_client: Callable[[Handler], None]) -> None: + captured: dict[str, object] = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + captured["body"] = json.loads(request.content) + return httpx2.Response(200, json={"task_id": "vi-native"}) + + install_build_client(handler) + result = runner.invoke( + app, + ["validate-icp", "--icp", "Cybersecurity for SMBs", "--domain", "acme.com", "--integration-id", "native-icp"], + ) + assert result.exit_code == 0, result.output + assert captured["body"] == { + "icp_text": "Cybersecurity for SMBs", + "domains": ["acme.com"], + "integration_id": "native-icp", + } diff --git a/packages/discolike-cli/tests/test_help.py b/packages/discolike-cli/tests/test_help.py index be8dc8e..d25bbfd 100644 --- a/packages/discolike-cli/tests/test_help.py +++ b/packages/discolike-cli/tests/test_help.py @@ -51,6 +51,15 @@ def test_command_help_documents_output_and_errors(argv: list[str]) -> None: assert any(line.strip().startswith("Common errors:") for line in result.output.splitlines()) +def test_validate_icp_help_documents_a_title_case_verdict() -> None: + result = runner.invoke(app, ["validate-icp", "--help"]) + assert result.exit_code == 0, result.output + assert '"Fit": "Yes"|"No"' in result.output + assert '"ICP Fit" ("Yes"|"No"' in result.output + assert '"yes"' not in result.output + assert '"no"' not in result.output + + def test_every_command_epilog_is_short() -> None: for name, text in _help.COMMAND_EPILOGS.items(): assert len(text.splitlines()) <= 18, name diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 815a2ec..152a58a 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -99,6 +99,29 @@ print(result.title_validation) # "none" on the native engine, "llm" on a BYOK r The native engine returns every person the search surfaces with a title and does not validate titles against `icp_text`, so filter them yourself when that matters. Omit `integration_id` to use your default LLM integration, or native when you have none. A search provider is required either way. +### ICP validation without an LLM key + +`validate_icp` runs your ICP text against each domain on your own LLM provider key, or on DiscoLike's own ICP-fit model. Pass `NATIVE_ICP_ENGINE` for the latter — no LLM key, no LLM cost, and no web search on that run: + +```python +from discolike import NATIVE_ICP_ENGINE +from discolike.requests import ValidateIcpRequest + +job = client.validate_icp( + ValidateIcpRequest( + icp_text="Cybersecurity for SMBs in North America, 50-500 employees", + domains=["gusto.com", "rippling.com"], + integration_id=NATIVE_ICP_ENGINE, + ) +) +print(job.column_name) # ["ICP Fit", "ICP Score", "Reasoning"] +result = job.wait() +``` + +The engine decides the result columns, so read `job.column_name` instead of hardcoding them: an LLM run returns `Fit` / `Confidence` / `Reasoning`, the native model `ICP Fit` / `ICP Score` / `Reasoning (always null)`. `ICP Fit` is `Yes` or `No` at a 0.50 threshold on `ICP Score`, the calibrated probability as a 0.00-1.00 string. The native model returns only that score, so `Reasoning` is always `null` — the column is there to keep the set the same shape as an LLM run, not to carry an explanation. `integration_id="native-icp"` also works on `discogen.process` for a prompt that already carries the validation structure. + +Two errors are specific to the native engine: a 400 `ValidationError` when the ICP text does not yield a Mandatory / Reject if / Nice-to-have prompt, and a 503 `ServerError` when no ICP-fit engine is available. Task lifecycle, polling and statuses are the same either way. + ## Links - **API documentation**: [docs.discolike.com](https://docs.discolike.com) diff --git a/packages/discolike/src/discolike/__init__.py b/packages/discolike/src/discolike/__init__.py index 017974b..f992306 100644 --- a/packages/discolike/src/discolike/__init__.py +++ b/packages/discolike/src/discolike/__init__.py @@ -19,6 +19,7 @@ from discolike._models import DiscolikeRequest from discolike._version import __version__ from discolike.resources.contacts import NATIVE_ENGINE +from discolike.resources.discogen import NATIVE_ICP_ENGINE from discolike.resources.discovery import Company from discolike.resources.discovery import Count from discolike.resources.email import EmailBatchResults @@ -32,6 +33,7 @@ __all__ = [ "NATIVE_ENGINE", + "NATIVE_ICP_ENGINE", "APIConnectionError", "ApiKeyCredential", "AsyncDiscolike", diff --git a/packages/discolike/src/discolike/_jobs.py b/packages/discolike/src/discolike/_jobs.py index c59792e..b3f9355 100644 --- a/packages/discolike/src/discolike/_jobs.py +++ b/packages/discolike/src/discolike/_jobs.py @@ -22,6 +22,11 @@ DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0 DEFAULT_POLL_INTERVAL_SECONDS = 5.0 +# The engine that ran decides the result columns: an LLM validation returns Fit / Confidence / +# Reasoning, the native ICP-fit model ICP Fit / ICP Score / Reasoning, whose Reasoning is always +# null. Read it, never assume. +ColumnName = str | list[str] | None + class JobStatus(DiscolikeModel): status: str @@ -41,10 +46,11 @@ class JobStatus(DiscolikeModel): class Job: - def __init__(self, transport: Transport, *, task_family: str, task_id: str) -> None: + def __init__(self, transport: Transport, *, task_family: str, task_id: str, column_name: ColumnName = None) -> None: self._transport = transport self.task_family = task_family self.task_id = task_id + self.column_name = column_name def status(self) -> JobStatus: response = self._transport.request("GET", f"/{self.task_family}/status/{self.task_id}") @@ -78,10 +84,13 @@ def wait( class AsyncJob: - def __init__(self, transport: AsyncTransport, *, task_family: str, task_id: str) -> None: + def __init__( + self, transport: AsyncTransport, *, task_family: str, task_id: str, column_name: ColumnName = None + ) -> None: self._transport = transport self.task_family = task_family self.task_id = task_id + self.column_name = column_name async def status(self) -> JobStatus: response = await self._transport.request("GET", f"/{self.task_family}/status/{self.task_id}") diff --git a/packages/discolike/src/discolike/resources/discogen.py b/packages/discolike/src/discolike/resources/discogen.py index dae85f6..d6719bd 100644 --- a/packages/discolike/src/discolike/resources/discogen.py +++ b/packages/discolike/src/discolike/resources/discogen.py @@ -1,11 +1,14 @@ from __future__ import annotations +import httpx2 import pydantic from discolike._jobs import FAMILY_DISCOGEN from discolike._jobs import AsyncJob from discolike._jobs import Job from discolike._models import DiscolikeModel +from discolike._transport import AsyncTransport +from discolike._transport import Transport from discolike.requests import DiscoGenPersonaProcessRequest from discolike.requests import DiscoGenProcessRequest from discolike.requests import ValidateIcpRequest @@ -13,6 +16,28 @@ from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route +NATIVE_ICP_ENGINE = "native-icp" + + +def _job(transport: Transport, response: httpx2.Response) -> Job: + payload = response.json() + return Job( + transport, + task_family=FAMILY_DISCOGEN, + task_id=payload["task_id"], + column_name=payload.get("column_name"), + ) + + +def _async_job(transport: AsyncTransport, response: httpx2.Response) -> AsyncJob: + payload = response.json() + return AsyncJob( + transport, + task_family=FAMILY_DISCOGEN, + task_id=payload["task_id"], + column_name=payload.get("column_name"), + ) + class DiscogenModelInfo(DiscolikeModel): name: str | None = None @@ -26,13 +51,17 @@ class DiscogenModels(DiscolikeModel): class DiscogenResource(SyncAPIResource): @api_route("POST", "/discogen/process") def process(self, request: DiscoGenProcessRequest) -> Job: - response = self._transport.request("POST", "/discogen/process", json_body=request.to_wire()) - return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) + """Run a research prompt against a list of domains. + + `integration_id` takes an LLM provider integration UUID, or `NATIVE_ICP_ENGINE` to score + an ICP validation prompt with DiscoLike's own model. Omit it for the org default. + """ + return _job(self._transport, self._transport.request("POST", "/discogen/process", json_body=request.to_wire())) @api_route("POST", "/discogen/process-personas") def process_personas(self, request: DiscoGenPersonaProcessRequest) -> Job: response = self._transport.request("POST", "/discogen/process-personas", json_body=request.to_wire()) - return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) + return _job(self._transport, response) @api_route("GET", "/discogen/models") def models(self) -> DiscogenModels: @@ -45,20 +74,32 @@ def job(self, task_id: str) -> Job: class ValidateResource(SyncAPIResource): @api_route("POST", "/validate/icp") def icp(self, request: ValidateIcpRequest) -> Job: - response = self._transport.request("POST", "/validate/icp", json_body=request.to_wire()) - return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) + """Validate domains against an ICP description. + + `integration_id` takes an LLM provider integration UUID, or `NATIVE_ICP_ENGINE` to score + with DiscoLike's own ICP-fit model: no LLM key, no LLM cost, and no web search on that + run. Omit it for the org default. Read `Job.column_name` for the columns the run returns + rather than assuming the LLM ones; the native run's `Reasoning` column is always null, + since the model returns a score rather than an explanation. + """ + return _job(self._transport, self._transport.request("POST", "/validate/icp", json_body=request.to_wire())) class AsyncDiscogenResource(AsyncAPIResource): @api_route("POST", "/discogen/process") async def process(self, request: DiscoGenProcessRequest) -> AsyncJob: + """Run a research prompt against a list of domains. + + `integration_id` takes an LLM provider integration UUID, or `NATIVE_ICP_ENGINE` to score + an ICP validation prompt with DiscoLike's own model. Omit it for the org default. + """ response = await self._transport.request("POST", "/discogen/process", json_body=request.to_wire()) - return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) + return _async_job(self._transport, response) @api_route("POST", "/discogen/process-personas") async def process_personas(self, request: DiscoGenPersonaProcessRequest) -> AsyncJob: response = await self._transport.request("POST", "/discogen/process-personas", json_body=request.to_wire()) - return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) + return _async_job(self._transport, response) @api_route("GET", "/discogen/models") async def models(self) -> DiscogenModels: @@ -72,5 +113,13 @@ def job(self, task_id: str) -> AsyncJob: class AsyncValidateResource(AsyncAPIResource): @api_route("POST", "/validate/icp") async def icp(self, request: ValidateIcpRequest) -> AsyncJob: + """Validate domains against an ICP description. + + `integration_id` takes an LLM provider integration UUID, or `NATIVE_ICP_ENGINE` to score + with DiscoLike's own ICP-fit model: no LLM key, no LLM cost, and no web search on that + run. Omit it for the org default. Read `AsyncJob.column_name` for the columns the run + returns rather than assuming the LLM ones; the native run's `Reasoning` column is always + null, since the model returns a score rather than an explanation. + """ response = await self._transport.request("POST", "/validate/icp", json_body=request.to_wire()) - return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) + return _async_job(self._transport, response) diff --git a/packages/discolike/tests/test_discogen.py b/packages/discolike/tests/test_discogen.py index 67e0bab..0741b1c 100644 --- a/packages/discolike/tests/test_discogen.py +++ b/packages/discolike/tests/test_discogen.py @@ -12,6 +12,7 @@ from discolike.requests import DiscoGenPersonaProcessRequest from discolike.requests import DiscoGenProcessRequest from discolike.requests import ValidateIcpRequest +from discolike.resources.discogen import NATIVE_ICP_ENGINE from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -280,3 +281,108 @@ def test_route_metadata_stamped() -> None: assert get_discolike_route(DiscogenResource.models) == ("GET", "/discogen/models", True) assert get_discolike_route(DiscogenResource.job) is None assert get_discolike_route(ValidateResource.icp) == ("POST", "/validate/icp", True) + + +def test_validate_icp_native_sentinel_serializes_and_exposes_columns(make_client: ClientFactory) -> None: + seen = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen["body"] = json.loads(request.content) + return httpx2.Response( + 200, + json={ + "task_id": "val-native", + "column_name": ["ICP Fit", "ICP Score", "Reasoning"], + "status": "in_progress", + "total_domains": 1, + }, + ) + + with make_client(handler) as client: + job = client.validate_icp( + ValidateIcpRequest( + icp_text="Cybersecurity for SMBs", + domains=["gusto.com"], + integration_id=NATIVE_ICP_ENGINE, + ) + ) + + assert NATIVE_ICP_ENGINE == "native-icp" + assert seen["body"]["integration_id"] == "native-icp" + assert job.task_id == "val-native" + assert job.column_name == ["ICP Fit", "ICP Score", "Reasoning"] + + +async def test_validate_icp_async_native_sentinel_serializes_and_exposes_columns( + make_async_client: AsyncClientFactory, +) -> None: + seen = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen["body"] = json.loads(request.content) + return httpx2.Response( + 200, json={"task_id": "val-native-async", "column_name": ["ICP Fit", "ICP Score", "Reasoning"]} + ) + + async with make_async_client(handler) as client: + job = await client.validate_icp( + ValidateIcpRequest(icp_text="q", domains=["a.com"], integration_id=NATIVE_ICP_ENGINE) + ) + + assert seen["body"]["integration_id"] == "native-icp" + assert job.column_name == ["ICP Fit", "ICP Score", "Reasoning"] + + +def test_validate_icp_llm_run_reports_its_own_columns(make_client: ClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"task_id": "val-llm", "column_name": ["Fit", "Confidence", "Reasoning"]}) + + with make_client(handler) as client: + job = client.validate_icp(ValidateIcpRequest(icp_text="q", domains=["a.com"], integration_id="int-1")) + + assert job.column_name == ["Fit", "Confidence", "Reasoning"] + + +def test_validate_icp_column_name_is_none_when_absent(make_client: ClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"task_id": "val-bare"}) + + with make_client(handler) as client: + job = client.validate_icp(ValidateIcpRequest(icp_text="q", domains=["a.com"])) + + assert job.column_name is None + + +def test_process_native_icp_sentinel_serializes(make_client: ClientFactory) -> None: + seen = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen["body"] = json.loads(request.content) + return httpx2.Response(200, json={"task_id": "dg-native-icp", "column_name": ["ICP Fit"]}) + + with make_client(handler) as client: + job = client.discogen.process( + DiscoGenProcessRequest( + query="Mandatory:\nUS-based\n\nReject if:\nagency", + domains=["a.com"], + integration_id=NATIVE_ICP_ENGINE, + ) + ) + + assert seen["body"]["integration_id"] == "native-icp" + assert job.column_name == ["ICP Fit"] + + +def test_native_icp_engine_is_exported_from_package_root() -> None: + import discolike + + assert discolike.NATIVE_ICP_ENGINE == NATIVE_ICP_ENGINE + assert "NATIVE_ICP_ENGINE" in discolike.__all__ + + +def test_reattached_job_has_no_column_name(make_client: ClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + pytest.fail("job() must not perform an HTTP request") + + with make_client(handler) as client: + assert client.discogen.job("dg-existing").column_name is None diff --git a/packages/discolike/tests/test_jobs.py b/packages/discolike/tests/test_jobs.py index 1cf42a7..cba2095 100644 --- a/packages/discolike/tests/test_jobs.py +++ b/packages/discolike/tests/test_jobs.py @@ -59,7 +59,7 @@ def test_status_exposes_cost_metadata_and_warnings() -> None: payload = { "status": "completed", "progress": 100, - "results": {"a.com": "yes"}, + "results": {"a.com": "Yes"}, "estimated_cost": 0.0283, "warnings": ["Search provider out of credits"], "cost_metadata": {