From d5869bcaeb64c0fceb8c5d0899435b5aa5a4f052 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 25 Apr 2026 23:32:18 +0200 Subject: [PATCH] candidates can now be edited before approval --- src/repo_registry/core/service.py | 56 ++++++++++++++++++ src/repo_registry/storage/sqlite.py | 66 +++++++++++++++++++++ src/repo_registry/web_api/app.py | 55 +++++++++++++++++ src/repo_registry/web_ui/views.py | 92 ++++++++++++++++++++++++++++- tests/test_registry_service.py | 50 ++++++++++++++++ tests/test_web_api.py | 43 +++++++++++++- 6 files changed, 357 insertions(+), 5 deletions(-) diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index a0cf827..989793f 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -200,6 +200,62 @@ class RegistryService: 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, + analysis_run_id: int, + candidate_ability_id: int, + *, + name: str, + description: str, + confidence: float, + notes: str = "", + ) -> CandidateGraph: + self.store.update_candidate_ability( + repository_id, + analysis_run_id, + candidate_ability_id, + name=name, + description=description, + confidence=confidence, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="edit_candidate_ability", + notes=notes, + ) + self.store.update_repository_status(repository_id, "reviewing") + return self.store.get_candidate_graph(repository_id, analysis_run_id) + + def edit_candidate_capability( + self, + repository_id: int, + analysis_run_id: int, + candidate_capability_id: int, + *, + name: str, + description: str, + confidence: float, + notes: str = "", + ) -> CandidateGraph: + self.store.update_candidate_capability( + repository_id, + analysis_run_id, + candidate_capability_id, + name=name, + description=description, + confidence=confidence, + ) + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="edit_candidate_capability", + notes=notes, + ) + self.store.update_repository_status(repository_id, "reviewing") + return self.store.get_candidate_graph(repository_id, analysis_run_id) + def add_ability( self, repository_id: int, diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index 0038d77..7118f9b 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -441,6 +441,72 @@ class RegistryStore: (capability_id, repository_id, analysis_run_id), ) + def update_candidate_ability( + self, + repository_id: int, + analysis_run_id: int, + candidate_ability_id: int, + *, + name: str, + description: str, + confidence: float, + ) -> None: + with self.connect() as connection: + cursor = connection.execute( + """ + UPDATE candidate_abilities + SET name = ?, description = ?, confidence = ? + WHERE id = ? AND repository_id = ? AND analysis_run_id = ? + """, + ( + name, + description, + confidence, + 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}" + ) + + def update_candidate_capability( + self, + repository_id: int, + analysis_run_id: int, + candidate_capability_id: int, + *, + name: str, + description: str, + confidence: float, + ) -> None: + with self.connect() as connection: + cursor = connection.execute( + """ + UPDATE candidate_capabilities + SET name = ?, description = ?, confidence = ? + WHERE id = ? AND repository_id = ? AND analysis_run_id = ? + """, + ( + name, + description, + confidence, + 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}" + ) + 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 1d25000..6071278 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -77,6 +77,13 @@ class CandidateRejection(BaseModel): notes: str = "" +class CandidateEdit(BaseModel): + name: str + description: str = "" + confidence: float = Field(default=0.5, ge=0.0, le=1.0) + notes: str = "" + + app = FastAPI(title="Repository Ability Registry", version="0.1.0") @@ -217,6 +224,54 @@ def reject_candidate_ability( 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}" +) +def edit_candidate_ability( + repository_id: int, + analysis_run_id: int, + candidate_ability_id: int, + payload: CandidateEdit, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.edit_candidate_ability( + repository_id, + analysis_run_id, + candidate_ability_id, + **payload.model_dump(), + ) + ) + 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-capabilities/{candidate_capability_id}" +) +def edit_candidate_capability( + repository_id: int, + analysis_run_id: int, + candidate_capability_id: int, + payload: CandidateEdit, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.edit_candidate_capability( + repository_id, + analysis_run_id, + candidate_capability_id, + **payload.model_dump(), + ) + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.post("/repos/{repository_id}/abilities", status_code=201) def create_ability( repository_id: int, diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index 9618d2c..3bdfbf3 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -330,13 +330,72 @@ def reject_candidate_ability_from_form( ) +@router.post( + "/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}" + "/candidate-abilities/{candidate_ability_id}/edit" +) +def edit_candidate_ability_from_form( + repository_id: int, + analysis_run_id: int, + candidate_ability_id: int, + name: str = Form(...), + description: str = Form(""), + confidence: float = Form(...), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.edit_candidate_ability( + repository_id, + analysis_run_id, + candidate_ability_id, + name=name, + description=description, + confidence=confidence, + notes="Edited 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-capabilities/{candidate_capability_id}/edit" +) +def edit_candidate_capability_from_form( + repository_id: int, + analysis_run_id: int, + candidate_capability_id: int, + name: str = Form(...), + description: str = Form(""), + confidence: float = Form(...), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + service.edit_candidate_capability( + repository_id, + analysis_run_id, + candidate_capability_id, + name=name, + description=description, + confidence=confidence, + notes="Edited from web UI", + ) + return RedirectResponse( + f"/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}", + status_code=303, + ) + + def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int) -> str: abilities = graph.get("abilities", []) if not abilities: return '

No candidates generated.

' items = [] for ability in abilities: - capabilities = "".join(render_candidate_capability(capability) for capability in ability["capabilities"]) + capabilities = "".join( + render_candidate_capability(capability, repository_id, analysis_run_id) + for capability in ability["capabilities"] + ) items.append( f"""
  • @@ -345,6 +404,7 @@ def render_candidate_graph(graph: dict, repository_id: int, analysis_run_id: int {ability['confidence']:.2f} {render_candidate_ability_actions(ability, repository_id, analysis_run_id)}

    {escape(ability['description'])}

    + {render_candidate_edit_form('candidate-abilities', ability, repository_id, analysis_run_id)} {render_sources(ability['source_refs'])}
  • @@ -371,7 +431,34 @@ def render_candidate_ability_actions( """ -def render_candidate_capability(capability: dict) -> str: +def render_candidate_edit_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']}/edit" + ) + confidence = f"{candidate['confidence']:.2f}" + return f""" +
    + + + + +
    + """ + + +def render_candidate_capability( + capability: dict, + repository_id: int, + analysis_run_id: int, +) -> str: features = "".join( f'
  • {escape(feature["name"])} {escape(feature["type"])} {escape(feature["location"])}
  • ' for feature in capability["features"] @@ -386,6 +473,7 @@ def render_candidate_capability(capability: dict) -> str: {escape(capability['status'])} {capability['confidence']:.2f}

    {escape(capability['description'])}

    + {render_candidate_edit_form('candidate-capabilities', capability, repository_id, analysis_run_id)} {render_sources(capability['source_refs'])}

    Features

    diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index 5669f57..6185868 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -243,6 +243,56 @@ def test_reject_candidate_ability_excludes_it_from_approval(tmp_path): assert ability_map.abilities == [] +def test_edit_candidate_graph_values_before_approval(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# Editable\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="Editable", url=str(source)) + summary = service.analyze_repository(repository.id) + graph = service.candidate_graph(repository.id, summary.analysis_run.id) + candidate_ability = graph.abilities[0] + candidate_capability = candidate_ability.capabilities[0] + + service.edit_candidate_ability( + repository.id, + summary.analysis_run.id, + candidate_ability.id, + name="Service Health Monitoring", + description="Expose health state for operational monitoring.", + confidence=0.91, + notes="Curator renamed the generic ability.", + ) + service.edit_candidate_capability( + repository.id, + summary.analysis_run.id, + candidate_capability.id, + name="Report HTTP Health", + description="Return a lightweight health response over HTTP.", + confidence=0.87, + ) + + ability_map = service.approve_candidate_graph(repository.id, summary.analysis_run.id) + + assert service.get_repository(repository.id).status == "indexed" + assert ability_map.abilities[0].name == "Service Health Monitoring" + assert ability_map.abilities[0].description == ( + "Expose health state for operational monitoring." + ) + assert ability_map.abilities[0].confidence == 0.91 + assert ability_map.abilities[0].capabilities[0].name == "Report HTTP Health" + assert ability_map.abilities[0].capabilities[0].confidence == 0.87 + + 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 d37526f..e432161 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -139,6 +139,7 @@ def test_api_analysis_run_loop(tmp_path): "Review Frontend Repository Usefulness" ) candidate_ability_id = candidate_graph["abilities"][0]["id"] + candidate_capability_id = candidate_graph["abilities"][0]["capabilities"][0]["id"] reject_response = client.post( f"/repos/{repository_id}/analysis-runs/" @@ -152,6 +153,41 @@ def test_api_analysis_run_loop(tmp_path): run_response = client.post(f"/repos/{repository_id}/analysis-runs", json={}) assert run_response.status_code == 201 run = run_response.json() + candidate_response = client.get( + f"/repos/{repository_id}/analysis-runs/" + f"{run['analysis_run']['id']}/candidate-graph" + ) + candidate_graph = candidate_response.json() + candidate_ability_id = candidate_graph["abilities"][0]["id"] + candidate_capability_id = candidate_graph["abilities"][0]["capabilities"][0]["id"] + + ability_edit_response = client.patch( + f"/repos/{repository_id}/analysis-runs/" + f"{run['analysis_run']['id']}/candidate-abilities/" + f"{candidate_ability_id}", + json={ + "name": "Frontend Delivery", + "description": "Serve a browser frontend.", + "confidence": 0.9, + "notes": "API edit test", + }, + ) + assert ability_edit_response.status_code == 200 + assert ability_edit_response.json()["abilities"][0]["name"] == ( + "Frontend Delivery" + ) + + capability_edit_response = client.patch( + f"/repos/{repository_id}/analysis-runs/" + f"{run['analysis_run']['id']}/candidate-capabilities/" + f"{candidate_capability_id}", + json={ + "name": "Describe Frontend Stack", + "description": "Capture React and Vite usage.", + "confidence": 0.8, + }, + ) + assert capability_edit_response.status_code == 200 approve_response = client.post( f"/repos/{repository_id}/analysis-runs/" @@ -161,11 +197,12 @@ def test_api_analysis_run_loop(tmp_path): assert approve_response.status_code == 200 ability_map = approve_response.json() assert ability_map["repository"]["status"] == "indexed" - assert ability_map["abilities"][0]["name"] == ( - "Review Frontend Repository Usefulness" + assert ability_map["abilities"][0]["name"] == "Frontend Delivery" + assert ability_map["abilities"][0]["capabilities"][0]["name"] == ( + "Describe Frontend Stack" ) - search_response = client.get("/search", params={"q": "structure"}) + search_response = client.get("/search", params={"q": "frontend"}) assert search_response.status_code == 200 assert search_response.json()