diff --git a/backend/app/collections.py b/backend/app/collections.py index d5b8f9c..76204fe 100644 --- a/backend/app/collections.py +++ b/backend/app/collections.py @@ -8,11 +8,26 @@ authz (auth.py) recovers a row's project by joining `collections` on """ from __future__ import annotations +import json + from . import db DEFAULT_COLLECTION_ID = "default" +def _enabled_models_from_config(config_json: str | None) -> list[str] | None: + """§22.12 per-collection enabled_models from a `config_json` blob, or None + when unset (the collection inherits its project's universe).""" + if not config_json: + return None + try: + cfg = json.loads(config_json) + except (json.JSONDecodeError, TypeError): + return None + em = cfg.get("enabled_models") if isinstance(cfg, dict) else None + return [str(m) for m in em] if isinstance(em, list) else None + + def default_collection_id(project_id: str) -> str: """The id of a project's default (S1: sole) collection. Falls back to the literal 'default' when the project has no collection row yet.""" @@ -60,13 +75,18 @@ def subfolder_of(collection_id: str) -> str: def get_collection(collection_id: str) -> dict | None: - """The full collection row as a dict, or None if unknown.""" + """The full collection row as a dict, or None if unknown. `enabled_models` + (§22.12) is unpacked from `config_json` as a list, or None when unset.""" row = db.conn().execute( - "SELECT id, project_id, type, subfolder, initial_state, visibility, name " - "FROM collections WHERE id = ?", + "SELECT id, project_id, type, subfolder, initial_state, visibility, name, " + "config_json FROM collections WHERE id = ?", (collection_id,), ).fetchone() - return dict(row) if row else None + if row is None: + return None + out = dict(row) + out["enabled_models"] = _enabled_models_from_config(out.pop("config_json", None)) + return out def list_collections(project_id: str, include_unlisted: bool = False) -> list[dict]: diff --git a/backend/app/models_resolver.py b/backend/app/models_resolver.py index b0c6894..4440f54 100644 --- a/backend/app/models_resolver.py +++ b/backend/app/models_resolver.py @@ -38,22 +38,78 @@ from . import db, funder from .providers import BaseProvider +def _models_from_config(config_json: str | None) -> list[str] | None: + """The `enabled_models` list inside a project/collection `config_json`, + or None when the key is absent (meaning "no narrowing at this tier").""" + if not config_json: + return None + try: + cfg = json.loads(config_json) + except (json.JSONDecodeError, TypeError): + return None + em = cfg.get("enabled_models") if isinstance(cfg, dict) else None + return [str(m) for m in em] if isinstance(em, list) else None + + +def _narrow(universe: list[str], allowed: list[str] | None) -> list[str]: + """Intersect `universe` with `allowed`, preserving universe order. `allowed` + None means no narrowing at this tier; an empty list narrows to empty (an + opt-out), exactly like the §6.6 per-entry `models: []`.""" + if allowed is None: + return universe + allow = set(allowed) + return [k for k in universe if k in allow] + + +def _scope_narrowed_universe( + collection_id: str | None, operator_keys: list[str] +) -> list[str]: + """§22.12 — narrow the operator (deployment) universe by the entry's + project then its collection `enabled_models`. Each tier may only narrow; + a missing config at a tier is a no-op. The collection cannot widen its + project because narrowing composes from the operator ceiling downward.""" + if collection_id is None: + return list(operator_keys) + conn = db.conn() + crow = conn.execute( + "SELECT project_id, config_json FROM collections WHERE id = ?", + (collection_id,), + ).fetchone() + universe = list(operator_keys) + if crow is None: + return universe + prow = conn.execute( + "SELECT config_json FROM projects WHERE id = ?", (crow["project_id"],) + ).fetchone() + universe = _narrow(universe, _models_from_config(prow["config_json"] if prow else None)) + universe = _narrow(universe, _models_from_config(crow["config_json"])) + return universe + + def resolve_models_for_rfc( slug: str, providers: dict[str, BaseProvider] ) -> list[str]: - """Return the per-RFC resolved model keys per §6.6, extended by §6.7. + """Return the per-RFC resolved model keys per §6.6, extended by §6.7 and + §22.12. The first entry is the RFC's default model. An empty list means AI is unavailable on this RFC and callers refuse the AI surface. """ - # §6.7: the funder universe (if any) replaces the operator universe - # as the base set the §6.6 frontmatter intersects against. - funder_universe = funder.resolve_funder_universe(slug, providers) - base_universe = funder_universe if funder_universe is not None else list(providers.keys()) row = db.conn().execute( - "SELECT models_json FROM cached_rfcs WHERE slug = ?", + "SELECT collection_id, models_json FROM cached_rfcs WHERE slug = ?", (slug,), ).fetchone() + collection_id = row["collection_id"] if row is not None else None + # §22.12: first narrow the operator universe by the entry's project + + # collection enabled_models (the deployment → project → collection chain). + scope_universe = _scope_narrowed_universe(collection_id, list(providers.keys())) + # §6.7: a consenting funder universe (if any) replaces the operator universe + # as the base set — still bounded by the §22.12 scope narrowing above. + funder_universe = funder.resolve_funder_universe(slug, providers) + if funder_universe is not None: + base_universe = _narrow(list(funder_universe), scope_universe) + else: + base_universe = scope_universe if row is None or row["models_json"] is None: return list(base_universe) try: diff --git a/backend/app/registry.py b/backend/app/registry.py index b4e9826..046275c 100644 --- a/backend/app/registry.py +++ b/backend/app/registry.py @@ -64,6 +64,7 @@ class CollectionEntry: visibility: str | None initial_state: str name: str | None + config: dict = field(default_factory=dict) # §22.12 enabled_models @dataclass @@ -149,7 +150,16 @@ def parse_collection_manifest(text: str) -> CollectionEntry: raise RegistryError(f"collection has invalid initial_state {initial_state!r}") name = raw.get("name") name = str(name).strip() if name else None - return CollectionEntry(ctype, vis, initial_state, name) + # §22.12: an optional per-collection enabled_models list that narrows the + # project's universe. Absent → no narrowing (inherit). Present (incl. empty) + # → narrowing applies; [] opts the collection out of AI. + cfg: dict = {} + if raw.get("enabled_models") is not None: + em = raw["enabled_models"] + if not isinstance(em, list): + raise RegistryError("collection enabled_models must be a list") + cfg["enabled_models"] = [str(m) for m in em] + return CollectionEntry(ctype, vis, initial_state, name, cfg) def _default_collection_id(project_id: str, default_id: str) -> str: @@ -268,17 +278,19 @@ def _upsert_named_collection( conn.execute( """ INSERT INTO collections - (id, project_id, type, subfolder, initial_state, visibility, name, registry_sha, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + (id, project_id, type, subfolder, initial_state, visibility, name, config_json, registry_sha, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(id) DO UPDATE SET project_id = excluded.project_id, initial_state = excluded.initial_state, visibility = excluded.visibility, name = excluded.name, + config_json = excluded.config_json, registry_sha = excluded.registry_sha, updated_at = datetime('now') """, - (subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name, sha), + (subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name, + json.dumps(ce.config), sha), ) diff --git a/backend/migrations/031_collection_enabled_models.sql b/backend/migrations/031_collection_enabled_models.sql new file mode 100644 index 0000000..bda530a --- /dev/null +++ b/backend/migrations/031_collection_enabled_models.sql @@ -0,0 +1,9 @@ +-- §22.12 S6 — per-collection model universe. +-- +-- A collection's `.collection.yaml` may carry an `enabled_models` list that +-- NARROWS its project's universe (which narrows the deployment ENABLED_MODELS). +-- Mirrored into a `config_json` blob on the collection row, paralleling +-- `projects.config_json` (which already holds the project's enabled_models + +-- theme). Additive only — no rebuild. NULL means "no per-collection narrowing; +-- inherit the project's universe." +ALTER TABLE collections ADD COLUMN config_json TEXT; diff --git a/backend/tests/test_s6_collection_models_vertical.py b/backend/tests/test_s6_collection_models_vertical.py new file mode 100644 index 0000000..15a69a5 --- /dev/null +++ b/backend/tests/test_s6_collection_models_vertical.py @@ -0,0 +1,141 @@ +"""§22.12 S6 — per-collection model universe. + +A collection's `.collection.yaml` may carry an `enabled_models` list that +NARROWS its project's universe, which in turn narrows the deployment +ENABLED_MODELS. The resolution chain (extending §6.6/§6.7) is: + + funder ∩ per-entry models ∩ collection universe ∩ project universe + (operator providers = the ceiling) + +These tests prove (1) the manifest parser reads `enabled_models`, (2) the +mirror stores it on the collection row, (3) the resolver narrows the base +universe by project then collection, with absent = inherit and [] = opt-out, +and (4) the collection API surfaces the collection's own narrowing. +""" +from __future__ import annotations + +import json + +from fastapi.testclient import TestClient + +from app import db, models_resolver, registry +from test_propose_vertical import ( # noqa: F401 + app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env, +) +from test_rfc_view_vertical import FakeProvider, seed_active_rfc # noqa: F401 + + +def _install_two_providers(app) -> None: + app.state.providers.clear() + app.state.providers["claude"] = FakeProvider("TITLE: A\nDESCRIPTION: B") + app.state.providers["gemini"] = FakeProvider("TITLE: G\nDESCRIPTION: H") + + +def _set_project_models(project_id: str, models) -> None: + cfg = {} if models is None else {"enabled_models": models} + db.conn().execute( + "UPDATE projects SET config_json = ? WHERE id = ?", + (json.dumps(cfg), project_id), + ) + + +def _set_collection_models(collection_id: str, models) -> None: + cfg = None if models is None else json.dumps({"enabled_models": models}) + db.conn().execute( + "UPDATE collections SET config_json = ? WHERE id = ?", + (cfg, collection_id), + ) + + +# ── manifest parsing ─────────────────────────────────────────────────────── + + +def test_manifest_parses_enabled_models_into_config(): + ce = registry.parse_collection_manifest( + "type: bdd\nname: Features\nenabled_models: [claude, gemini]\n" + ) + assert ce.config.get("enabled_models") == ["claude", "gemini"] + + +def test_manifest_without_enabled_models_has_no_key(): + ce = registry.parse_collection_manifest("type: document\nname: Model\n") + assert "enabled_models" not in ce.config + + +def test_manifest_rejects_non_list_enabled_models(): + import pytest + with pytest.raises(registry.RegistryError): + registry.parse_collection_manifest("type: bdd\nenabled_models: claude\n") + + +# ── resolver narrowing (the §22.12 chain) ────────────────────────────────── + + +def test_resolver_inherits_operator_universe_when_no_scope_narrowing(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app): + seed_active_rfc(fake, slug="ohm", title="OHM", body="x") + _install_two_providers(app) + # No project/collection narrowing → full operator universe. + resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers) + assert resolved == ["claude", "gemini"] + + +def test_resolver_narrows_by_project_universe(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app): + seed_active_rfc(fake, slug="ohm", title="OHM", body="x") + _install_two_providers(app) + _set_project_models("default", ["gemini"]) + resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers) + assert resolved == ["gemini"] + + +def test_resolver_collection_narrows_within_project(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app): + seed_active_rfc(fake, slug="ohm", title="OHM", body="x") + _install_two_providers(app) + # Project allows both; the collection narrows to claude only. + _set_project_models("default", ["claude", "gemini"]) + _set_collection_models("default", ["claude"]) + resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers) + assert resolved == ["claude"] + + +def test_resolver_collection_empty_list_opts_out(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app): + seed_active_rfc(fake, slug="ohm", title="OHM", body="x") + _install_two_providers(app) + _set_collection_models("default", []) # opt this collection out of AI + resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers) + assert resolved == [] + + +def test_resolver_collection_cannot_widen_project(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app): + seed_active_rfc(fake, slug="ohm", title="OHM", body="x") + _install_two_providers(app) + # Project restricts to gemini; the collection naming claude+gemini + # cannot re-add claude (narrowing only). + _set_project_models("default", ["gemini"]) + _set_collection_models("default", ["claude", "gemini"]) + resolved = models_resolver.resolve_models_for_rfc("ohm", app.state.providers) + assert resolved == ["gemini"] + + +# ── API surfacing ────────────────────────────────────────────────────────── + + +def test_collection_api_surfaces_enabled_models(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", + role="owner", email="ben@test") + _set_collection_models("default", ["claude"]) + r = client.get("/api/projects/default/collections/default") + assert r.status_code == 200, r.text + assert r.json().get("enabled_models") == ["claude"]