From 71beb0d458ff5a11cac9308919cc953816796100 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 25 Apr 2026 23:39:29 +0200 Subject: [PATCH] Added rejection support for the rest of the candidate graph --- src/repo_registry/core/service.py | 66 +++++++++++++++ src/repo_registry/storage/sqlite.py | 96 ++++++++++++++++++++++ src/repo_registry/web_api/app.py | 72 ++++++++++++++++ src/repo_registry/web_ui/views.py | 122 +++++++++++++++++++++++++++- tests/test_registry_service.py | 79 ++++++++++++++++++ tests/test_web_api.py | 87 ++++++++++++++++++++ 6 files changed, 520 insertions(+), 2 deletions(-) diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index 989793f..5f4eea8 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -200,6 +200,72 @@ class RegistryService: self.store.update_repository_status(repository_id, "reviewing") return self.store.get_candidate_graph(repository_id, analysis_run_id) + def reject_candidate_capability( + self, + repository_id: int, + analysis_run_id: int, + candidate_capability_id: int, + *, + notes: str = "", + ) -> CandidateGraph: + self.store.reject_candidate_capability( + repository_id, + analysis_run_id, + candidate_capability_id, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="reject_candidate_capability", + notes=notes, + ) + self.store.update_repository_status(repository_id, "reviewing") + return self.store.get_candidate_graph(repository_id, analysis_run_id) + + def reject_candidate_feature( + self, + repository_id: int, + analysis_run_id: int, + candidate_feature_id: int, + *, + notes: str = "", + ) -> CandidateGraph: + self.store.reject_candidate_feature( + repository_id, + analysis_run_id, + candidate_feature_id, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="reject_candidate_feature", + notes=notes, + ) + self.store.update_repository_status(repository_id, "reviewing") + return self.store.get_candidate_graph(repository_id, analysis_run_id) + + def reject_candidate_evidence( + self, + repository_id: int, + analysis_run_id: int, + candidate_evidence_id: int, + *, + notes: str = "", + ) -> CandidateGraph: + self.store.reject_candidate_evidence( + repository_id, + analysis_run_id, + candidate_evidence_id, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="reject_candidate_evidence", + notes=notes, + ) + self.store.update_repository_status(repository_id, "reviewing") + return self.store.get_candidate_graph(repository_id, analysis_run_id) + def edit_candidate_ability( self, repository_id: int, diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index 7118f9b..b2969e0 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -441,6 +441,75 @@ class RegistryStore: (capability_id, repository_id, analysis_run_id), ) + def reject_candidate_capability( + self, + repository_id: int, + analysis_run_id: int, + candidate_capability_id: int, + ) -> None: + with self.connect() as connection: + cursor = connection.execute( + """ + UPDATE candidate_capabilities + SET status = 'rejected' + WHERE id = ? + AND repository_id = ? + AND analysis_run_id = ? + AND status = 'candidate' + """, + (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}" + ) + connection.execute( + """ + UPDATE candidate_features + SET status = 'rejected' + WHERE capability_id = ? AND repository_id = ? AND analysis_run_id = ? + """, + (candidate_capability_id, repository_id, analysis_run_id), + ) + connection.execute( + """ + UPDATE candidate_evidence + SET status = 'rejected' + WHERE capability_id = ? AND repository_id = ? AND analysis_run_id = ? + """, + (candidate_capability_id, repository_id, analysis_run_id), + ) + + def reject_candidate_feature( + self, + repository_id: int, + analysis_run_id: int, + candidate_feature_id: int, + ) -> None: + self._reject_candidate_leaf( + table="candidate_features", + label="candidate feature", + repository_id=repository_id, + analysis_run_id=analysis_run_id, + candidate_id=candidate_feature_id, + ) + + def reject_candidate_evidence( + self, + repository_id: int, + analysis_run_id: int, + candidate_evidence_id: int, + ) -> None: + self._reject_candidate_leaf( + table="candidate_evidence", + label="candidate evidence", + repository_id=repository_id, + analysis_run_id=analysis_run_id, + candidate_id=candidate_evidence_id, + ) + def update_candidate_ability( self, repository_id: int, @@ -507,6 +576,33 @@ class RegistryStore: f"{repository_id} analysis run {analysis_run_id}" ) + def _reject_candidate_leaf( + self, + *, + table: str, + label: str, + repository_id: int, + analysis_run_id: int, + candidate_id: int, + ) -> None: + with self.connect() as connection: + cursor = connection.execute( + f""" + UPDATE {table} + SET status = 'rejected' + WHERE id = ? + AND repository_id = ? + AND analysis_run_id = ? + AND status = 'candidate' + """, + (candidate_id, repository_id, analysis_run_id), + ) + if cursor.rowcount == 0: + raise NotFoundError( + f"{label} {candidate_id} was not found for repository " + f"{repository_id} analysis run {analysis_run_id}" + ) + def create_review_decision( self, repository_id: int, diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index 6071278..9f369d7 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -224,6 +224,78 @@ def reject_candidate_ability( raise HTTPException(status_code=404, detail=str(exc)) from exc +@app.post( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-capabilities/{candidate_capability_id}/reject" +) +def reject_candidate_capability( + repository_id: int, + analysis_run_id: int, + candidate_capability_id: int, + payload: CandidateRejection, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.reject_candidate_capability( + repository_id, + analysis_run_id, + candidate_capability_id, + notes=payload.notes, + ) + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.post( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-features/{candidate_feature_id}/reject" +) +def reject_candidate_feature( + repository_id: int, + analysis_run_id: int, + candidate_feature_id: int, + payload: CandidateRejection, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.reject_candidate_feature( + repository_id, + analysis_run_id, + candidate_feature_id, + notes=payload.notes, + ) + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.post( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-evidence/{candidate_evidence_id}/reject" +) +def reject_candidate_evidence( + repository_id: int, + analysis_run_id: int, + candidate_evidence_id: int, + payload: CandidateRejection, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.reject_candidate_evidence( + repository_id, + analysis_run_id, + candidate_evidence_id, + notes=payload.notes, + ) + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.patch( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-abilities/{candidate_ability_id}" diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index 3bdfbf3..92cac0f 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -330,6 +330,72 @@ def reject_candidate_ability_from_form( ) +@router.post( + "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-capabilities/{candidate_capability_id}/reject" +) +def reject_candidate_capability_from_form( + repository_id: int, + analysis_run_id: int, + candidate_capability_id: int, + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.reject_candidate_capability( + repository_id, + analysis_run_id, + candidate_capability_id, + notes="Rejected from web UI", + ) + return RedirectResponse( + f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}", + status_code=303, + ) + + +@router.post( + "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-features/{candidate_feature_id}/reject" +) +def reject_candidate_feature_from_form( + repository_id: int, + analysis_run_id: int, + candidate_feature_id: int, + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.reject_candidate_feature( + repository_id, + analysis_run_id, + candidate_feature_id, + notes="Rejected from web UI", + ) + return RedirectResponse( + f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}", + status_code=303, + ) + + +@router.post( + "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-evidence/{candidate_evidence_id}/reject" +) +def reject_candidate_evidence_from_form( + repository_id: int, + analysis_run_id: int, + candidate_evidence_id: int, + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.reject_candidate_evidence( + repository_id, + analysis_run_id, + candidate_evidence_id, + notes="Rejected from web UI", + ) + return RedirectResponse( + f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}", + status_code=303, + ) + + @router.post( "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-abilities/{candidate_ability_id}/edit" @@ -460,11 +526,11 @@ def render_candidate_capability( analysis_run_id: int, ) -> str: features = "".join( - f'
  • {escape(feature["name"])} {escape(feature["type"])} {escape(feature["location"])}
  • ' + render_candidate_feature(feature, repository_id, analysis_run_id) for feature in capability["features"] ) evidence = "".join( - f'
  • {escape(item["type"])} {escape(item["strength"])} {escape(item["reference"])}
  • ' + render_candidate_evidence(item, repository_id, analysis_run_id) for item in capability["evidence"] ) return f""" @@ -472,6 +538,7 @@ def render_candidate_capability( {escape(capability['name'])} {escape(capability['status'])} {capability['confidence']:.2f} + {render_candidate_reject_form('candidate-capabilities', capability, repository_id, analysis_run_id)}

    {escape(capability['description'])}

    {render_candidate_edit_form('candidate-capabilities', capability, repository_id, analysis_run_id)} {render_sources(capability['source_refs'])} @@ -483,6 +550,57 @@ def render_candidate_capability( """ +def render_candidate_feature( + feature: dict, + repository_id: int, + analysis_run_id: int, +) -> str: + return f""" +
  • + {escape(feature["name"])} + {escape(feature["status"])} + {escape(feature["type"])} + {escape(feature["location"])} + {render_candidate_reject_form('candidate-features', feature, repository_id, analysis_run_id)} +
  • + """ + + +def render_candidate_evidence( + evidence: dict, + repository_id: int, + analysis_run_id: int, +) -> str: + return f""" +
  • + {escape(evidence["type"])} + {escape(evidence["status"])} + {escape(evidence["strength"])} + {escape(evidence["reference"])} + {render_candidate_reject_form('candidate-evidence', evidence, repository_id, analysis_run_id)} +
  • + """ + + +def render_candidate_reject_form( + collection: str, + candidate: dict, + repository_id: int, + analysis_run_id: int, +) -> str: + if candidate["status"] != "candidate": + return "" + action = ( + f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + f"/{collection}/{candidate['id']}/reject" + ) + return f""" +
    + +
    + """ + + def render_ability_map(ability_map: dict) -> str: abilities = ability_map.get("abilities", []) if not abilities: diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index 6185868..f987976 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -293,6 +293,85 @@ def test_edit_candidate_graph_values_before_approval(tmp_path): assert ability_map.abilities[0].capabilities[0].confidence == 0.87 +def test_reject_candidate_capability_excludes_it_from_approval(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# Capability Reject\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="Capability Reject", url=str(source)) + summary = service.analyze_repository(repository.id) + graph = service.candidate_graph(repository.id, summary.analysis_run.id) + candidate_capability = graph.abilities[0].capabilities[0] + + rejected_graph = service.reject_candidate_capability( + repository.id, + summary.analysis_run.id, + candidate_capability.id, + notes="Interface is not relevant.", + ) + ability_map = service.approve_candidate_graph(repository.id, summary.analysis_run.id) + + assert rejected_graph.abilities[0].capabilities[0].status == "rejected" + assert rejected_graph.abilities[0].capabilities[0].features[0].status == "rejected" + approved_capability_names = { + capability.name for capability in ability_map.abilities[0].capabilities + } + assert candidate_capability.name not in approved_capability_names + + +def test_reject_candidate_feature_and_evidence_excludes_only_those_leaves(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# Leaf Reject\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="Leaf Reject", url=str(source)) + summary = service.analyze_repository(repository.id) + graph = service.candidate_graph(repository.id, summary.analysis_run.id) + capability = graph.abilities[0].capabilities[0] + + service.reject_candidate_feature( + repository.id, + summary.analysis_run.id, + capability.features[0].id, + notes="Feature is incidental.", + ) + service.reject_candidate_evidence( + repository.id, + summary.analysis_run.id, + capability.evidence[0].id, + notes="Evidence is too weak.", + ) + ability_map = service.approve_candidate_graph(repository.id, summary.analysis_run.id) + + approved_capability = ability_map.abilities[0].capabilities[0] + assert approved_capability.name == capability.name + assert approved_capability.features == [] + assert len(approved_capability.evidence) == len(capability.evidence) - 1 + + def test_analyze_repository_failure_is_recorded(tmp_path): service = make_service(tmp_path) repository = service.register_repository( diff --git a/tests/test_web_api.py b/tests/test_web_api.py index e432161..a652eaa 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -284,3 +284,90 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): assert "Review UI Repo Repository Usefulness" in approved_detail.text finally: app.dependency_overrides.clear() + + +def test_api_rejects_candidate_capability_feature_and_evidence(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# API Reject Leaves\n", encoding="utf-8") + (source / "tests").mkdir() + (source / "tests" / "test_status.py").write_text( + "def test_status(): pass\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 / "api-reject.sqlite3"), + checkout_root=str(tmp_path / "api-reject-checkouts"), + ) + + app.dependency_overrides[get_settings] = override_settings + client = TestClient(app) + try: + repository_response = client.post( + "/repos", + json={"name": "API Reject Leaves", "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"] + graph_response = client.get( + f"/repos/{repository_id}/analysis-runs/{run_id}/candidate-graph" + ) + capability = graph_response.json()["abilities"][0]["capabilities"][0] + feature_id = capability["features"][0]["id"] + evidence_id = capability["evidence"][0]["id"] + + feature_response = client.post( + f"/repos/{repository_id}/analysis-runs/{run_id}" + f"/candidate-features/{feature_id}/reject", + json={"notes": "Noisy interface"}, + ) + assert feature_response.status_code == 200 + assert ( + feature_response.json()["abilities"][0]["capabilities"][0]["features"][0][ + "status" + ] + == "rejected" + ) + + evidence_response = client.post( + f"/repos/{repository_id}/analysis-runs/{run_id}" + f"/candidate-evidence/{evidence_id}/reject", + json={"notes": "Weak evidence"}, + ) + assert evidence_response.status_code == 200 + assert ( + evidence_response.json()["abilities"][0]["capabilities"][0]["evidence"][0][ + "status" + ] + == "rejected" + ) + + run_response = client.post(f"/repos/{repository_id}/analysis-runs", json={}) + run_id = run_response.json()["analysis_run"]["id"] + graph_response = client.get( + f"/repos/{repository_id}/analysis-runs/{run_id}/candidate-graph" + ) + capability_id = graph_response.json()["abilities"][0]["capabilities"][0]["id"] + capability_response = client.post( + f"/repos/{repository_id}/analysis-runs/{run_id}" + f"/candidate-capabilities/{capability_id}/reject", + json={"notes": "Reject whole capability"}, + ) + assert capability_response.status_code == 200 + assert ( + capability_response.json()["abilities"][0]["capabilities"][0]["status"] + == "rejected" + ) + finally: + app.dependency_overrides.clear()