diff --git a/src/repo_registry/core/models.py b/src/repo_registry/core/models.py index db5adf7..40ebb12 100644 --- a/src/repo_registry/core/models.py +++ b/src/repo_registry/core/models.py @@ -129,6 +129,18 @@ class ScanSummary: facts: list[ObservedFact] +@dataclass(frozen=True) +class CharacteristicRebuildResult: + repository: Repository + analysis_run: AnalysisRun + dry_run: bool + confirmed: bool + cleared_approved: bool + previous_counts: dict[str, int] + previous_ids: dict[str, list[int]] + candidate_counts: dict[str, int] + + @dataclass(frozen=True) class SourceReference: fact_id: int | None diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index 0fc0cbd..b95fa56 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -15,6 +15,7 @@ from repo_registry.core.models import ( CandidateEvidence, CandidateFeature, CandidateGraph, + CharacteristicRebuildResult, ContentChunk, ExpectationGap, ObservedFact, @@ -228,14 +229,13 @@ class RegistryService: notes=f"Generated {len(candidates)} candidate ability draft(s).", ) if trusted_auto_approve: - self.approve_candidate_graph( + self.trusted_auto_approve_candidate_graph( repository_id, completed_run.id, notes=( - "Trusted auto-populate mode approved candidate graph " + "Trusted auto-populate mode reviewed candidate graph " f"after {candidate_source} candidate generation." ), - action="trusted_auto_approve_candidate_graph", ) log_operation( "analysis_completed", @@ -335,6 +335,79 @@ class RegistryService: def candidate_graph(self, repository_id: int, analysis_run_id: int) -> CandidateGraph: return self.store.get_candidate_graph(repository_id, analysis_run_id) + def rebuild_characteristics_from_scratch( + self, + repository_id: int, + *, + dry_run: bool = True, + confirm: bool = False, + source_path: str | None = None, + use_cached_checkout: bool = False, + use_llm_assistance: bool = True, + access_username: str | None = None, + access_password: str | None = None, + ) -> CharacteristicRebuildResult: + if not dry_run and not confirm: + raise ValueError("confirmed rebuild requires confirm=True") + + repository = self.store.get_repository(repository_id) + previous_counts = self._approved_counts(repository_id) + previous_ids = self._approved_ids(repository_id) + summary = self.analyze_repository( + repository_id, + source_path=source_path, + use_cached_checkout=use_cached_checkout, + use_llm_assistance=use_llm_assistance, + trusted_auto_approve=False, + access_username=access_username, + access_password=access_password, + ) + if summary.analysis_run.status != "completed": + return CharacteristicRebuildResult( + repository=repository, + analysis_run=summary.analysis_run, + dry_run=dry_run, + confirmed=confirm, + cleared_approved=False, + previous_counts=previous_counts, + previous_ids=previous_ids, + candidate_counts={}, + ) + + graph = self.store.get_candidate_graph(repository_id, summary.analysis_run.id) + candidate_counts = self._candidate_counts(graph) + cleared = False + if not dry_run: + self.store.clear_approved_characteristics(repository_id) + self.store.update_repository_status(repository_id, "analyzed") + cleared = True + + action = ( + "rebuild_characteristics_from_scratch" + if cleared + else "dry_run_rebuild_characteristics_from_scratch" + ) + self.store.create_review_decision( + repository_id, + summary.analysis_run.id, + action=action, + notes=( + f"Previous approved counts: {previous_counts}. " + f"Previous approved IDs: {previous_ids}. " + f"New candidate counts: {candidate_counts}." + ), + ) + return CharacteristicRebuildResult( + repository=repository, + analysis_run=summary.analysis_run, + dry_run=dry_run, + confirmed=confirm, + cleared_approved=cleared, + previous_counts=previous_counts, + previous_ids=previous_ids, + candidate_counts=candidate_counts, + ) + def approve_candidate_graph( self, repository_id: int, @@ -406,6 +479,160 @@ class RegistryService: self.store.update_repository_status(repository_id, "indexed") return self.store.get_ability_map(repository_id) + def trusted_auto_approve_candidate_graph( + self, + repository_id: int, + analysis_run_id: int, + *, + notes: str = "", + ) -> RepositoryAbilityMap: + graph = self.store.get_candidate_graph(repository_id, analysis_run_id) + approved_count = 0 + skipped_count = 0 + for ability in graph.abilities: + if ability.status != "candidate": + continue + candidate_capabilities = [ + capability + for capability in ability.capabilities + if capability.status == "candidate" + ] + safe_capabilities = [ + capability + for capability in candidate_capabilities + if self._trusted_auto_approve_capability_safe(capability) + ] + skipped_count += len(candidate_capabilities) - len(safe_capabilities) + if not safe_capabilities: + continue + approved_ability_id = self._ensure_approved_ability(repository_id, ability) + for capability in safe_capabilities: + self._create_approved_capability_subtree( + repository_id, + approved_ability_id, + capability, + ) + self.store.mark_candidate_capability_status( + repository_id, + analysis_run_id, + capability.id, + "approved", + ) + approved_count += 1 + if len(safe_capabilities) == len(candidate_capabilities): + self.store.mark_candidate_ability_status( + repository_id, + analysis_run_id, + ability.id, + "approved", + ) + + if approved_count: + self.store.update_repository_status(repository_id, "indexed") + self.store.create_review_decision( + repository_id, + analysis_run_id, + action="trusted_auto_approve_candidate_graph", + notes=( + f"{notes} Auto-approved {approved_count} safe candidate " + f"capability(s); left {skipped_count} for review." + ).strip(), + ) + return self.store.get_ability_map(repository_id) + + def _trusted_auto_approve_capability_safe( + self, + capability: CandidateCapability, + ) -> bool: + has_source_refs = bool(capability.source_refs) or any( + feature.source_refs for feature in capability.features + ) + if not has_source_refs: + return False + if capability.primary_class == "repository-structure": + return False + if capability.primary_class == "llm-integration": + return bool( + {"utility-owned", "utility-facade", "utility-adapter"} + & set(capability.attributes) + ) + if capability.primary_class in {"interface", "API", "CLI", "callable", "api", "cli"}: + return capability.confidence >= 0.55 + if capability.features: + return capability.confidence >= 0.55 + return capability.confidence >= 0.75 + + def _approved_counts(self, repository_id: int) -> dict[str, int]: + ability_map = self.store.get_ability_map(repository_id) + capabilities = [ + capability + for ability in ability_map.abilities + for capability in ability.capabilities + ] + features = [ + feature + for capability in capabilities + for feature in capability.features + ] + evidence = [ + item + for capability in capabilities + for item in capability.evidence + ] + return { + "abilities": len(ability_map.abilities), + "capabilities": len(capabilities), + "features": len(features), + "evidence": len(evidence), + } + + def _approved_ids(self, repository_id: int) -> dict[str, list[int]]: + ability_map = self.store.get_ability_map(repository_id) + capabilities = [ + capability + for ability in ability_map.abilities + for capability in ability.capabilities + ] + features = [ + feature + for capability in capabilities + for feature in capability.features + ] + evidence = [ + item + for capability in capabilities + for item in capability.evidence + ] + return { + "abilities": [ability.id for ability in ability_map.abilities], + "capabilities": [capability.id for capability in capabilities], + "features": [feature.id for feature in features], + "evidence": [item.id for item in evidence], + } + + def _candidate_counts(self, graph: CandidateGraph) -> dict[str, int]: + capabilities = [ + capability + for ability in graph.abilities + for capability in ability.capabilities + ] + features = [ + feature + for capability in capabilities + for feature in capability.features + ] + evidence = [ + item + for capability in capabilities + for item in capability.evidence + ] + return { + "abilities": len(graph.abilities), + "capabilities": len(capabilities), + "features": len(features), + "evidence": len(evidence), + } + def accept_candidate_ability( self, repository_id: int, diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index 9965b36..99d8102 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -2226,6 +2226,14 @@ class RegistryStore: ), ) + def clear_approved_characteristics(self, repository_id: int) -> None: + self.get_repository(repository_id) + with self.connect() as connection: + connection.execute( + "DELETE FROM approved_abilities WHERE repository_id = ?", + (repository_id,), + ) + def get_ability_map(self, repository_id: int) -> RepositoryAbilityMap: repository = self.get_repository(repository_id) scope = self._ensure_scope(repository_id) diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index e2c8f8a..9a09274 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -34,6 +34,8 @@ from repo_registry.web_api.schemas import ( CandidateFeatureMerge, CandidateGraphApproval, CandidateGraphResponse, + CharacteristicRebuildRequest, + CharacteristicRebuildResponse, CandidateLeafRelink, CandidateRejection, CapabilityGapRequest, @@ -271,6 +273,33 @@ def create_analysis_run( return asdict(summary) +@app.post( + "/repos/{repository_id}/characteristics/rebuild", + tags=["analysis"], + response_model=CharacteristicRebuildResponse, +) +def rebuild_characteristics_from_scratch( + repository_id: int, + payload: CharacteristicRebuildRequest, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict( + service.rebuild_characteristics_from_scratch( + repository_id, + dry_run=payload.dry_run, + confirm=payload.confirm, + source_path=payload.source_path, + use_cached_checkout=payload.use_cached_checkout, + use_llm_assistance=payload.use_llm_assistance, + ) + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get( "/repos/{repository_id}/analysis-runs", tags=["analysis"], diff --git a/src/repo_registry/web_api/schemas.py b/src/repo_registry/web_api/schemas.py index b363de3..b4f83e0 100644 --- a/src/repo_registry/web_api/schemas.py +++ b/src/repo_registry/web_api/schemas.py @@ -289,6 +289,25 @@ class AnalysisRunChangeApproval(BaseModel): } +class CharacteristicRebuildRequest(BaseModel): + dry_run: bool = True + confirm: bool = False + source_path: str | None = None + use_cached_checkout: bool = False + use_llm_assistance: bool = True + + +class CharacteristicRebuildResponse(BaseModel): + repository: RepositoryResponse + analysis_run: AnalysisRunResponse + dry_run: bool + confirmed: bool + cleared_approved: bool + previous_counts: dict[str, int] + previous_ids: dict[str, list[int]] + candidate_counts: dict[str, int] + + class CandidateRejection(BaseModel): notes: str = "" diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index 17fec7d..94178ce 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -721,6 +721,18 @@ def repository_detail( RunStatusStartedCompareError {run_rows or 'No runs yet.'} +

Rebuild Characteristics

+
+ + + + + +
+ + Rebuilding... +
+
@@ -1146,6 +1158,30 @@ def create_analysis_run_from_form( ) +@router.post("/ui/repos/{repository_id}/characteristics/rebuild") +def rebuild_characteristics_from_form( + repository_id: int, + source_path: str = Form(""), + use_cached_checkout: str | None = Form(None), + use_llm_assistance: str | None = Form(None), + dry_run: str | None = Form(None), + confirm: str | None = Form(None), + service: RegistryService = Depends(get_service), +) -> RedirectResponse: + result = service.rebuild_characteristics_from_scratch( + repository_id, + dry_run=bool(dry_run), + confirm=bool(confirm), + source_path=source_path or None, + use_cached_checkout=bool(use_cached_checkout), + use_llm_assistance=bool(use_llm_assistance), + ) + return RedirectResponse( + f"/ui/repos/{repository_id}/analysis-runs/{result.analysis_run.id}", + status_code=303, + ) + + @router.get("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}") def analysis_run_detail( repository_id: int, diff --git a/tests/test_candidate_graph.py b/tests/test_candidate_graph.py index 1f172ef..23a0fc4 100644 --- a/tests/test_candidate_graph.py +++ b/tests/test_candidate_graph.py @@ -372,6 +372,7 @@ def test_candidate_generator_maps_llm_provider_facts_to_capability(): feature for feature in capability.features if feature.name == "Use OpenRouter Models" ) assert openrouter_feature.primary_class == "integration" + assert {"llm-provider", "openrouter"} <= set(openrouter_feature.attributes) def test_candidate_generator_does_not_promote_llm_provider_mentions_to_capability(): @@ -402,4 +403,3 @@ def test_candidate_generator_does_not_promote_llm_provider_mentions_to_capabilit for capability in graph[0].capabilities if capability.name == "Route LLM Requests Across Providers" ] == [] - assert {"llm-provider", "openrouter"} <= set(openrouter_feature.attributes) diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index 6bdb002..91954d1 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -746,10 +746,92 @@ def test_analyze_repository_can_trusted_auto_approve_candidates(tmp_path): decisions = service.list_review_decisions(repository.id, summary.analysis_run.id) assert service.get_repository(repository.id).status == "indexed" - assert graph.abilities[0].status == "approved" + statuses_by_capability = { + capability.name: capability.status + for capability in graph.abilities[0].capabilities + } + assert statuses_by_capability["Expose Repository Interface"] == "approved" + assert statuses_by_capability["Describe Repository Structure"] == "candidate" assert ability_map.abilities[0].name == "Report Health Over HTTP" assert decisions[0].action == "trusted_auto_approve_candidate_graph" assert "deterministic candidate generation" in decisions[0].notes + assert "Auto-approved 1 safe candidate capability(s); left 1 for review." in decisions[0].notes + + +def test_rebuild_characteristics_dry_run_preserves_approved_map(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# Rebuild\nReports health over HTTP.\n", encoding="utf-8") + (source / "app.py").write_text('@app.get("/health")\ndef health():\n return {}\n', encoding="utf-8") + service = make_service(tmp_path) + repository = service.register_repository(name="Rebuild", url=str(source)) + summary = service.analyze_repository(repository.id, use_llm_assistance=False) + service.approve_candidate_graph(repository.id, summary.analysis_run.id) + + result = service.rebuild_characteristics_from_scratch( + repository.id, + dry_run=True, + source_path=str(source), + use_llm_assistance=False, + ) + + assert result.dry_run is True + assert result.cleared_approved is False + assert result.previous_counts["abilities"] == 1 + assert result.previous_ids["abilities"] + assert result.candidate_counts["abilities"] == 1 + assert service.ability_map(repository.id).abilities + decisions = service.list_review_decisions(repository.id, result.analysis_run.id) + assert decisions[-1].action == "dry_run_rebuild_characteristics_from_scratch" + + +def test_rebuild_characteristics_requires_confirmation_before_clearing(tmp_path): + service = make_service(tmp_path) + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# Rebuild\n", encoding="utf-8") + repository = service.register_repository(name="Rebuild", url=str(source)) + + try: + service.rebuild_characteristics_from_scratch( + repository.id, + dry_run=False, + confirm=False, + source_path=str(source), + use_llm_assistance=False, + ) + except ValueError as exc: + assert "confirm=True" in str(exc) + else: + raise AssertionError("expected confirmed rebuild to require confirm=True") + + +def test_rebuild_characteristics_confirmed_clears_approved_map(tmp_path): + source = tmp_path / "repo" + source.mkdir() + (source / "README.md").write_text("# Rebuild\nReports health over HTTP.\n", encoding="utf-8") + (source / "app.py").write_text('@app.get("/health")\ndef health():\n return {}\n', encoding="utf-8") + service = make_service(tmp_path) + repository = service.register_repository(name="Rebuild", url=str(source)) + summary = service.analyze_repository(repository.id, use_llm_assistance=False) + service.approve_candidate_graph(repository.id, summary.analysis_run.id) + + result = service.rebuild_characteristics_from_scratch( + repository.id, + dry_run=False, + confirm=True, + source_path=str(source), + use_llm_assistance=False, + ) + + assert result.cleared_approved is True + assert result.previous_counts["abilities"] == 1 + assert result.previous_ids["abilities"] + assert service.ability_map(repository.id).abilities == [] + assert service.get_repository(repository.id).status == "analyzed" + decisions = service.list_review_decisions(repository.id, result.analysis_run.id) + assert decisions[-1].action == "rebuild_characteristics_from_scratch" + assert "Previous approved IDs" in decisions[-1].notes def test_analyze_repository_records_llm_failure_and_falls_back(tmp_path): diff --git a/tests/test_web_api.py b/tests/test_web_api.py index f439cf7..5ebf49f 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -182,6 +182,12 @@ def test_openapi_contract_snapshot_for_stable_agent_paths(): "/repos/{repository_id}/analysis-runs/{base_analysis_run_id}/diff/{target_analysis_run_id}": { "get": {"tags": ["review"], "success_schema": "AnalysisRunDiffResponse"} }, + "/repos/{repository_id}/characteristics/rebuild": { + "post": { + "tags": ["analysis"], + "success_schema": "CharacteristicRebuildResponse", + } + }, "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-abilities/{candidate_ability_id}": { "patch": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} }, @@ -1767,7 +1773,118 @@ def test_ui_register_and_explore_lands_on_analysis_result(tmp_path): repository_detail = client.get("/ui/repos/1") assert repository_detail.status_code == 200 - assert "Use Approved Registry" in repository_detail.text + assert "Latest Candidate Graph" in repository_detail.text + assert "Rebuild Characteristics" in repository_detail.text + assert "Use Approved Registry" not in repository_detail.text + finally: + app.dependency_overrides.clear() + + +def test_rebuild_characteristics_endpoint_dry_run_and_confirm(tmp_path): + source = tmp_path / "rebuild-api" + source.mkdir() + (source / "README.md").write_text("# Rebuild API\nReports status.\n", encoding="utf-8") + (source / "app.py").write_text('@app.get("/status")\ndef status():\n return {}\n', encoding="utf-8") + + def override_settings(): + return Settings( + database_path=str(tmp_path / "rebuild-api.sqlite3"), + checkout_root=str(tmp_path / "rebuild-api-checkouts"), + ) + + app.dependency_overrides[get_settings] = override_settings + client = TestClient(app) + try: + repository_id = client.post( + "/repos", + json={"name": "Rebuild API", "url": str(source)}, + ).json()["id"] + run_id = client.post( + f"/repos/{repository_id}/analysis-runs", + json={"source_path": str(source), "use_llm_assistance": False}, + ).json()["analysis_run"]["id"] + approve_response = client.post( + f"/repos/{repository_id}/analysis-runs/{run_id}/candidate-graph/approve", + json={"notes": "approve before rebuild"}, + ) + assert approve_response.status_code == 200 + + dry_run_response = client.post( + f"/repos/{repository_id}/characteristics/rebuild", + json={ + "dry_run": True, + "source_path": str(source), + "use_llm_assistance": False, + }, + ) + assert dry_run_response.status_code == 200 + dry_run = dry_run_response.json() + assert dry_run["cleared_approved"] is False + assert dry_run["previous_counts"]["abilities"] == 1 + assert dry_run["previous_ids"]["abilities"] + + rejected_response = client.post( + f"/repos/{repository_id}/characteristics/rebuild", + json={ + "dry_run": False, + "confirm": False, + "source_path": str(source), + "use_llm_assistance": False, + }, + ) + assert rejected_response.status_code == 400 + + confirmed_response = client.post( + f"/repos/{repository_id}/characteristics/rebuild", + json={ + "dry_run": False, + "confirm": True, + "source_path": str(source), + "use_llm_assistance": False, + }, + ) + assert confirmed_response.status_code == 200 + confirmed = confirmed_response.json() + assert confirmed["cleared_approved"] is True + assert client.get(f"/repos/{repository_id}/ability-map").json()["abilities"] == [] + finally: + app.dependency_overrides.clear() + + +def test_ui_rebuild_characteristics_form_runs_dry_run(tmp_path): + source = tmp_path / "ui-rebuild" + source.mkdir() + (source / "README.md").write_text("# UI Rebuild\nReports status.\n", encoding="utf-8") + + def override_settings(): + return Settings( + database_path=str(tmp_path / "ui-rebuild.sqlite3"), + checkout_root=str(tmp_path / "ui-rebuild-checkouts"), + ) + + app.dependency_overrides[get_settings] = override_settings + client = TestClient(app) + try: + repository_id = client.post( + "/repos", + json={"name": "UI Rebuild", "url": str(source)}, + ).json()["id"] + + response = client.post( + f"/ui/repos/{repository_id}/characteristics/rebuild", + data={ + "source_path": str(source), + "dry_run": "1", + "use_llm_assistance": "", + "use_cached_checkout": "", + }, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert f"/ui/repos/{repository_id}/analysis-runs/" in response.headers["location"] + detail = client.get(response.headers["location"]) + assert "dry_run_rebuild_characteristics_from_scratch" in detail.text finally: app.dependency_overrides.clear() diff --git a/workplans/RREG-WP-0009-provenance-aware-characteristic-rebuild.md b/workplans/RREG-WP-0009-provenance-aware-characteristic-rebuild.md index 6b8ef6d..198aab0 100644 --- a/workplans/RREG-WP-0009-provenance-aware-characteristic-rebuild.md +++ b/workplans/RREG-WP-0009-provenance-aware-characteristic-rebuild.md @@ -148,7 +148,7 @@ Acceptance criteria: ```task id: RREG-WP-0009-T05 -status: todo +status: in_progress priority: medium state_hub_task_id: "d10d4bd7-4e5e-4efc-a724-b072fc53b8d2" ``` @@ -168,7 +168,7 @@ Acceptance criteria: ```task id: RREG-WP-0009-T06 -status: todo +status: done priority: high state_hub_task_id: "490b7926-9d03-4663-9d4d-9de9bbb9c755" ```