From 466fd86d6d5a3c7bb3eda9549d68974337cd73d7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 29 Apr 2026 17:52:42 +0200 Subject: [PATCH] Candidate support/evidence acceptance --- src/repo_registry/core/service.py | 87 +++++++++++++++++++ src/repo_registry/storage/sqlite.py | 23 +++++ src/repo_registry/web_ui/views.py | 26 ++++++ tests/test_registry_service.py | 46 ++++++++++ tests/test_web_api.py | 12 +++ ...P-0003-automatic-repository-exploration.md | 6 ++ 6 files changed, 200 insertions(+) diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index 720ff14..157e214 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -379,6 +379,13 @@ class RegistryService: type=evidence.type, reference=evidence.reference, strength=evidence.strength, + target_kind=evidence.target_kind, + target_id=self._approved_evidence_target_id( + evidence, + approved_capability_id, + ), + reference_kind=evidence.reference_kind, + reference_id=evidence.reference_id, source_refs=evidence.source_refs, ) @@ -520,6 +527,58 @@ class RegistryService: ) return self.store.get_ability_map(repository_id) + def accept_candidate_evidence( + self, + repository_id: int, + analysis_run_id: int, + candidate_evidence_id: int, + *, + notes: str = "", + ) -> RepositoryAbilityMap: + graph = self.store.get_candidate_graph(repository_id, analysis_run_id) + parent_ability, parent_capability, evidence = ( + self._candidate_evidence_with_parent(graph, candidate_evidence_id) + ) + if evidence.status != "candidate": + raise ValueError( + f"candidate evidence {candidate_evidence_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_evidence( + repository_id, + approved_capability_id, + type=evidence.type, + reference=evidence.reference, + strength=evidence.strength, + target_kind=evidence.target_kind, + target_id=self._approved_evidence_target_id( + evidence, + approved_capability_id, + ), + reference_kind=evidence.reference_kind, + reference_id=evidence.reference_id, + source_refs=evidence.source_refs, + ) + self.store.mark_candidate_evidence_status( + repository_id, + analysis_run_id, + candidate_evidence_id, + "approved", + ) + self._record_candidate_acceptance( + repository_id, + analysis_run_id, + "accept_candidate_evidence", + notes or f"Accepted candidate support: {evidence.reference}", + ) + return self.store.get_ability_map(repository_id) + def diff_analysis_runs( self, repository_id: int, @@ -618,6 +677,13 @@ class RegistryService: type=evidence.type, reference=evidence.reference, strength=evidence.strength, + target_kind=evidence.target_kind, + target_id=self._approved_evidence_target_id( + evidence, + approved_capability_id, + ), + reference_kind=evidence.reference_kind, + reference_id=evidence.reference_id, source_refs=evidence.source_refs, ) return approved_capability_id @@ -673,6 +739,15 @@ class RegistryService: return ability, capability raise ValueError(f"candidate capability {candidate_capability_id} was not found") + def _approved_evidence_target_id( + self, + evidence: CandidateEvidence, + approved_capability_id: int, + ) -> int | None: + if evidence.target_kind == "capability": + return approved_capability_id + return evidence.target_id + def _candidate_feature_with_parent( self, graph: CandidateGraph, @@ -685,6 +760,18 @@ class RegistryService: return ability, capability, feature raise ValueError(f"candidate feature {candidate_feature_id} was not found") + def _candidate_evidence_with_parent( + self, + graph: CandidateGraph, + candidate_evidence_id: int, + ) -> tuple[CandidateAbility, CandidateCapability, CandidateEvidence]: + for ability in graph.abilities: + for capability in ability.capabilities: + for evidence in capability.evidence: + if evidence.id == candidate_evidence_id: + return ability, capability, evidence + raise ValueError(f"candidate evidence {candidate_evidence_id} was not found") + def _record_candidate_acceptance( self, repository_id: int, diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index 4dd80ea..9437b6a 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -677,6 +677,29 @@ class RegistryStore: f"{repository_id} analysis run {analysis_run_id}" ) + def mark_candidate_evidence_status( + self, + repository_id: int, + analysis_run_id: int, + candidate_evidence_id: int, + status: str, + ) -> None: + with self.connect() as connection: + cursor = connection.execute( + """ + UPDATE candidate_evidence + SET status = ? + WHERE id = ? AND repository_id = ? AND analysis_run_id = ? + """, + (status, candidate_evidence_id, repository_id, analysis_run_id), + ) + if cursor.rowcount == 0: + raise NotFoundError( + "candidate evidence " + f"{candidate_evidence_id} was not found for repository " + f"{repository_id} analysis run {analysis_run_id}" + ) + def _mark_candidate_children_status( self, connection: sqlite3.Connection, diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index 2ee0e00..a7c6b52 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -1367,6 +1367,25 @@ def reject_candidate_evidence_from_form( ) +@router.post( + "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-evidence/{candidate_evidence_id}/accept" +) +def accept_candidate_evidence_from_form( + repository_id: int, + analysis_run_id: int, + candidate_evidence_id: int, + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.accept_candidate_evidence( + repository_id, + analysis_run_id, + candidate_evidence_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-abilities/{candidate_ability_id}/edit" @@ -2376,6 +2395,10 @@ def render_candidate_support_element_actions( f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" f"/candidate-evidence/{item_id}/reject" ) + accept_action = ( + f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + f"/candidate-evidence/{item_id}/accept" + ) relink_action = ( f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" f"/candidate-evidence/{item_id}/relink" @@ -2385,6 +2408,9 @@ def render_candidate_support_element_actions( f"/candidate-evidence/{item_id}/merge" ) return f""" +
+ +
diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index 8bcfd17..5714938 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -878,6 +878,52 @@ def test_accept_candidate_feature_promotes_parent_context_once(tmp_path): } +def test_accept_candidate_evidence_promotes_parent_context(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text( + "# Support Accept\nDocuments an HTTP health interface.\n", + encoding="utf-8", + ) + (source / "tests").mkdir() + (source / "tests" / "test_health.py").write_text( + "def test_health(): pass\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="Support Accept", url=str(source)) + summary = service.analyze_repository(repository.id) + graph = service.candidate_graph(repository.id, summary.analysis_run.id) + candidate_evidence = graph.abilities[0].capabilities[0].evidence[0] + + ability_map = service.accept_candidate_evidence( + repository.id, + summary.analysis_run.id, + candidate_evidence.id, + ) + graph_after_accept = service.candidate_graph( + repository.id, + summary.analysis_run.id, + ) + + approved_evidence = ability_map.abilities[0].capabilities[0].evidence[0] + assert approved_evidence.reference == candidate_evidence.reference + assert approved_evidence.target_kind == "capability" + assert graph_after_accept.abilities[0].capabilities[0].evidence[0].status == ( + "approved" + ) + decisions = service.list_review_decisions(repository.id, summary.analysis_run.id) + assert decisions[0].action == "accept_candidate_evidence" + + 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 3a7b09a..bd99eca 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -1205,6 +1205,18 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): assert pending_candidate_listing.status_code == 200 assert "Accept" in pending_candidate_listing.text + pending_support_listing = client.get( + f"/ui/repos/{repository_id}/elements", + params={ + "scope": "candidate", + "analysis_run_id": first_run_id, + "type": "supports", + }, + ) + assert pending_support_listing.status_code == 200 + assert "Candidate Supports" in pending_support_listing.text + assert "Accept" in pending_support_listing.text + approve_response = client.post( f"{run_path}/candidate-graph/approve", follow_redirects=False, diff --git a/workplans/RREG-WP-0003-automatic-repository-exploration.md b/workplans/RREG-WP-0003-automatic-repository-exploration.md index 8376894..568b903 100644 --- a/workplans/RREG-WP-0003-automatic-repository-exploration.md +++ b/workplans/RREG-WP-0003-automatic-repository-exploration.md @@ -241,3 +241,9 @@ Implementation note 2026-04-29: the element browser now includes approved scope and support/evidence rows. Count badges link to scope and support listings, and support rows show both the supported characteristic target and the referenced source/fact/characteristic metadata. + +Implementation note 2026-04-29: candidate support/evidence can now be accepted +from the UI and service layer. Accepting support promotes the parent ability and +capability context as needed, records a review decision, marks the candidate +support approved, and maps capability support targets to the approved capability +ID.