diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 04eb4dc..8d4bfe3 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -18,14 +18,16 @@ jobs: python-version: '3.14' - run: uv sync --all-packages # SDK features land on development before the platform deploys to - # prod, so PRs targeting development check against the dev spec. + # prod, so every PR but a release into main checks against the dev + # spec — including one stacked on another feature branch, which is + # just as far ahead of prod as the branch it targets. # DEV_SPEC_URL is a repo secret; fork PRs don't receive it and # fall back to the prod spec, keeping dev infra internal-only. - env: BASE_REF: ${{ github.base_ref }} DEV_SPEC_URL: ${{ secrets.DEV_SPEC_URL }} run: | - if [ "$BASE_REF" = "development" ] && [ -n "$DEV_SPEC_URL" ]; then + if [ "$BASE_REF" != "main" ] && [ -n "$DEV_SPEC_URL" ]; then uv run python scripts/check_contract.py --spec-url "$DEV_SPEC_URL" uv run python scripts/gen_requests.py --check --spec-url "$DEV_SPEC_URL" else diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ad405e..6e7864d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - 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/CLI: `discogen.process` and `discogen.process_personas` take `typed_columns`, and the CLI gains `--typed-columns`. It opts the query into the detector answering yes/no, fixed-set and scale columns with a TypeSafe judgment model instead of generated prose; which columns are typed and how is decided server-side from the query text, not passed in by the caller. Off by default. See `include_confidence` below for the sibling flag that adds a confidence column to each typed column. +- SDK/CLI: `discogen.process` and `discogen.process_personas` take `include_confidence`, and the CLI gains `--include-confidence`. It applies to typed columns, which a DiscoGen run answers with a TypeSafe judgment model rather than generated prose; with it on, each typed column gets a sibling confidence column. Off by default, and it changes display only - the answers and their probabilities come back either way. - 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/packages/discolike-cli/src/discolike_cli/discogen.py b/packages/discolike-cli/src/discolike_cli/discogen.py index 2203954..b1ff0f6 100644 --- a/packages/discolike-cli/src/discolike_cli/discogen.py +++ b/packages/discolike-cli/src/discolike_cli/discogen.py @@ -35,6 +35,8 @@ 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." +TYPED_COLUMNS_HELP = "Let the detector answer yes/no, fixed-set and scale columns with a TypeSafe judgment model." +INCLUDE_CONFIDENCE_HELP = "Add a confidence column beside each typed column." SEARCH_PROVIDER_ID_HELP = "Search provider ID to use for web search." SEARCH_CONTEXT_SIZE_HELP = "Search context size; see docs.discolike.com." TASK_ID_HELP = "Task ID returned when the job was started." @@ -62,6 +64,10 @@ def run_command( include_x_search: bool | None = typer.Option( None, "--include-x-search/--no-include-x-search", help=INCLUDE_X_SEARCH_HELP ), + typed_columns: bool | None = typer.Option(None, "--typed-columns/--no-typed-columns", help=TYPED_COLUMNS_HELP), + include_confidence: bool | None = typer.Option( + None, "--include-confidence/--no-include-confidence", help=INCLUDE_CONFIDENCE_HELP + ), search_provider_id: str | None = typer.Option(None, "--search-provider-id", help=SEARCH_PROVIDER_ID_HELP), search_context_size: str | None = typer.Option(None, "--search-context-size", help=SEARCH_CONTEXT_SIZE_HELP), wait: bool = typer.Option(False, "--wait", help=WAIT_HELP), @@ -84,6 +90,8 @@ def run_command( web_search=web_search, context_mode=context_mode, include_x_search=include_x_search, + typed_columns=typed_columns, + include_confidence=include_confidence, search_provider_id=search_provider_id, search_context_size=search_context_size, ), @@ -103,6 +111,10 @@ def run_personas_command( include_x_search: bool | None = typer.Option( None, "--include-x-search/--no-include-x-search", help=INCLUDE_X_SEARCH_HELP ), + typed_columns: bool | None = typer.Option(None, "--typed-columns/--no-typed-columns", help=TYPED_COLUMNS_HELP), + include_confidence: bool | None = typer.Option( + None, "--include-confidence/--no-include-confidence", help=INCLUDE_CONFIDENCE_HELP + ), search_provider_id: str | None = typer.Option(None, "--search-provider-id", help=SEARCH_PROVIDER_ID_HELP), search_context_size: str | None = typer.Option(None, "--search-context-size", help=SEARCH_CONTEXT_SIZE_HELP), wait: bool = typer.Option(False, "--wait", help=WAIT_HELP), @@ -122,6 +134,8 @@ def run_personas_command( web_search=web_search, context_mode=context_mode, include_x_search=include_x_search, + typed_columns=typed_columns, + include_confidence=include_confidence, search_provider_id=search_provider_id, search_context_size=search_context_size, ), diff --git a/packages/discolike-cli/tests/test_discogen_cli.py b/packages/discolike-cli/tests/test_discogen_cli.py index 4397665..7095e79 100644 --- a/packages/discolike-cli/tests/test_discogen_cli.py +++ b/packages/discolike-cli/tests/test_discogen_cli.py @@ -67,6 +67,91 @@ def handler(request: httpx2.Request) -> httpx2.Response: } +def test_discogen_run_sends_typed_columns_when_passed(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": "dg-1c"}) + + install_build_client(handler) + result = runner.invoke( + app, + ["discogen", "run", "--query", "q", "--domain", "acme.com", "--typed-columns"], + ) + assert result.exit_code == 0, result.output + assert captured["body"] == { + "query": "q", + "domains": ["acme.com"], + "typed_columns": True, + } + + +def test_discogen_run_sends_include_confidence_true_when_flag_passed( + 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": "dg-1d"}) + + install_build_client(handler) + result = runner.invoke( + app, + ["discogen", "run", "--query", "q", "--domain", "acme.com", "--include-confidence"], + ) + assert result.exit_code == 0, result.output + assert captured["body"] == { + "query": "q", + "domains": ["acme.com"], + "include_confidence": True, + } + + +def test_discogen_run_sends_include_confidence_false_when_flag_negated( + 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": "dg-1e"}) + + install_build_client(handler) + result = runner.invoke( + app, + ["discogen", "run", "--query", "q", "--domain", "acme.com", "--no-include-confidence"], + ) + assert result.exit_code == 0, result.output + assert captured["body"] == { + "query": "q", + "domains": ["acme.com"], + "include_confidence": False, + } + + +def test_discogen_run_omits_include_confidence_when_flag_not_passed( + 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": "dg-1f"}) + + install_build_client(handler) + result = runner.invoke( + app, + ["discogen", "run", "--query", "q", "--domain", "acme.com"], + ) + assert result.exit_code == 0, result.output + assert captured["body"] == { + "query": "q", + "domains": ["acme.com"], + } + + def test_discogen_run_personas_posts_persona_ids(install_build_client: Callable[[Handler], None]) -> None: captured: dict[str, object] = {} @@ -110,6 +195,93 @@ def handler(request: httpx2.Request) -> httpx2.Response: } +def test_discogen_run_personas_sends_typed_columns_when_passed( + 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": "dg-2c"}) + + install_build_client(handler) + result = runner.invoke( + app, + ["discogen", "run-personas", "--query", "q", "--persona-id", "1", "--typed-columns"], + ) + assert result.exit_code == 0, result.output + assert captured["body"] == { + "query": "q", + "persona_ids": [1], + "typed_columns": True, + } + + +def test_discogen_run_personas_sends_include_confidence_true_when_flag_passed( + 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": "dg-2c"}) + + install_build_client(handler) + result = runner.invoke( + app, + ["discogen", "run-personas", "--query", "q", "--persona-id", "1", "--include-confidence"], + ) + assert result.exit_code == 0, result.output + assert captured["body"] == { + "query": "q", + "persona_ids": [1], + "include_confidence": True, + } + + +def test_discogen_run_personas_sends_include_confidence_false_when_flag_negated( + 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": "dg-2d"}) + + install_build_client(handler) + result = runner.invoke( + app, + ["discogen", "run-personas", "--query", "q", "--persona-id", "1", "--no-include-confidence"], + ) + assert result.exit_code == 0, result.output + assert captured["body"] == { + "query": "q", + "persona_ids": [1], + "include_confidence": False, + } + + +def test_discogen_run_personas_omits_include_confidence_when_flag_not_passed( + 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": "dg-2e"}) + + install_build_client(handler) + result = runner.invoke( + app, + ["discogen", "run-personas", "--query", "q", "--persona-id", "1"], + ) + assert result.exit_code == 0, result.output + assert captured["body"] == { + "query": "q", + "persona_ids": [1], + } + + def test_discogen_run_with_wait_polls_to_completion(install_build_client: Callable[[Handler], None]) -> None: statuses = iter( [ diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 563d1bd..6959cf1 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -1421,12 +1421,20 @@ class DiscoGenProcessRequest(DiscolikeRequest): integration_id: Annotated[ str | None, Field( - description="LLM provider integration UUID (omit for org default)", + description="LLM provider integration UUID (omit for org default), or 'native-icp' to score an ICP validation prompt with the native model", title="Integration Id", ), ] = None web_search: Annotated[bool | None, Field(title="Web Search")] = False include_x_search: Annotated[bool | None, Field(title="Include X Search")] = False + typed_columns: Annotated[ + bool | None, + Field( + description="Let the detector answer yes/no, fixed-set and scale columns with a TypeSafe judgment model", + title="Typed Columns", + ), + ] = False + include_confidence: Annotated[bool | None, Field(title="Include Confidence")] = False search_provider_id: Annotated[ str | None, Field( @@ -1456,12 +1464,20 @@ class DiscoGenPersonaProcessRequest(DiscolikeRequest): integration_id: Annotated[ str | None, Field( - description="LLM provider integration UUID (omit for org default)", + description="LLM provider integration UUID (omit for org default), or 'native-icp' to score an ICP validation prompt with the native model", title="Integration Id", ), ] = None web_search: Annotated[bool | None, Field(title="Web Search")] = False include_x_search: Annotated[bool | None, Field(title="Include X Search")] = False + typed_columns: Annotated[ + bool | None, + Field( + description="Let the detector answer yes/no, fixed-set and scale columns with a TypeSafe judgment model", + title="Typed Columns", + ), + ] = False + include_confidence: Annotated[bool | None, Field(title="Include Confidence")] = False search_provider_id: Annotated[ str | None, Field( @@ -1507,7 +1523,7 @@ class ValidateIcpRequest(DiscolikeRequest): integration_id: Annotated[ str | None, Field( - description="LLM provider integration UUID (omit for org default)", + description="LLM provider integration UUID (omit for org default), or 'native-icp' to score with DiscoLike's own ICP-fit model at no LLM cost", title="Integration Id", ), ] = None diff --git a/packages/discolike/tests/test_discogen.py b/packages/discolike/tests/test_discogen.py index 0741b1c..20423a7 100644 --- a/packages/discolike/tests/test_discogen.py +++ b/packages/discolike/tests/test_discogen.py @@ -72,6 +72,8 @@ def handler(request: httpx2.Request) -> httpx2.Response: web_search=True, context_mode="website", include_x_search=False, + typed_columns=True, + include_confidence=True, search_provider_id="serper", search_context_size="medium", ) @@ -84,11 +86,26 @@ def handler(request: httpx2.Request) -> httpx2.Response: "web_search": True, "context_mode": "website", "include_x_search": False, + "typed_columns": True, + "include_confidence": True, "search_provider_id": "serper", "search_context_size": "medium", } +def test_process_sends_typed_columns_when_set(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-typed"}) + + with make_client(handler) as client: + client.discogen.process(DiscoGenProcessRequest(query="q", domains=["a.com"], typed_columns=True)) + + assert seen["body"] == {"query": "q", "domains": ["a.com"], "typed_columns": True} + + def test_process_personas_posts_json_and_returns_job(make_client: ClientFactory) -> None: seen = {}