diff --git a/README.md b/README.md index 8090ee9..52c2f5c 100644 --- a/README.md +++ b/README.md @@ -161,12 +161,18 @@ ocl repo versions OWNER REPO [--type source|collection] [--updated-since YYYY-MM # Create & update ocl repo create OWNER REPO_ID NAME --type source|collection [options] ocl repo update OWNER REPO [--name NAME] [--description DESC] -ocl repo version-create OWNER REPO VERSION_ID [--released/--no-released] +ocl repo version-create OWNER REPO VERSION_ID [--released/--no-released] [--match-algorithms es,llm] ocl repo version-update OWNER REPO VERSION_ID [--released/--no-released] [--match-algorithms es,llm] -# Enable vectorized matching on a new release -ocl repo version-update CIEL CIEL v2026-03-23 --match-algorithms es,llm +# Without --match-algorithms, a new source version is vectorized (semantic matching) +# when its HEAD is. Opt in or out when you create it: +ocl repo version-create Regenstrief LOINC 2.82 --match-algorithms es,llm # opt in: vectorized +ocl repo version-create Regenstrief LOINC 2.82 --match-algorithms es # opt out: not vectorized +``` + +`--match-algorithms` applies to sources only. Set it when you create a version rather than with `version-update` afterwards. Changing an existing version's match algorithms makes the server reindex its concepts: adding `llm` embeds them, which can take hours for a large repository, and removing it can drop their vectors. `version-update --match-algorithms` prints a warning to that effect. +```bash # Custom attributes ocl repo extras OWNER REPO ocl repo extra-set OWNER REPO KEY VALUE diff --git a/src/ocl_cli/api_client.py b/src/ocl_cli/api_client.py index 83b3d1d..6c805d7 100644 --- a/src/ocl_cli/api_client.py +++ b/src/ocl_cli/api_client.py @@ -914,13 +914,20 @@ def create_repo_version( repo_type: str = "source", description: Optional[str] = None, released: bool = True, + match_algorithms: Optional[list[str]] = None, ) -> dict: - """Create a new repository version (snapshot).""" + """Create a new repository version (snapshot). + + Without match_algorithms, the server decides: a new source version is vectorized + (``llm``) when the source's HEAD is. + """ self._require_auth() endpoint = _build_repo_endpoint(owner_type, owner, repo_type, repo, suffix="versions/") body: dict[str, Any] = {"id": version_id, "released": released} if description: body["description"] = description + if match_algorithms is not None: + body["match_algorithms"] = match_algorithms return self.post(endpoint, json=body) def update_repo_version( diff --git a/src/ocl_cli/commands/repo.py b/src/ocl_cli/commands/repo.py index 0e674dc..3400ca4 100644 --- a/src/ocl_cli/commands/repo.py +++ b/src/ocl_cli/commands/repo.py @@ -169,6 +169,26 @@ def update(ctx, owner, repo_name, repo_type, owner_type, new_name, description, handle_api_error(e) +VERSION_UPDATE_MATCH_ALGORITHMS_WARNING = ( + "Warning: changing an existing version's match algorithms makes the server reindex its " + "concepts. Adding llm embeds them, which can take hours for a large repository; removing it " + "can drop their vectors. To vectorize a release, set it when you create the version instead: " + "repo version-create --match-algorithms es,llm" +) + + +def _split_match_algorithms(value, repo_type): + if value is None: + return None + if repo_type != "source": + raise click.UsageError("--match-algorithms applies to sources only") + algorithms = [algorithm.strip() for algorithm in value.split(",") if algorithm.strip()] + if not algorithms: + # an empty value (e.g. an unset shell variable) would otherwise clear them: opting out is `es` + raise click.UsageError("--match-algorithms needs at least one algorithm, e.g. es or es,llm") + return algorithms + + @repo.command("version-create") @click.argument("owner") @click.argument("repo_name") @@ -177,14 +197,23 @@ def update(ctx, owner, repo_name, repo_type, owner_type, new_name, description, @click.option("--owner-type", type=click.Choice(["users", "orgs"]), default="orgs") @click.option("--description", help="Version description") @click.option("--released/--no-released", default=True, help="Mark as released (default: true)") +@click.option( + "--match-algorithms", default=None, + help="Sources only. Comma-separated match algorithms (e.g. es,llm). Omit to let the server " + "decide: a new source version is vectorized when HEAD is.", +) @click.pass_context -def version_create(ctx, owner, repo_name, version_id, repo_type, owner_type, description, released): +def version_create( + ctx, owner, repo_name, version_id, repo_type, owner_type, description, released, match_algorithms +): """Create a new repository version (snapshot).""" client = ctx.obj["client"] + match_algorithms = _split_match_algorithms(match_algorithms, repo_type) try: result = client.create_repo_version( owner, repo_name, version_id, owner_type=owner_type, repo_type=repo_type, description=description, released=released, + match_algorithms=match_algorithms, ) output_result(ctx, result, format_repo_detail) except APIError as e: @@ -199,7 +228,9 @@ def version_create(ctx, owner, repo_name, version_id, repo_type, owner_type, des @click.option("--owner-type", type=click.Choice(["users", "orgs"]), default="orgs") @click.option("--description", help="Version description") @click.option("--released/--no-released", default=None, help="Released status") -@click.option("--match-algorithms", default=None, help="Comma-separated match algorithms (e.g. es,llm)") +@click.option( + "--match-algorithms", default=None, help="Sources only. Comma-separated match algorithms (e.g. es,llm)" +) @click.pass_context def version_update(ctx, owner, repo_name, version_id, repo_type, owner_type, description, released, match_algorithms): """Update a repository version.""" @@ -211,7 +242,8 @@ def version_update(ctx, owner, repo_name, version_id, repo_type, owner_type, des if released is not None: fields["released"] = released if match_algorithms is not None: - fields["match_algorithms"] = [a.strip() for a in match_algorithms.split(",")] + fields["match_algorithms"] = _split_match_algorithms(match_algorithms, repo_type) + click.echo(VERSION_UPDATE_MATCH_ALGORITHMS_WARNING, err=True) result = client.update_repo_version( owner, repo_name, version_id, owner_type=owner_type, repo_type=repo_type, **fields, diff --git a/tests/test_repo_commands.py b/tests/test_repo_commands.py new file mode 100644 index 0000000..873f02c --- /dev/null +++ b/tests/test_repo_commands.py @@ -0,0 +1,123 @@ +import json +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from click.testing import CliRunner + +from ocl_cli.api_client import OCLAPIClient +from ocl_cli.main import cli + + +def make_runner(): + # Click 8.1 mixes stderr into stdout unless told not to; 8.2 dropped mix_stderr and always + # keeps them apart. Both satisfy click>=8.1.0. + try: + return CliRunner(mix_stderr=False) + except TypeError: + return CliRunner() + + +class FakeClient: + def __init__(self): + self.calls = [] + + def create_repo_version(self, *args, **kwargs): + self.calls.append(("create_repo_version", args, kwargs)) + return {"id": args[2], "version": args[2]} + + def update_repo_version(self, *args, **kwargs): + self.calls.append(("update_repo_version", args, kwargs)) + return {"id": args[2], "version": args[2]} + + def close(self): + pass + + +class FakeConfig: + def get_server(self, server_id): + return SimpleNamespace(base_url="https://api.example.test") + + def resolve_token(self, server, token_override=None): + return token_override + + +class RepoVersionCommandTest(unittest.TestCase): + def invoke(self, *args, exit_code=0): + client = FakeClient() + with ( + patch("ocl_cli.main.CLIConfig.load", return_value=FakeConfig()), + patch("ocl_cli.main.OCLAPIClient", return_value=client), + ): + result = make_runner().invoke(cli, ["--json", "repo", *args]) + self.assertEqual(result.exit_code, exit_code, result.output) + return client, result + + def test_version_create_sends_match_algorithms(self): + client, _ = self.invoke( + "version-create", "Regenstrief", "LOINC", "2.82", "--match-algorithms", "es, llm" + ) + + [(_, args, kwargs)] = client.calls + self.assertEqual(args, ("Regenstrief", "LOINC", "2.82")) + self.assertEqual(kwargs["match_algorithms"], ["es", "llm"]) + + def test_version_create_without_match_algorithms_lets_the_server_decide(self): + client, result = self.invoke("version-create", "CIEL", "CIEL", "v2026-10-01") + + [(_, _, kwargs)] = client.calls + self.assertIsNone(kwargs["match_algorithms"]) + self.assertEqual(result.stderr, "") + + def test_version_update_match_algorithms_warns(self): + client, result = self.invoke( + "version-update", "CIEL", "CIEL", "v2026-10-01", "--match-algorithms", "es,llm" + ) + + [(_, _, kwargs)] = client.calls + self.assertEqual(kwargs["match_algorithms"], ["es", "llm"]) + self.assertIn("Warning", result.stderr) + self.assertIn("version-create --match-algorithms", result.stderr) + self.assertEqual(json.loads(result.stdout), {"id": "v2026-10-01", "version": "v2026-10-01"}) + + def test_match_algorithms_are_refused_for_collections(self): + for command in ("version-create", "version-update"): + client, result = self.invoke( + command, "CIEL", "Starter", "v1", "--type", "collection", "--match-algorithms", "es,llm", + exit_code=2, + ) + + self.assertEqual(client.calls, []) + self.assertIn("--match-algorithms applies to sources only", result.stderr) + + def test_empty_match_algorithms_are_refused(self): + for command in ("version-create", "version-update"): + for value in ("", " ", ",,", " , "): + client, result = self.invoke( + command, "CIEL", "CIEL", "v1", "--match-algorithms", value, exit_code=2 + ) + + self.assertEqual(client.calls, []) + self.assertIn("--match-algorithms needs at least one algorithm", result.stderr) + + def test_version_update_without_match_algorithms_does_not_warn(self): + _, result = self.invoke("version-update", "CIEL", "CIEL", "v2026-10-01", "--released") + + self.assertEqual(result.stderr, "") + + +class CreateRepoVersionClientTest(unittest.TestCase): + def create(self, **kwargs): + client = OCLAPIClient(base_url="https://api.example.test", token="t") + with patch.object(OCLAPIClient, "post", return_value={}) as post: + client.create_repo_version("CIEL", "CIEL", "v1", **kwargs) + return post.call_args.kwargs["json"] + + def test_body_includes_match_algorithms_when_given(self): + self.assertEqual( + self.create(match_algorithms=["es", "llm"]), + {"id": "v1", "released": True, "match_algorithms": ["es", "llm"]}, + ) + + def test_body_leaves_match_algorithms_out_by_default(self): + self.assertEqual(self.create(), {"id": "v1", "released": True})