diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py
index 7c5fb1e..e809e3a 100644
--- a/src/repo_registry/core/service.py
+++ b/src/repo_registry/core/service.py
@@ -348,23 +348,15 @@ class RegistryService:
ability for ability in graph.abilities if ability.status == "candidate"
]
for ability in pending_abilities:
- approved_ability_id = self.store.create_ability(
- repository_id,
- name=ability.name,
- description=ability.description,
- confidence=ability.confidence,
- )
+ approved_ability_id = self._ensure_approved_ability(repository_id, ability)
for capability in ability.capabilities:
if capability.status != "candidate":
continue
- approved_capability_id = self.store.create_capability(
+ approved_capability_id = self._ensure_approved_capability(
repository_id,
approved_ability_id,
- name=capability.name,
- description=capability.description,
- inputs=capability.inputs,
- outputs=capability.outputs,
- confidence=capability.confidence,
+ ability.name,
+ capability,
)
for feature in capability.features:
if feature.status != "candidate":
@@ -405,6 +397,129 @@ class RegistryService:
self.store.update_repository_status(repository_id, "indexed")
return self.store.get_ability_map(repository_id)
+ def accept_candidate_ability(
+ self,
+ repository_id: int,
+ analysis_run_id: int,
+ candidate_ability_id: int,
+ *,
+ notes: str = "",
+ ) -> RepositoryAbilityMap:
+ graph = self.store.get_candidate_graph(repository_id, analysis_run_id)
+ ability = next(
+ (
+ item
+ for item in graph.abilities
+ if item.id == candidate_ability_id and item.status == "candidate"
+ ),
+ None,
+ )
+ if ability is None:
+ raise ValueError(f"candidate ability {candidate_ability_id} is not pending")
+ approved_ability_id = self._ensure_approved_ability(repository_id, ability)
+ for capability in ability.capabilities:
+ if capability.status == "candidate":
+ self._create_approved_capability_subtree(
+ repository_id,
+ approved_ability_id,
+ capability,
+ )
+ self.store.mark_candidate_ability_status(
+ repository_id,
+ analysis_run_id,
+ candidate_ability_id,
+ "approved",
+ )
+ self._record_candidate_acceptance(
+ repository_id,
+ analysis_run_id,
+ "accept_candidate_ability",
+ notes or f"Accepted candidate ability: {ability.name}",
+ )
+ return self.store.get_ability_map(repository_id)
+
+ def accept_candidate_capability(
+ self,
+ repository_id: int,
+ analysis_run_id: int,
+ candidate_capability_id: int,
+ *,
+ notes: str = "",
+ ) -> RepositoryAbilityMap:
+ graph = self.store.get_candidate_graph(repository_id, analysis_run_id)
+ parent_ability, capability = self._candidate_capability_with_parent(
+ graph,
+ candidate_capability_id,
+ )
+ if capability.status != "candidate":
+ raise ValueError(
+ f"candidate capability {candidate_capability_id} is not pending"
+ )
+ approved_ability_id = self._ensure_approved_ability(repository_id, parent_ability)
+ self._create_approved_capability_subtree(
+ repository_id,
+ approved_ability_id,
+ capability,
+ )
+ self.store.mark_candidate_capability_status(
+ repository_id,
+ analysis_run_id,
+ candidate_capability_id,
+ "approved",
+ )
+ self._record_candidate_acceptance(
+ repository_id,
+ analysis_run_id,
+ "accept_candidate_capability",
+ notes or f"Accepted candidate capability: {capability.name}",
+ )
+ return self.store.get_ability_map(repository_id)
+
+ def accept_candidate_feature(
+ self,
+ repository_id: int,
+ analysis_run_id: int,
+ candidate_feature_id: int,
+ *,
+ notes: str = "",
+ ) -> RepositoryAbilityMap:
+ graph = self.store.get_candidate_graph(repository_id, analysis_run_id)
+ parent_ability, parent_capability, feature = self._candidate_feature_with_parent(
+ graph,
+ candidate_feature_id,
+ )
+ if feature.status != "candidate":
+ raise ValueError(f"candidate feature {candidate_feature_id} is not pending")
+ approved_ability_id = self._ensure_approved_ability(repository_id, parent_ability)
+ approved_capability_id = self._ensure_approved_capability(
+ repository_id,
+ approved_ability_id,
+ parent_ability.name,
+ parent_capability,
+ )
+ self.store.create_feature(
+ repository_id,
+ approved_capability_id,
+ name=feature.name,
+ type=feature.type,
+ location=feature.location,
+ confidence=feature.confidence,
+ source_refs=feature.source_refs,
+ )
+ self.store.mark_candidate_feature_status(
+ repository_id,
+ analysis_run_id,
+ candidate_feature_id,
+ "approved",
+ )
+ self._record_candidate_acceptance(
+ repository_id,
+ analysis_run_id,
+ "accept_candidate_feature",
+ notes or f"Accepted candidate feature: {feature.name}",
+ )
+ return self.store.get_ability_map(repository_id)
+
def diff_analysis_runs(
self,
repository_id: int,
@@ -467,6 +582,124 @@ class RegistryService:
self.store.update_repository_status(repository_id, "indexed")
return self.store.get_ability_map(repository_id)
+ def _create_approved_capability_subtree(
+ self,
+ repository_id: int,
+ approved_ability_id: int,
+ capability: CandidateCapability,
+ ) -> int:
+ approved_capability_id = self.store.create_capability(
+ repository_id,
+ approved_ability_id,
+ name=capability.name,
+ description=capability.description,
+ inputs=capability.inputs,
+ outputs=capability.outputs,
+ confidence=capability.confidence,
+ )
+ for feature in capability.features:
+ if feature.status != "candidate":
+ continue
+ self.store.create_feature(
+ repository_id,
+ approved_capability_id,
+ name=feature.name,
+ type=feature.type,
+ location=feature.location,
+ confidence=feature.confidence,
+ source_refs=feature.source_refs,
+ )
+ for evidence in capability.evidence:
+ if evidence.status != "candidate":
+ continue
+ self.store.create_evidence(
+ repository_id,
+ approved_capability_id,
+ type=evidence.type,
+ reference=evidence.reference,
+ strength=evidence.strength,
+ source_refs=evidence.source_refs,
+ )
+ return approved_capability_id
+
+ def _ensure_approved_ability(
+ self,
+ repository_id: int,
+ candidate_ability: CandidateAbility,
+ ) -> int:
+ ability_map = self.store.get_ability_map(repository_id)
+ for ability in ability_map.abilities:
+ if ability.name == candidate_ability.name:
+ return ability.id
+ return self.store.create_ability(
+ repository_id,
+ name=candidate_ability.name,
+ description=candidate_ability.description,
+ confidence=candidate_ability.confidence,
+ )
+
+ def _ensure_approved_capability(
+ self,
+ repository_id: int,
+ approved_ability_id: int,
+ approved_ability_name: str,
+ candidate_capability: CandidateCapability,
+ ) -> int:
+ ability_map = self.store.get_ability_map(repository_id)
+ for ability in ability_map.abilities:
+ if ability.name != approved_ability_name:
+ continue
+ for capability in ability.capabilities:
+ if capability.name == candidate_capability.name:
+ return capability.id
+ return self.store.create_capability(
+ repository_id,
+ approved_ability_id,
+ name=candidate_capability.name,
+ description=candidate_capability.description,
+ inputs=candidate_capability.inputs,
+ outputs=candidate_capability.outputs,
+ confidence=candidate_capability.confidence,
+ )
+
+ def _candidate_capability_with_parent(
+ self,
+ graph: CandidateGraph,
+ candidate_capability_id: int,
+ ) -> tuple[CandidateAbility, CandidateCapability]:
+ for ability in graph.abilities:
+ for capability in ability.capabilities:
+ if capability.id == candidate_capability_id:
+ return ability, capability
+ raise ValueError(f"candidate capability {candidate_capability_id} was not found")
+
+ def _candidate_feature_with_parent(
+ self,
+ graph: CandidateGraph,
+ candidate_feature_id: int,
+ ) -> tuple[CandidateAbility, CandidateCapability, CandidateFeature]:
+ for ability in graph.abilities:
+ for capability in ability.capabilities:
+ for feature in capability.features:
+ if feature.id == candidate_feature_id:
+ return ability, capability, feature
+ raise ValueError(f"candidate feature {candidate_feature_id} was not found")
+
+ def _record_candidate_acceptance(
+ self,
+ repository_id: int,
+ analysis_run_id: int,
+ action: str,
+ notes: str,
+ ) -> None:
+ self.store.create_review_decision(
+ repository_id,
+ analysis_run_id,
+ action=action,
+ notes=notes,
+ )
+ self.store.update_repository_status(repository_id, "indexed")
+
def reject_candidate_ability(
self,
repository_id: int,
diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py
index f37bec2..7e78d33 100644
--- a/src/repo_registry/storage/sqlite.py
+++ b/src/repo_registry/storage/sqlite.py
@@ -504,6 +504,131 @@ class RegistryStore:
(status, repository_id, analysis_run_id),
)
+ def mark_candidate_ability_status(
+ self,
+ repository_id: int,
+ analysis_run_id: int,
+ candidate_ability_id: int,
+ status: str,
+ ) -> None:
+ with self.connect() as connection:
+ cursor = connection.execute(
+ """
+ UPDATE candidate_abilities
+ SET status = ?
+ WHERE id = ? AND repository_id = ? AND analysis_run_id = ?
+ """,
+ (status, candidate_ability_id, repository_id, analysis_run_id),
+ )
+ if cursor.rowcount == 0:
+ raise NotFoundError(
+ "candidate ability "
+ f"{candidate_ability_id} was not found for repository "
+ f"{repository_id} analysis run {analysis_run_id}"
+ )
+ capability_rows = connection.execute(
+ """
+ SELECT id FROM candidate_capabilities
+ WHERE ability_id = ? AND repository_id = ? AND analysis_run_id = ?
+ """,
+ (candidate_ability_id, repository_id, analysis_run_id),
+ ).fetchall()
+ capability_ids = [row["id"] for row in capability_rows]
+ connection.execute(
+ """
+ UPDATE candidate_capabilities
+ SET status = ?
+ WHERE ability_id = ? AND repository_id = ? AND analysis_run_id = ?
+ """,
+ (status, candidate_ability_id, repository_id, analysis_run_id),
+ )
+ for capability_id in capability_ids:
+ self._mark_candidate_children_status(
+ connection,
+ repository_id,
+ analysis_run_id,
+ capability_id,
+ status,
+ )
+
+ def mark_candidate_capability_status(
+ self,
+ repository_id: int,
+ analysis_run_id: int,
+ candidate_capability_id: int,
+ status: str,
+ ) -> None:
+ with self.connect() as connection:
+ cursor = connection.execute(
+ """
+ UPDATE candidate_capabilities
+ SET status = ?
+ WHERE id = ? AND repository_id = ? AND analysis_run_id = ?
+ """,
+ (status, candidate_capability_id, repository_id, analysis_run_id),
+ )
+ if cursor.rowcount == 0:
+ raise NotFoundError(
+ "candidate capability "
+ f"{candidate_capability_id} was not found for repository "
+ f"{repository_id} analysis run {analysis_run_id}"
+ )
+ self._mark_candidate_children_status(
+ connection,
+ repository_id,
+ analysis_run_id,
+ candidate_capability_id,
+ status,
+ )
+
+ def mark_candidate_feature_status(
+ self,
+ repository_id: int,
+ analysis_run_id: int,
+ candidate_feature_id: int,
+ status: str,
+ ) -> None:
+ with self.connect() as connection:
+ cursor = connection.execute(
+ """
+ UPDATE candidate_features
+ SET status = ?
+ WHERE id = ? AND repository_id = ? AND analysis_run_id = ?
+ """,
+ (status, candidate_feature_id, repository_id, analysis_run_id),
+ )
+ if cursor.rowcount == 0:
+ raise NotFoundError(
+ "candidate feature "
+ f"{candidate_feature_id} was not found for repository "
+ f"{repository_id} analysis run {analysis_run_id}"
+ )
+
+ def _mark_candidate_children_status(
+ self,
+ connection: sqlite3.Connection,
+ repository_id: int,
+ analysis_run_id: int,
+ candidate_capability_id: int,
+ status: str,
+ ) -> None:
+ connection.execute(
+ """
+ UPDATE candidate_features
+ SET status = ?
+ WHERE capability_id = ? AND repository_id = ? AND analysis_run_id = ?
+ """,
+ (status, candidate_capability_id, repository_id, analysis_run_id),
+ )
+ connection.execute(
+ """
+ UPDATE candidate_evidence
+ SET status = ?
+ WHERE capability_id = ? AND repository_id = ? AND analysis_run_id = ?
+ """,
+ (status, candidate_capability_id, repository_id, analysis_run_id),
+ )
+
def reject_candidate_ability(
self,
repository_id: int,
diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py
index 5fe793b..034597b 100644
--- a/src/repo_registry/web_ui/views.py
+++ b/src/repo_registry/web_ui/views.py
@@ -101,6 +101,14 @@ def page(title: str, body: str) -> HTMLResponse:
padding: 9px 10px;
font: inherit;
}}
+ select {{
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 9px 10px;
+ background: white;
+ font: inherit;
+ }}
label {{ display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 600; }}
label.checkbox {{
display: flex;
@@ -534,7 +542,10 @@ def repository_detail(
{render_graph_counts(
asdict(ability_map),
facts_count=None,
- base_href=f"/ui/repos/{repository_id}/elements?scope=approved",
+ base_href=(
+ f"/ui/repos/{repository_id}/elements?scope=all"
+ f"&entry_filter=approved"
+ ),
)}
{render_approved_registry_actions(repository_id, asdict(ability_map))}
Latest Candidate Graph
@@ -903,7 +914,8 @@ def analysis_run_detail(
asdict(candidate_graph),
facts_count=len(facts),
base_href=(
- f"/ui/repos/{repository_id}/elements?scope=candidate"
+ f"/ui/repos/{repository_id}/elements?scope=all"
+ f"&entry_filter=candidate"
f"&analysis_run_id={analysis_run_id}"
),
facts_href=(
@@ -986,38 +998,69 @@ def create_expectation_gap_from_form(
@router.get("/ui/repos/{repository_id}/elements")
def repository_element_listing(
repository_id: int,
- scope: str = Query("approved"),
+ scope: str = Query("all"),
type: str = Query("abilities"),
q: str = Query(""),
class_filter: str = Query(""),
+ entry_filter: str = Query(""),
+ candidate_status_filter: str = Query("active"),
analysis_run_id: int | None = Query(default=None),
service: RegistryService = Depends(get_service),
) -> HTMLResponse:
repository = service.get_repository(repository_id)
+ if scope in {"approved", "candidate"} and not entry_filter:
+ entry_filter = scope
title = element_listing_title(repository.name, scope, type)
- if scope == "approved":
- graph = asdict(service.ability_map(repository_id))
- elements = graph_element_rows(graph, type)
- elif scope == "candidate":
+ if scope in {"all", "approved", "candidate"}:
+ elements = graph_element_rows(
+ asdict(service.ability_map(repository_id)),
+ type,
+ entry_state="approved",
+ )
+ candidate_elements: list[dict] = []
if analysis_run_id is None:
runs = service.list_analysis_runs(repository_id)
latest = latest_completed_candidate_graph(service, repository_id, runs)
- if latest is None:
- elements = []
- else:
+ if latest is not None:
analysis_run_id, candidate_graph = latest
- elements = graph_element_rows(asdict(candidate_graph), type)
+ candidate_elements = graph_element_rows(
+ asdict(candidate_graph),
+ type,
+ entry_state="candidate",
+ )
else:
candidate_graph = service.candidate_graph(repository_id, analysis_run_id)
- elements = graph_element_rows(asdict(candidate_graph), type)
+ candidate_elements = graph_element_rows(
+ asdict(candidate_graph),
+ type,
+ entry_state="candidate",
+ )
+ elements.extend(candidate_elements)
elif scope == "facts":
facts = service.list_observed_facts(repository_id, analysis_run_id)
elements = fact_element_rows(facts)
else:
elements = []
- filtered = filter_element_rows(elements, q, class_filter)
- rows = render_element_rows(filtered)
+ entry_scoped_elements = filter_element_rows(
+ elements,
+ "",
+ "",
+ entry_filter,
+ candidate_status_filter,
+ )
+ filtered = filter_element_rows(
+ entry_scoped_elements,
+ q,
+ class_filter,
+ candidate_status_filter="all",
+ )
+ rows = render_element_rows(
+ filtered,
+ repository_id=repository_id,
+ analysis_run_id=analysis_run_id,
+ )
filter_action = f"/ui/repos/{repository_id}/elements"
+ listing_scope = "facts" if scope == "facts" else "all"
body = f"""
@@ -1026,24 +1069,26 @@ def repository_element_listing(
- | Class | Name | Parent | Source |
+ | Entry | Class | Name | Parent | Source | Actions |
{rows}
@@ -1155,6 +1200,25 @@ def reject_candidate_ability_from_form(
)
+@router.post(
+ "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
+ "/candidate-abilities/{candidate_ability_id}/accept"
+)
+def accept_candidate_ability_from_form(
+ repository_id: int,
+ analysis_run_id: int,
+ candidate_ability_id: int,
+ service: RegistryService = Depends(get_service),
+) -> RedirectResponse:
+ service.accept_candidate_ability(
+ repository_id,
+ analysis_run_id,
+ candidate_ability_id,
+ notes="Accepted from web UI",
+ )
+ return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
+
+
@router.post(
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
"/candidate-capabilities/{candidate_capability_id}/reject"
@@ -1177,6 +1241,25 @@ def reject_candidate_capability_from_form(
)
+@router.post(
+ "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
+ "/candidate-capabilities/{candidate_capability_id}/accept"
+)
+def accept_candidate_capability_from_form(
+ repository_id: int,
+ analysis_run_id: int,
+ candidate_capability_id: int,
+ service: RegistryService = Depends(get_service),
+) -> RedirectResponse:
+ service.accept_candidate_capability(
+ repository_id,
+ analysis_run_id,
+ candidate_capability_id,
+ notes="Accepted from web UI",
+ )
+ return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
+
+
@router.post(
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
"/candidate-features/{candidate_feature_id}/reject"
@@ -1199,6 +1282,25 @@ def reject_candidate_feature_from_form(
)
+@router.post(
+ "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
+ "/candidate-features/{candidate_feature_id}/accept"
+)
+def accept_candidate_feature_from_form(
+ repository_id: int,
+ analysis_run_id: int,
+ candidate_feature_id: int,
+ service: RegistryService = Depends(get_service),
+) -> RedirectResponse:
+ service.accept_candidate_feature(
+ repository_id,
+ analysis_run_id,
+ candidate_feature_id,
+ notes="Accepted from web UI",
+ )
+ return RedirectResponse(f"/ui/repos/{repository_id}", status_code=303)
+
+
@router.post(
"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
"/candidate-evidence/{candidate_evidence_id}/reject"
@@ -1752,7 +1854,8 @@ def render_latest_candidate_counts(
facts_count=facts_count,
label_prefix="candidate",
base_href=(
- f"/ui/repos/{repository_id}/elements?scope=candidate"
+ f"/ui/repos/{repository_id}/elements?scope=all"
+ f"&entry_filter=candidate"
f"&analysis_run_id={analysis_run_id}"
),
facts_href=(
@@ -1832,13 +1935,18 @@ def render_approved_registry_actions(repository_id: int, ability_map: dict) -> s
Search Profile
Discovery
Export
- Approved Elements
+ Elements
"""
-def graph_element_rows(graph: dict, item_type: str) -> list[dict]:
+def graph_element_rows(
+ graph: dict,
+ item_type: str,
+ *,
+ entry_state: str = "",
+) -> list[dict]:
rows: list[dict] = []
for ability in graph.get("abilities", []):
if item_type == "abilities":
@@ -1848,6 +1956,12 @@ def graph_element_rows(graph: dict, item_type: str) -> list[dict]:
ability["name"],
"",
ability.get("source_refs", []),
+ item_id=ability.get("id"),
+ item_kind="abilities",
+ description=ability.get("description", ""),
+ confidence=ability.get("confidence", 1.0),
+ status=ability.get("status", ""),
+ entry_state=entry_state,
)
)
for capability in ability.get("capabilities", []):
@@ -1858,6 +1972,14 @@ def graph_element_rows(graph: dict, item_type: str) -> list[dict]:
capability["name"],
ability["name"],
capability.get("source_refs", []),
+ item_id=capability.get("id"),
+ item_kind="capabilities",
+ description=capability.get("description", ""),
+ confidence=capability.get("confidence", 1.0),
+ inputs=capability.get("inputs", []),
+ outputs=capability.get("outputs", []),
+ status=capability.get("status", ""),
+ entry_state=entry_state,
)
)
for feature in capability.get("features", []):
@@ -1868,6 +1990,12 @@ def graph_element_rows(graph: dict, item_type: str) -> list[dict]:
feature["name"],
capability["name"],
feature.get("source_refs", []),
+ item_id=feature.get("id"),
+ item_kind="features",
+ confidence=feature.get("confidence", 1.0),
+ location=feature.get("location", ""),
+ status=feature.get("status", ""),
+ entry_state=entry_state,
)
)
return rows
@@ -1883,6 +2011,7 @@ def element_listing_title(repository_name: str, scope: str, item_type: str) -> s
if scope == "facts":
return f"{repository_name} ยท Observed Facts"
scope_labels = {
+ "all": "Registry",
"approved": "Approved",
"candidate": "Candidate",
}
@@ -1915,12 +2044,14 @@ def element_row(
name: str,
parent: str,
source_refs: list[dict],
+ **metadata,
) -> dict:
return {
"primary_class": primary_class,
"name": name,
"parent": parent,
"source_refs": source_refs,
+ **metadata,
}
@@ -1928,11 +2059,19 @@ def filter_element_rows(
rows: list[dict],
query: str,
class_filter: str,
+ entry_filter: str = "",
+ candidate_status_filter: str = "",
) -> list[dict]:
query = query.strip().lower()
class_filter = class_filter.strip().lower()
+ entry_filter = entry_filter.strip().lower()
+ candidate_status_filter = candidate_status_filter.strip().lower()
filtered = []
for row in rows:
+ if entry_filter and row.get("entry_state", "").lower() != entry_filter:
+ continue
+ if not candidate_status_matches(row, candidate_status_filter):
+ continue
row_class = str(row["primary_class"]).lower()
if class_filter and class_filter not in row_class:
continue
@@ -1941,6 +2080,8 @@ def filter_element_rows(
str(row["primary_class"]),
str(row["name"]),
str(row["parent"]),
+ str(row.get("entry_state", "")),
+ str(row.get("status", "")),
source_refs_text(row["source_refs"]),
]
).lower()
@@ -1950,23 +2091,232 @@ def filter_element_rows(
return filtered
-def render_element_rows(rows: list[dict]) -> str:
+def candidate_status_matches(row: dict, candidate_status_filter: str) -> bool:
+ if row.get("entry_state") != "candidate":
+ return True
+ status = str(row.get("status") or "candidate").lower()
+ if candidate_status_filter == "all":
+ return True
+ if candidate_status_filter in {"", "active"}:
+ return status != "rejected"
+ return status == candidate_status_filter
+
+
+def render_element_rows(
+ rows: list[dict],
+ repository_id: int,
+ analysis_run_id: int | None,
+) -> str:
if not rows:
- return '| No matching elements. |
'
- return "\n".join(render_element_row(row) for row in rows)
+ return '| No matching elements. |
'
+ return "\n".join(
+ render_element_row(row, repository_id, analysis_run_id)
+ for row in rows
+ )
-def render_element_row(row: dict) -> str:
+def render_element_row(
+ row: dict,
+ repository_id: int,
+ analysis_run_id: int | None,
+) -> str:
return f"""
+ | {render_entry_badge(row)} |
{escape(str(row["primary_class"]))} |
{escape(str(row["name"]))} |
{escape(str(row["parent"]))} |
{render_sources(row["source_refs"])} |
+ {render_element_actions(row, repository_id, analysis_run_id)} |
"""
+def render_element_actions(
+ row: dict,
+ repository_id: int,
+ analysis_run_id: int | None,
+) -> str:
+ item_id = row.get("item_id")
+ item_kind = row.get("item_kind")
+ if not item_id or not item_kind:
+ return ""
+ if row.get("entry_state") == "approved":
+ return render_approved_element_actions(row, repository_id)
+ if (
+ row.get("entry_state") == "candidate"
+ and analysis_run_id is not None
+ and row.get("status", "candidate") in {"candidate", "rejected"}
+ ):
+ return render_candidate_element_actions(row, repository_id, analysis_run_id)
+ if row.get("entry_state") == "candidate" and row.get("status") == "approved":
+ return 'Accepted'
+ return ""
+
+
+def render_entry_badge(row: dict) -> str:
+ entry_state = row.get("entry_state")
+ if not entry_state:
+ return 'fact'
+ status = row.get("status")
+ label = str(entry_state)
+ if entry_state == "candidate" and status and status != "candidate":
+ label = f"candidate: {status}"
+ return f'{escape(label)}'
+
+
+def render_entry_filter(entry_filter: str) -> str:
+ options = [
+ ("", "Approved and candidate"),
+ ("approved", "Approved only"),
+ ("candidate", "Candidate only"),
+ ]
+ rendered_options = "".join(
+ f''
+ for value, label in options
+ )
+ return f"""
+
+ """
+
+
+def render_approved_element_actions(row: dict, repository_id: int) -> str:
+ item_id = row["item_id"]
+ item_kind = row["item_kind"]
+ edit_action = f"/ui/repos/{repository_id}/{item_kind}/{item_id}/edit"
+ delete_action = f"/ui/repos/{repository_id}/{item_kind}/{item_id}/delete"
+ hidden_fields = render_element_hidden_fields(row)
+ edit_fields = render_element_edit_fields(row)
+ return f"""
+
+
+ """
+
+
+def render_candidate_element_actions(
+ row: dict,
+ repository_id: int,
+ analysis_run_id: int,
+) -> str:
+ item_id = row["item_id"]
+ item_kind = row["item_kind"]
+ collection = f"candidate-{item_kind}"
+ reject_action = (
+ f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
+ f"/{collection}/{item_id}/reject"
+ )
+ accept_action = (
+ f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
+ f"/{collection}/{item_id}/accept"
+ )
+ status = row.get("status", "candidate")
+ edit = ""
+ if item_kind in {"abilities", "capabilities"}:
+ edit_action = (
+ f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}"
+ f"/{collection}/{item_id}/edit"
+ )
+ edit = f"""
+
+ """
+ remove = (
+ f"""
+
+ """
+ if status == "candidate"
+ else ""
+ )
+ return f"""
+ {edit}
+
+ {remove}
+ """
+
+
+def render_candidate_status_filter(candidate_status_filter: str) -> str:
+ options = [
+ ("active", "Hide rejected"),
+ ("candidate", "Pending only"),
+ ("approved", "Accepted only"),
+ ("rejected", "Rejected only"),
+ ("all", "Include rejected"),
+ ]
+ rendered_options = "".join(
+ f''
+ for value, label in options
+ )
+ return f"""
+
+ """
+
+
+def render_element_edit_fields(row: dict) -> str:
+ item_kind = row["item_kind"]
+ name = escape(str(row["name"]))
+ if item_kind == "features":
+ feature_type = escape(str(row["primary_class"]))
+ location = escape(str(row.get("location", "")))
+ return f"""
+
+
+
+ """
+ return f''
+
+
+def render_element_hidden_fields(row: dict) -> str:
+ item_kind = row["item_kind"]
+ confidence = float(row.get("confidence", 1.0))
+ fields = [f'']
+ if item_kind in {"abilities", "capabilities"}:
+ fields.append(
+ f''
+ )
+ if item_kind == "capabilities":
+ inputs = escape(", ".join(row.get("inputs", [])))
+ outputs = escape(", ".join(row.get("outputs", [])))
+ fields.append(
+ f''
+ )
+ fields.append(
+ f''
+ )
+ return "".join(fields)
+
+
+def render_candidate_edit_fields(row: dict) -> str:
+ return f''
+
+
+def render_candidate_hidden_fields(row: dict) -> str:
+ return (
+ f''
+ f''
+ )
+
+
def render_class_datalist(rows: list[dict]) -> str:
classes = sorted({str(row["primary_class"]) for row in rows if row["primary_class"]})
options = "".join(
diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py
index 2ac5cac..cb36584 100644
--- a/tests/test_registry_service.py
+++ b/tests/test_registry_service.py
@@ -804,6 +804,59 @@ def test_approve_candidate_graph_publishes_ability_map_once(tmp_path):
assert decisions[0].notes == "Looks good for the first pass."
+def test_accept_candidate_feature_promotes_parent_context_once(tmp_path):
+ source = tmp_path / "repo"
+ source.mkdir()
+ (source / "README.md").write_text(
+ "# Feature Accept\nReports health over HTTP.\n",
+ encoding="utf-8",
+ )
+ (source / "app.py").write_text(
+ "from fastapi import FastAPI\n"
+ "app = FastAPI()\n"
+ '@app.get("/health")\n'
+ "def health():\n"
+ " return {}\n",
+ encoding="utf-8",
+ )
+ service = make_service(tmp_path)
+ repository = service.register_repository(name="Feature Accept", url=str(source))
+ summary = service.analyze_repository(repository.id)
+ graph = service.candidate_graph(repository.id, summary.analysis_run.id)
+ candidate_feature = graph.abilities[0].capabilities[0].features[0]
+
+ ability_map = service.accept_candidate_feature(
+ repository.id,
+ summary.analysis_run.id,
+ candidate_feature.id,
+ )
+ graph_after_feature_accept = service.candidate_graph(
+ repository.id,
+ summary.analysis_run.id,
+ )
+
+ assert len(ability_map.abilities) == 1
+ assert ability_map.abilities[0].capabilities[0].features[0].name == "GET /health"
+ assert graph_after_feature_accept.abilities[0].capabilities[0].features[0].status == (
+ "approved"
+ )
+
+ final_map = service.approve_candidate_graph(repository.id, summary.analysis_run.id)
+ assert len(final_map.abilities) == 1
+ interface_capabilities = [
+ capability
+ for capability in final_map.abilities[0].capabilities
+ if capability.name == "Expose Repository Interface"
+ ]
+ assert len(interface_capabilities) == 1
+ assert len(interface_capabilities[0].features) == 1
+ decisions = service.list_review_decisions(repository.id, summary.analysis_run.id)
+ assert {decision.action for decision in decisions} >= {
+ "accept_candidate_feature",
+ "approve_candidate_graph",
+ }
+
+
def test_analysis_run_diff_keeps_approved_map_stable_until_change_approval(tmp_path):
source = tmp_path / "repo"
source.mkdir()
diff --git a/tests/test_web_api.py b/tests/test_web_api.py
index bf09aa4..7e2e7c5 100644
--- a/tests/test_web_api.py
+++ b/tests/test_web_api.py
@@ -1194,6 +1194,17 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
assert "Use OpenRouter Models" in run_detail.text
assert "Expected from provider docs." in run_detail.text
+ pending_candidate_listing = client.get(
+ f"/ui/repos/{repository_id}/elements",
+ params={
+ "scope": "candidate",
+ "analysis_run_id": first_run_id,
+ "type": "features",
+ },
+ )
+ assert pending_candidate_listing.status_code == 200
+ assert "Accept" in pending_candidate_listing.text
+
approve_response = client.post(
f"{run_path}/candidate-graph/approve",
follow_redirects=False,
@@ -1215,14 +1226,14 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
assert "Search Profile" in approved_detail.text
assert "Discovery" in approved_detail.text
assert "Export" in approved_detail.text
- assert "Approved Elements" in approved_detail.text
+ assert "Elements" in approved_detail.text
assert "q=Report+Service+Status" in approved_detail.text
assert (
- f"/ui/repos/{repository_id}/elements?scope=approved&type=abilities"
+ f"/ui/repos/{repository_id}/elements?scope=all&entry_filter=approved&type=abilities"
in approved_detail.text
)
assert (
- f"/ui/repos/{repository_id}/elements?scope=candidate&analysis_run_id={first_run_id}&type=features"
+ f"/ui/repos/{repository_id}/elements?scope=all&entry_filter=candidate&analysis_run_id={first_run_id}&type=features"
in approved_detail.text
)
assert (
@@ -1237,11 +1248,49 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
approved_listing = client.get(
f"/ui/repos/{repository_id}/elements",
- params={"scope": "approved", "type": "capabilities"},
+ params={"scope": "all", "entry_filter": "approved", "type": "capabilities"},
)
assert approved_listing.status_code == 200
- assert "Approved Capabilities" in approved_listing.text
+ assert "Registry Capabilities" in approved_listing.text
+ assert "Entry" in approved_listing.text
+ assert "Approved only" in approved_listing.text
assert "Expose Repository Interface" in approved_listing.text
+ assert "Save" in approved_listing.text
+ assert "Delete" in approved_listing.text
+
+ combined_listing = client.get(
+ f"/ui/repos/{repository_id}/elements",
+ params={
+ "scope": "all",
+ "analysis_run_id": first_run_id,
+ "type": "features",
+ },
+ )
+ assert combined_listing.status_code == 200
+ assert "Registry Features" in combined_listing.text
+ assert "Approved and candidate" in combined_listing.text
+ assert ">approved<" in combined_listing.text
+ assert ">candidate: approved<" in combined_listing.text
+
+ approved_map = client.get(f"/repos/{repository_id}/ability-map").json()
+ approved_capability = approved_map["abilities"][0]["capabilities"][0]
+ tune_response = client.post(
+ f"/ui/repos/{repository_id}/capabilities/{approved_capability['id']}/edit",
+ data={
+ "name": "Expose Tuned Repository Interface",
+ "description": approved_capability["description"],
+ "inputs": ", ".join(approved_capability["inputs"]),
+ "outputs": ", ".join(approved_capability["outputs"]),
+ "confidence": str(approved_capability["confidence"]),
+ },
+ follow_redirects=False,
+ )
+ assert tune_response.status_code == 303
+ tuned_listing = client.get(
+ f"/ui/repos/{repository_id}/elements",
+ params={"scope": "all", "entry_filter": "approved", "type": "capabilities"},
+ )
+ assert "Expose Tuned Repository Interface" in tuned_listing.text
candidate_listing = client.get(
f"/ui/repos/{repository_id}/elements",
@@ -1359,6 +1408,95 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
)
assert filtered_search_response.status_code == 200
assert "UI Repo" in filtered_search_response.text
+
+ final_map = client.get(f"/repos/{repository_id}/ability-map").json()
+ feature_id = final_map["abilities"][0]["capabilities"][0]["features"][0]["id"]
+ delete_feature_response = client.post(
+ f"/ui/repos/{repository_id}/features/{feature_id}/delete",
+ follow_redirects=False,
+ )
+ assert delete_feature_response.status_code == 303
+ deleted_feature_listing = client.get(
+ f"/ui/repos/{repository_id}/elements",
+ params={"scope": "approved", "type": "features"},
+ )
+ assert f"/ui/repos/{repository_id}/features/{feature_id}/delete" not in (
+ deleted_feature_listing.text
+ )
+ finally:
+ app.dependency_overrides.clear()
+
+
+def test_ui_element_listing_hides_rejected_candidates_by_default(tmp_path):
+ source = tmp_path / "repo"
+ source.mkdir()
+ (source / "requirements.txt").write_text("fastapi\n", encoding="utf-8")
+ (source / "app.py").write_text(
+ "from fastapi import FastAPI\n"
+ "app = FastAPI()\n"
+ '@app.get("/status")\n'
+ "def status():\n"
+ " return {}\n",
+ encoding="utf-8",
+ )
+
+ def override_settings():
+ return Settings(
+ database_path=str(tmp_path / "ui-rejected.sqlite3"),
+ checkout_root=str(tmp_path / "ui-rejected-checkouts"),
+ )
+
+ app.dependency_overrides[get_settings] = override_settings
+ client = TestClient(app)
+ try:
+ repository_response = client.post(
+ "/repos",
+ json={"name": "Rejected UI", "url": str(source)},
+ )
+ repository_id = repository_response.json()["id"]
+ run_response = client.post(f"/repos/{repository_id}/analysis-runs", json={})
+ run_id = run_response.json()["analysis_run"]["id"]
+ candidate_graph = client.get(
+ f"/repos/{repository_id}/analysis-runs/{run_id}/candidate-graph"
+ ).json()
+ candidate_feature = candidate_graph["abilities"][0]["capabilities"][0][
+ "features"
+ ][0]
+ candidate_feature_id = candidate_feature["id"]
+ candidate_feature_name = candidate_feature["name"]
+
+ reject_response = client.post(
+ f"/ui/repos/{repository_id}/analysis-runs/{run_id}"
+ f"/candidate-features/{candidate_feature_id}/reject",
+ follow_redirects=False,
+ )
+ assert reject_response.status_code == 303
+
+ default_listing = client.get(
+ f"/ui/repos/{repository_id}/elements",
+ params={
+ "scope": "all",
+ "analysis_run_id": run_id,
+ "type": "features",
+ },
+ )
+ assert default_listing.status_code == 200
+ assert "Hide rejected" in default_listing.text
+ assert candidate_feature_name not in default_listing.text
+
+ rejected_listing = client.get(
+ f"/ui/repos/{repository_id}/elements",
+ params={
+ "scope": "all",
+ "analysis_run_id": run_id,
+ "type": "features",
+ "candidate_status_filter": "all",
+ },
+ )
+ assert rejected_listing.status_code == 200
+ assert candidate_feature_name in rejected_listing.text
+ assert "candidate: rejected" in rejected_listing.text
+ assert "Accept" in rejected_listing.text
finally:
app.dependency_overrides.clear()