Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
7 changes: 6 additions & 1 deletion packages/discolike-cli/src/discolike_cli/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
{{<domain>: {{"Fit": "yes"|"no", "Confidence": "high"|"medium"|"low", "Reasoning": <string>}}, ...}}
{{<domain>: {{"Fit": "Yes"|"No", "Confidence": "high"|"medium"|"low", "Reasoning": <string>}}, ...}}
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}
""",
Expand Down
5 changes: 4 additions & 1 deletion packages/discolike-cli/src/discolike_cli/discogen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
8 changes: 5 additions & 3 deletions packages/discolike-cli/src/discolike_cli/enrich.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."
),
Expand Down
20 changes: 20 additions & 0 deletions packages/discolike-cli/tests/test_enrich_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
9 changes: 9 additions & 0 deletions packages/discolike-cli/tests/test_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions packages/discolike/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions packages/discolike/src/discolike/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +33,7 @@

__all__ = [
"NATIVE_ENGINE",
"NATIVE_ICP_ENGINE",
"APIConnectionError",
"ApiKeyCredential",
"AsyncDiscolike",
Expand Down
13 changes: 11 additions & 2 deletions packages/discolike/src/discolike/_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}")
Expand Down Expand Up @@ -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}")
Expand Down
65 changes: 57 additions & 8 deletions packages/discolike/src/discolike/resources/discogen.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
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
from discolike.resources._base import AsyncAPIResource
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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Loading
Loading