From 142812e7f27171eea922c7596e0f2302e3fbf0b3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 29 Apr 2026 13:19:58 +0200 Subject: [PATCH] Combined approved and candidate view with actions --- src/repo_registry/core/service.py | 257 +++++++++++++++++- src/repo_registry/storage/sqlite.py | 125 +++++++++ src/repo_registry/web_ui/views.py | 402 ++++++++++++++++++++++++++-- tests/test_registry_service.py | 53 ++++ tests/test_web_api.py | 148 +++++++++- 5 files changed, 942 insertions(+), 43 deletions(-) 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(
- + {render_optional_hidden("analysis_run_id", analysis_run_id)}
+ {render_entry_filter(entry_filter) if scope != "facts" else ""} + {render_candidate_status_filter(candidate_status_filter) if scope != "facts" else ""}
- {render_class_datalist(elements)} + {render_class_datalist(entry_scoped_elements)}
- Clear - {len(filtered)} of {len(elements)} shown + Clear + {len(filtered)} of {len(entry_scoped_elements)} shown
- + {rows}
ClassNameParentSource
EntryClassNameParentSourceActions
@@ -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""" +
+ {hidden_fields} + {edit_fields} + +
+
+ +
+ """ + + +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""" +
+ {render_candidate_hidden_fields(row)} + {render_candidate_edit_fields(row)} + +
+ """ + 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()