Scope as first class root charactaristic

This commit is contained in:
2026-04-29 16:25:24 +02:00
parent eb1513e463
commit 8d6a9f7050
10 changed files with 228 additions and 10 deletions

View File

@@ -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);

View File

@@ -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]

View File

@@ -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)

View File

@@ -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] = []

View File

@@ -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,

View File

@@ -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 '<p class="muted">No approved entries yet.</p>'
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"""
<div class="tree">
<ul>
<li id="scope-{repository['id']}">
<strong>{escape(repository['name'])}</strong>
<li id="scope-{scope['id']}">
<strong>{escape(scope['name'])}</strong>
<span class="pill">scope</span>
<span class="pill">{scope['confidence']:.2f} {escape(scope['confidence_label'])}</span>
<p class="muted">{escape(scope_description)}</p>
<ul>{"".join(items)}</ul>
{render_approved_scope_form(scope, repository_id)}
<ul>{"".join(items) or '<li class="muted">No approved characteristics yet.</li>'}</ul>
</li>
</ul>
</div>
"""
def render_approved_scope_form(scope: dict, repository_id: int) -> str:
return f"""
<form class="stack" method="post" action="/ui/repos/{repository_id}/scope/edit">
<label>Name <input name="name" value="{escape(scope['name'])}" required></label>
<label>Description <textarea name="description" rows="2">{escape(scope['description'])}</textarea></label>
<label>Confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="{scope['confidence']:.2f}" required></label>
<div class="actions">
<button class="secondary" type="submit">Save Scope</button>
</div>
</form>
"""
def render_approved_ability_forms(ability: dict, repository_id: int) -> str:
return f"""
<form class="stack" method="post" action="/ui/repos/{repository_id}/abilities/{ability['id']}/edit">

View File

@@ -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)

View File

@@ -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",

View File

@@ -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",

View File

@@ -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.