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 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 ( CREATE TABLE IF NOT EXISTS approved_abilities (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, 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_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_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_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_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_capabilities_repository ON approved_capabilities(repository_id);
CREATE INDEX IF NOT EXISTS idx_features_repository ON approved_features(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 reference_id: int | None = None
@dataclass(frozen=True)
class Scope:
id: int
name: str
description: str
confidence: float
confidence_label: str = ""
@dataclass(frozen=True) @dataclass(frozen=True)
class Feature: class Feature:
id: int id: int
@@ -247,6 +256,7 @@ class Ability:
@dataclass(frozen=True) @dataclass(frozen=True)
class RepositoryAbilityMap: class RepositoryAbilityMap:
repository: Repository repository: Repository
scope: Scope
abilities: list[Ability] abilities: list[Ability]

View File

@@ -1214,6 +1214,22 @@ class RegistryService:
self.store.delete_evidence(repository_id, evidence_id) self.store.delete_evidence(repository_id, evidence_id)
return self.store.get_ability_map(repository_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: def ability_map(self, repository_id: int) -> RepositoryAbilityMap:
return self.store.get_ability_map(repository_id) return self.store.get_ability_map(repository_id)

View File

@@ -25,6 +25,7 @@ from repo_registry.core.models import (
RepositorySnapshot, RepositorySnapshot,
ReviewDecision, ReviewDecision,
SearchResult, SearchResult,
Scope,
SourceReference, SourceReference,
confidence_label, confidence_label,
) )
@@ -47,6 +48,7 @@ class RegistryStore:
with self.connect() as connection: with self.connect() as connection:
connection.executescript(migration_path.read_text(encoding="utf-8")) connection.executescript(migration_path.read_text(encoding="utf-8"))
self._ensure_content_chunks_table(connection) self._ensure_content_chunks_table(connection)
self._ensure_repository_scopes_table(connection)
self._ensure_approved_source_ref_columns(connection) self._ensure_approved_source_ref_columns(connection)
self._ensure_evidence_relationship_columns(connection) self._ensure_evidence_relationship_columns(connection)
self._ensure_expectation_gaps_table(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)" "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: def _ensure_expectation_gaps_table(self, connection: sqlite3.Connection) -> None:
connection.execute( connection.execute(
""" """
@@ -170,6 +189,14 @@ class RegistryStore:
(name, url, description, branch), (name, url, description, branch),
) )
repository_id = int(cursor.lastrowid) 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) return self.get_repository(repository_id)
def update_repository( def update_repository(
@@ -1822,6 +1849,81 @@ class RegistryStore:
row_id=evidence_id, 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( def replace_approved_from_candidate_graph(
self, self,
repository_id: int, repository_id: int,
@@ -1922,6 +2024,7 @@ class RegistryStore:
def get_ability_map(self, repository_id: int) -> RepositoryAbilityMap: def get_ability_map(self, repository_id: int) -> RepositoryAbilityMap:
repository = self.get_repository(repository_id) repository = self.get_repository(repository_id)
scope = self._ensure_scope(repository_id)
with self.connect() as connection: with self.connect() as connection:
ability_rows = connection.execute( ability_rows = connection.execute(
""" """
@@ -2018,7 +2121,7 @@ class RegistryStore:
) )
for row in ability_rows for row in ability_rows
] ]
return RepositoryAbilityMap(repository=repository, abilities=abilities) return RepositoryAbilityMap(repository=repository, scope=scope, abilities=abilities)
def search( def search(
self, self,
@@ -2370,7 +2473,7 @@ class RegistryStore:
label: str, label: str,
repository_id: int, repository_id: int,
row_id: int, row_id: int,
values: dict[str, str | float | None], values: dict[str, str | float | int | None],
) -> None: ) -> None:
assignments: list[str] = [] assignments: list[str] = []
params: list[str | float | int] = [] params: list[str | float | int] = []

View File

@@ -716,8 +716,17 @@ class AbilityResponse(BaseModel):
capabilities: list[CapabilityResponse] capabilities: list[CapabilityResponse]
class ScopeResponse(BaseModel):
id: int
name: str
description: str
confidence: float
confidence_label: str
class RepositoryAbilityMapResponse(BaseModel): class RepositoryAbilityMapResponse(BaseModel):
repository: RepositoryResponse repository: RepositoryResponse
scope: ScopeResponse
abilities: list[AbilityResponse] abilities: list[AbilityResponse]
model_config = { model_config = {
@@ -725,6 +734,13 @@ class RepositoryAbilityMapResponse(BaseModel):
"examples": [ "examples": [
{ {
"repository": REPOSITORY_EXAMPLE, "repository": REPOSITORY_EXAMPLE,
"scope": {
"id": 1,
"name": "MailRouter",
"description": "Scope root for the MailRouter repository.",
"confidence": 1.0,
"confidence_label": "high",
},
"abilities": [ "abilities": [
{ {
"id": 1, "id": 1,

View File

@@ -647,6 +647,23 @@ def edit_repository_from_form(
return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303) 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") @router.post("/ui/repos/{repository_id}/delete")
def delete_repository_from_form( def delete_repository_from_form(
repository_id: int, repository_id: int,
@@ -2697,11 +2714,9 @@ def render_candidate_merge_form(
def render_ability_map(ability_map: dict, repository_id: int) -> str: def render_ability_map(ability_map: dict, repository_id: int) -> str:
abilities = ability_map.get("abilities", []) abilities = ability_map.get("abilities", [])
if not abilities: scope = ability_map["scope"]
return '<p class="muted">No approved entries yet.</p>' scope_description = scope.get("description") or (
repository = ability_map["repository"] f"Scope root for the approved characteristics of {scope['name']}."
scope_description = repository.get("description") or (
f"Scope root for the approved characteristics of {repository['name']}."
) )
items = [] items = []
for ability in abilities: for ability in abilities:
@@ -2745,17 +2760,32 @@ def render_ability_map(ability_map: dict, repository_id: int) -> str:
return f""" return f"""
<div class="tree"> <div class="tree">
<ul> <ul>
<li id="scope-{repository['id']}"> <li id="scope-{scope['id']}">
<strong>{escape(repository['name'])}</strong> <strong>{escape(scope['name'])}</strong>
<span class="pill">scope</span> <span class="pill">scope</span>
<span class="pill">{scope['confidence']:.2f} {escape(scope['confidence_label'])}</span>
<p class="muted">{escape(scope_description)}</p> <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> </li>
</ul> </ul>
</div> </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: def render_approved_ability_forms(ability: dict, repository_id: int) -> str:
return f""" return f"""
<form class="stack" method="post" action="/ui/repos/{repository_id}/abilities/{ability['id']}/edit"> <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) ability_map = service.ability_map(repository.id)
assert ability_map.repository.name == "MailRouter" 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" assert ability_map.abilities[0].name == "Business Email Routing"
capability = ability_map.abilities[0].capabilities[0] capability = ability_map.abilities[0].capabilities[0]
assert capability.name == "Classify Incoming Email" 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_kind == "fact"
assert capability.evidence[0].reference_id == 42 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): def test_manual_registry_updates_and_deletes_approved_entries(tmp_path):
service = make_service(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 } <= evidence_columns
assert "content_chunks" in tables assert "content_chunks" in tables
assert "expectation_gaps" in tables assert "expectation_gaps" in tables
assert "repository_scopes" in tables
def test_approved_registry_schema_allows_future_nullable_vocabulary_ref(tmp_path): 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: with service.store.connect() as connection:
for table in ( for table in (
"approved_abilities", "approved_abilities",
"repository_scopes",
"approved_capabilities", "approved_capabilities",
"approved_features", "approved_features",
"approved_evidence", "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 "Add Capability Support" in detail_response.text
assert "Supported characteristic kind" in detail_response.text assert "Supported characteristic kind" in detail_response.text
assert "Reference 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( ability_response = client.post(
f"{repository_path}/abilities", 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 additive support metadata: `target_kind`, `target_id`, `reference_kind`, and
`reference_id`. Existing capability-bound evidence remains compatible, while the `reference_id`. Existing capability-bound evidence remains compatible, while the
UI exposes these fields as supported-characteristic and reference metadata. 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.