From 8d6a9f7050245715a6574d61661c4c70d97b7dbf Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 29 Apr 2026 16:25:24 +0200 Subject: [PATCH] Scope as first class root charactaristic --- migrations/0001_initial.sql | 10 ++ src/repo_registry/core/models.py | 10 ++ src/repo_registry/core/service.py | 16 +++ src/repo_registry/storage/sqlite.py | 107 +++++++++++++++++- src/repo_registry/web_api/schemas.py | 16 +++ src/repo_registry/web_ui/views.py | 46 ++++++-- tests/test_registry_service.py | 11 ++ tests/test_storage_migrations.py | 2 + tests/test_web_api.py | 15 +++ ...P-0003-automatic-repository-exploration.md | 5 + 10 files changed, 228 insertions(+), 10 deletions(-) diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql index 568c2a6..a1e7589 100644 --- a/migrations/0001_initial.sql +++ b/migrations/0001_initial.sql @@ -123,6 +123,15 @@ CREATE TABLE IF NOT EXISTS review_decisions ( created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS repository_scopes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repository_id INTEGER NOT NULL UNIQUE REFERENCES repositories(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + confidence REAL NOT NULL DEFAULT 1.0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS approved_abilities ( id INTEGER PRIMARY KEY AUTOINCREMENT, repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, @@ -183,6 +192,7 @@ CREATE INDEX IF NOT EXISTS idx_candidate_capabilities_repository ON candidate_ca CREATE INDEX IF NOT EXISTS idx_candidate_features_repository ON candidate_features(repository_id); CREATE INDEX IF NOT EXISTS idx_candidate_evidence_repository ON candidate_evidence(repository_id); CREATE INDEX IF NOT EXISTS idx_review_decisions_repository ON review_decisions(repository_id); +CREATE INDEX IF NOT EXISTS idx_scopes_repository ON repository_scopes(repository_id); CREATE INDEX IF NOT EXISTS idx_abilities_repository ON approved_abilities(repository_id); CREATE INDEX IF NOT EXISTS idx_capabilities_repository ON approved_capabilities(repository_id); CREATE INDEX IF NOT EXISTS idx_features_repository ON approved_features(repository_id); diff --git a/src/repo_registry/core/models.py b/src/repo_registry/core/models.py index c335280..77b2afc 100644 --- a/src/repo_registry/core/models.py +++ b/src/repo_registry/core/models.py @@ -210,6 +210,15 @@ class Evidence: reference_id: int | None = None +@dataclass(frozen=True) +class Scope: + id: int + name: str + description: str + confidence: float + confidence_label: str = "" + + @dataclass(frozen=True) class Feature: id: int @@ -247,6 +256,7 @@ class Ability: @dataclass(frozen=True) class RepositoryAbilityMap: repository: Repository + scope: Scope abilities: list[Ability] diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index b3dacfc..720ff14 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -1214,6 +1214,22 @@ class RegistryService: self.store.delete_evidence(repository_id, evidence_id) return self.store.get_ability_map(repository_id) + def update_scope( + self, + repository_id: int, + *, + name: str | None = None, + description: str | None = None, + confidence: float | None = None, + ) -> RepositoryAbilityMap: + self.store.update_scope( + repository_id, + name=name, + description=description, + confidence=confidence, + ) + return self.store.get_ability_map(repository_id) + def ability_map(self, repository_id: int) -> RepositoryAbilityMap: return self.store.get_ability_map(repository_id) diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index a37452e..4dd80ea 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -25,6 +25,7 @@ from repo_registry.core.models import ( RepositorySnapshot, ReviewDecision, SearchResult, + Scope, SourceReference, confidence_label, ) @@ -47,6 +48,7 @@ class RegistryStore: with self.connect() as connection: connection.executescript(migration_path.read_text(encoding="utf-8")) self._ensure_content_chunks_table(connection) + self._ensure_repository_scopes_table(connection) self._ensure_approved_source_ref_columns(connection) self._ensure_evidence_relationship_columns(connection) self._ensure_expectation_gaps_table(connection) @@ -130,6 +132,23 @@ class RegistryStore: "CREATE INDEX IF NOT EXISTS idx_content_chunks_run ON content_chunks(analysis_run_id)" ) + def _ensure_repository_scopes_table(self, connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS repository_scopes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repository_id INTEGER NOT NULL UNIQUE REFERENCES repositories(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + confidence REAL NOT NULL DEFAULT 1.0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS idx_scopes_repository ON repository_scopes(repository_id)" + ) + def _ensure_expectation_gaps_table(self, connection: sqlite3.Connection) -> None: connection.execute( """ @@ -170,6 +189,14 @@ class RegistryStore: (name, url, description, branch), ) repository_id = int(cursor.lastrowid) + connection.execute( + """ + INSERT INTO repository_scopes + (repository_id, name, description, confidence) + VALUES (?, ?, ?, 1.0) + """, + (repository_id, name, description or ""), + ) return self.get_repository(repository_id) def update_repository( @@ -1822,6 +1849,81 @@ class RegistryStore: row_id=evidence_id, ) + def _ensure_scope(self, repository_id: int) -> Scope: + repository = self.get_repository(repository_id) + with self.connect() as connection: + row = connection.execute( + """ + SELECT id, name, description, confidence + FROM repository_scopes + WHERE repository_id = ? + """, + (repository_id,), + ).fetchone() + if row is None: + cursor = connection.execute( + """ + INSERT INTO repository_scopes + (repository_id, name, description, confidence) + VALUES (?, ?, ?, 1.0) + """, + (repository_id, repository.name, repository.description or ""), + ) + scope_id = int(cursor.lastrowid) + return Scope( + id=scope_id, + name=repository.name, + description=repository.description or "", + confidence=1.0, + confidence_label=confidence_label(1.0), + ) + return self._scope_from_row(row) + + def _get_scope(self, repository_id: int) -> Scope: + with self.connect() as connection: + row = connection.execute( + """ + SELECT id, name, description, confidence + FROM repository_scopes + WHERE repository_id = ? + """, + (repository_id,), + ).fetchone() + if row is None: + return self._ensure_scope(repository_id) + return self._scope_from_row(row) + + def _scope_from_row(self, row: sqlite3.Row) -> Scope: + return Scope( + id=row["id"], + name=row["name"], + description=row["description"], + confidence=row["confidence"], + confidence_label=confidence_label(row["confidence"]), + ) + + def update_scope( + self, + repository_id: int, + *, + name: str | None = None, + description: str | None = None, + confidence: float | None = None, + ) -> Scope: + self._ensure_scope(repository_id) + self._update_approved_row( + table="repository_scopes", + label="scope", + repository_id=repository_id, + row_id=self._get_scope(repository_id).id, + values={ + "name": name, + "description": description, + "confidence": confidence, + }, + ) + return self._get_scope(repository_id) + def replace_approved_from_candidate_graph( self, repository_id: int, @@ -1922,6 +2024,7 @@ class RegistryStore: def get_ability_map(self, repository_id: int) -> RepositoryAbilityMap: repository = self.get_repository(repository_id) + scope = self._ensure_scope(repository_id) with self.connect() as connection: ability_rows = connection.execute( """ @@ -2018,7 +2121,7 @@ class RegistryStore: ) for row in ability_rows ] - return RepositoryAbilityMap(repository=repository, abilities=abilities) + return RepositoryAbilityMap(repository=repository, scope=scope, abilities=abilities) def search( self, @@ -2370,7 +2473,7 @@ class RegistryStore: label: str, repository_id: int, row_id: int, - values: dict[str, str | float | None], + values: dict[str, str | float | int | None], ) -> None: assignments: list[str] = [] params: list[str | float | int] = [] diff --git a/src/repo_registry/web_api/schemas.py b/src/repo_registry/web_api/schemas.py index ec40ca0..792775d 100644 --- a/src/repo_registry/web_api/schemas.py +++ b/src/repo_registry/web_api/schemas.py @@ -716,8 +716,17 @@ class AbilityResponse(BaseModel): capabilities: list[CapabilityResponse] +class ScopeResponse(BaseModel): + id: int + name: str + description: str + confidence: float + confidence_label: str + + class RepositoryAbilityMapResponse(BaseModel): repository: RepositoryResponse + scope: ScopeResponse abilities: list[AbilityResponse] model_config = { @@ -725,6 +734,13 @@ class RepositoryAbilityMapResponse(BaseModel): "examples": [ { "repository": REPOSITORY_EXAMPLE, + "scope": { + "id": 1, + "name": "MailRouter", + "description": "Scope root for the MailRouter repository.", + "confidence": 1.0, + "confidence_label": "high", + }, "abilities": [ { "id": 1, diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index f692e84..c554c90 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -647,6 +647,23 @@ def edit_repository_from_form( return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) +@router.post("/ui/repos/{repository_id}/scope/edit") +def edit_scope_from_form( + repository_id: int, + name: str = Form(...), + description: str = Form(""), + confidence: float = Form(1.0), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.update_scope( + repository_id, + name=name, + description=description, + confidence=confidence, + ) + return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) + + @router.post("/ui/repos/{repository_id}/delete") def delete_repository_from_form( repository_id: int, @@ -2697,11 +2714,9 @@ def render_candidate_merge_form( def render_ability_map(ability_map: dict, repository_id: int) -> str: abilities = ability_map.get("abilities", []) - if not abilities: - return '

No approved entries yet.

' - repository = ability_map["repository"] - scope_description = repository.get("description") or ( - f"Scope root for the approved characteristics of {repository['name']}." + scope = ability_map["scope"] + scope_description = scope.get("description") or ( + f"Scope root for the approved characteristics of {scope['name']}." ) items = [] for ability in abilities: @@ -2745,17 +2760,32 @@ def render_ability_map(ability_map: dict, repository_id: int) -> str: return f"""
""" +def render_approved_scope_form(scope: dict, repository_id: int) -> str: + return f""" +
+ + + +
+ +
+
+ """ + + def render_approved_ability_forms(ability: dict, repository_id: int) -> str: return f"""
diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index 4b73361..8bcfd17 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -82,6 +82,8 @@ def test_manual_registry_builds_ability_map(tmp_path): ability_map = service.ability_map(repository.id) assert ability_map.repository.name == "MailRouter" + assert ability_map.scope.name == "MailRouter" + assert ability_map.scope.confidence_label == "high" assert ability_map.abilities[0].name == "Business Email Routing" capability = ability_map.abilities[0].capabilities[0] assert capability.name == "Classify Incoming Email" @@ -93,6 +95,15 @@ def test_manual_registry_builds_ability_map(tmp_path): assert capability.evidence[0].reference_kind == "fact" assert capability.evidence[0].reference_id == 42 + updated_map = service.update_scope( + repository.id, + name="MailRouter Product Scope", + description="Email routing repository scope.", + confidence=0.9, + ) + assert updated_map.scope.name == "MailRouter Product Scope" + assert updated_map.scope.description == "Email routing repository scope." + def test_manual_registry_updates_and_deletes_approved_entries(tmp_path): service = make_service(tmp_path) diff --git a/tests/test_storage_migrations.py b/tests/test_storage_migrations.py index f420ed1..16f89c1 100644 --- a/tests/test_storage_migrations.py +++ b/tests/test_storage_migrations.py @@ -42,6 +42,7 @@ def test_initialize_is_idempotent_and_applies_expected_columns(tmp_path): } <= evidence_columns assert "content_chunks" in tables assert "expectation_gaps" in tables + assert "repository_scopes" in tables def test_approved_registry_schema_allows_future_nullable_vocabulary_ref(tmp_path): @@ -152,6 +153,7 @@ def test_delete_repository_cascades_registry_and_review_rows(tmp_path): with service.store.connect() as connection: for table in ( "approved_abilities", + "repository_scopes", "approved_capabilities", "approved_features", "approved_evidence", diff --git a/tests/test_web_api.py b/tests/test_web_api.py index ac62516..7263098 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -1611,6 +1611,21 @@ def test_ui_manual_registry_entry_loop(tmp_path): assert "Add Capability Support" in detail_response.text assert "Supported characteristic kind" in detail_response.text assert "Reference kind" in detail_response.text + assert "Save Scope" in detail_response.text + + scope_response = client.post( + f"{repository_path}/scope/edit", + data={ + "name": "Manual Repository Scope", + "description": "Root product scope.", + "confidence": "0.91", + }, + follow_redirects=False, + ) + assert scope_response.status_code == 303 + assert client.get(f"/repos/{repository_id}/ability-map").json()["scope"][ + "name" + ] == "Manual Repository Scope" ability_response = client.post( f"{repository_path}/abilities", diff --git a/workplans/RREG-WP-0003-automatic-repository-exploration.md b/workplans/RREG-WP-0003-automatic-repository-exploration.md index d3be710..bce4b3b 100644 --- a/workplans/RREG-WP-0003-automatic-repository-exploration.md +++ b/workplans/RREG-WP-0003-automatic-repository-exploration.md @@ -231,3 +231,8 @@ Implementation note 2026-04-29: approved and candidate evidence now carry additive support metadata: `target_kind`, `target_id`, `reference_kind`, and `reference_id`. Existing capability-bound evidence remains compatible, while the UI exposes these fields as supported-characteristic and reference metadata. + +Implementation note 2026-04-29: repository scope is now first-class. A +`repository_scopes` row is created for new repositories and backfilled lazily for +existing repositories. The ability-map model and API include `scope`, and the UI +allows editing the scope root above approved abilities.