generated from coulomb/repo-seed
Scope as first class root charactaristic
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user