From cc0eef21be8d57de0b42f25af1768ae840663c00 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 25 Apr 2026 23:56:19 +0200 Subject: [PATCH] Milestone 6 API completeness --- README.md | 32 +++++ src/repo_registry/core/models.py | 22 ++++ src/repo_registry/core/service.py | 11 ++ src/repo_registry/storage/sqlite.py | 52 +++++++++ src/repo_registry/web_api/app.py | 174 ++++++++++++++++++++++++++++ tests/test_registry_service.py | 8 ++ tests/test_web_api.py | 16 +++ 7 files changed, 315 insertions(+) diff --git a/README.md b/README.md index df4ca72..87c2b0d 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Inspect recorded facts: ```bash curl http://127.0.0.1:8000/repos/1/analysis-runs +curl http://127.0.0.1:8000/repos/1/analysis-runs/1 curl http://127.0.0.1:8000/repos/1/observed-facts ``` @@ -91,3 +92,34 @@ curl -X POST http://127.0.0.1:8000/repos/1/analysis-runs/1/candidate-graph/appro ``` Approval copies candidate abilities, capabilities, features, and evidence into the approved registry tables, marks candidates approved, and moves the repository status to `indexed`. + +## Review Workflow + +Candidate graphs are meant to be corrected before publication. The API supports: + +- edit candidate abilities and capabilities with `PATCH` +- reject candidate abilities, capabilities, features, and evidence +- relink capabilities under another ability +- relink features or evidence under another capability +- merge duplicate abilities, capabilities, features, or evidence + +Examples are available in the generated OpenAPI docs at `/docs`. + +## Agent-Facing Endpoints + +The v0.1 API covers the main registration, analysis, review, search, and inspection loop: + +```text +GET /repos +POST /repos +GET /repos/{id} +POST /repos/{id}/analysis-runs +GET /repos/{id}/analysis-runs +GET /repos/{id}/analysis-runs/{run_id} +GET /repos/{id}/analysis-runs/{run_id}/candidate-graph +POST /repos/{id}/analysis-runs/{run_id}/candidate-graph/approve +GET /repos/{id}/ability-map +GET /abilities +GET /capabilities +GET /search?q=... +``` diff --git a/src/repo_registry/core/models.py b/src/repo_registry/core/models.py index 41976ff..a182c5f 100644 --- a/src/repo_registry/core/models.py +++ b/src/repo_registry/core/models.py @@ -168,3 +168,25 @@ class SearchResult: match_type: str match_name: str confidence: float + + +@dataclass(frozen=True) +class AbilitySummary: + id: int + repository_id: int + repository_name: str + name: str + description: str + confidence: float + + +@dataclass(frozen=True) +class CapabilitySummary: + id: int + repository_id: int + repository_name: str + ability_id: int + ability_name: str + name: str + description: str + confidence: float diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index 4be446d..1566c45 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -3,7 +3,9 @@ from __future__ import annotations from collections.abc import Sequence from repo_registry.core.models import ( + AbilitySummary, AnalysisRun, + CapabilitySummary, CandidateGraph, ObservedFact, Repository, @@ -101,6 +103,15 @@ class RegistryService: def list_analysis_runs(self, repository_id: int) -> list[AnalysisRun]: return self.store.list_analysis_runs(repository_id) + def get_analysis_run(self, repository_id: int, analysis_run_id: int) -> AnalysisRun: + return self.store.get_analysis_run(repository_id, analysis_run_id) + + def list_abilities(self) -> list[AbilitySummary]: + return self.store.list_abilities() + + def list_capabilities(self) -> list[CapabilitySummary]: + return self.store.list_capabilities() + def list_observed_facts( self, repository_id: int, diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index e208da3..0b93707 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -6,6 +6,7 @@ from pathlib import Path from repo_registry.core.models import ( Ability, + AbilitySummary, AnalysisRun, CandidateAbility, CandidateCapability, @@ -13,6 +14,7 @@ from repo_registry.core.models import ( CandidateFeature, CandidateGraph, Capability, + CapabilitySummary, Evidence, Feature, ObservedFact, @@ -975,6 +977,56 @@ class RegistryStore: ).fetchall() return [self._analysis_run_from_row(row) for row in rows] + def list_abilities(self) -> list[AbilitySummary]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT a.id, a.repository_id, r.name AS repository_name, + a.name, a.description, a.confidence + FROM approved_abilities a + JOIN repositories r ON r.id = a.repository_id + ORDER BY r.name ASC, a.name ASC, a.id ASC + """ + ).fetchall() + return [ + AbilitySummary( + id=row["id"], + repository_id=row["repository_id"], + repository_name=row["repository_name"], + name=row["name"], + description=row["description"], + confidence=row["confidence"], + ) + for row in rows + ] + + def list_capabilities(self) -> list[CapabilitySummary]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT c.id, c.repository_id, r.name AS repository_name, + c.ability_id, a.name AS ability_name, + c.name, c.description, c.confidence + FROM approved_capabilities c + JOIN approved_abilities a ON a.id = c.ability_id + JOIN repositories r ON r.id = c.repository_id + ORDER BY r.name ASC, a.name ASC, c.name ASC, c.id ASC + """ + ).fetchall() + return [ + CapabilitySummary( + id=row["id"], + repository_id=row["repository_id"], + repository_name=row["repository_name"], + ability_id=row["ability_id"], + ability_name=row["ability_name"], + name=row["name"], + description=row["description"], + confidence=row["confidence"], + ) + for row in rows + ] + def get_snapshot(self, snapshot_id: int) -> RepositorySnapshot: with self.connect() as connection: row = connection.execute( diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index 2981ea1..f65f1d8 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -34,12 +34,37 @@ class RepositoryCreate(BaseModel): description: str | None = None branch: str = "main" + model_config = { + "json_schema_extra": { + "examples": [ + { + "url": "https://github.com/example/repository.git", + "name": "Example Repository", + "description": "Optional human-readable repository summary.", + "branch": "main", + } + ] + } + } + class AbilityCreate(BaseModel): name: str description: str = "" confidence: float = Field(default=1.0, ge=0.0, le=1.0) + model_config = { + "json_schema_extra": { + "examples": [ + { + "name": "Business Email Routing", + "description": "Route inbound messages to the right team.", + "confidence": 0.92, + } + ] + } + } + class CapabilityCreate(BaseModel): ability_id: int @@ -49,6 +74,21 @@ class CapabilityCreate(BaseModel): outputs: list[str] = Field(default_factory=list) confidence: float = Field(default=1.0, ge=0.0, le=1.0) + model_config = { + "json_schema_extra": { + "examples": [ + { + "ability_id": 1, + "name": "Classify Incoming Email", + "description": "Classify messages by intent.", + "inputs": ["subject", "body"], + "outputs": ["intent", "confidence"], + "confidence": 0.88, + } + ] + } + } + class FeatureCreate(BaseModel): capability_id: int @@ -57,6 +97,20 @@ class FeatureCreate(BaseModel): location: str = "" confidence: float = Field(default=1.0, ge=0.0, le=1.0) + model_config = { + "json_schema_extra": { + "examples": [ + { + "capability_id": 1, + "name": "POST /api/classify-email", + "type": "REST endpoint", + "location": "src/routes/classify_email.py", + "confidence": 0.84, + } + ] + } + } + class EvidenceCreate(BaseModel): capability_id: int @@ -64,18 +118,52 @@ class EvidenceCreate(BaseModel): reference: str strength: str = "medium" + model_config = { + "json_schema_extra": { + "examples": [ + { + "capability_id": 1, + "type": "unit_test", + "reference": "tests/test_email_classification.py", + "strength": "strong", + } + ] + } + } + class AnalysisRunCreate(BaseModel): source_path: str | None = None + model_config = { + "json_schema_extra": { + "examples": [ + {}, + {"source_path": "/path/to/local/repository"}, + ] + } + } + class CandidateGraphApproval(BaseModel): notes: str = "" + model_config = { + "json_schema_extra": { + "examples": [{"notes": "Approved after curator review."}] + } + } + class CandidateRejection(BaseModel): notes: str = "" + model_config = { + "json_schema_extra": { + "examples": [{"notes": "Rejected because the claim is too generic."}] + } + } + class CandidateEdit(BaseModel): name: str @@ -83,36 +171,96 @@ class CandidateEdit(BaseModel): confidence: float = Field(default=0.5, ge=0.0, le=1.0) notes: str = "" + model_config = { + "json_schema_extra": { + "examples": [ + { + "name": "Service Health Monitoring", + "description": "Expose health state for operational checks.", + "confidence": 0.9, + "notes": "Renamed from generated review seed.", + } + ] + } + } + class CandidateCapabilityRelink(BaseModel): target_ability_id: int notes: str = "" + model_config = { + "json_schema_extra": { + "examples": [ + {"target_ability_id": 2, "notes": "Move under operational ability."} + ] + } + } + class CandidateLeafRelink(BaseModel): target_capability_id: int notes: str = "" + model_config = { + "json_schema_extra": { + "examples": [ + { + "target_capability_id": 3, + "notes": "Evidence supports a different capability.", + } + ] + } + } + class CandidateAbilityMerge(BaseModel): target_ability_id: int notes: str = "" + model_config = { + "json_schema_extra": { + "examples": [ + {"target_ability_id": 2, "notes": "Duplicate ability wording."} + ] + } + } + class CandidateCapabilityMerge(BaseModel): target_capability_id: int notes: str = "" + model_config = { + "json_schema_extra": { + "examples": [ + {"target_capability_id": 3, "notes": "Duplicate capability."} + ] + } + } + class CandidateFeatureMerge(BaseModel): target_feature_id: int notes: str = "" + model_config = { + "json_schema_extra": { + "examples": [{"target_feature_id": 4, "notes": "Duplicate route."}] + } + } + class CandidateEvidenceMerge(BaseModel): target_evidence_id: int notes: str = "" + model_config = { + "json_schema_extra": { + "examples": [{"target_evidence_id": 5, "notes": "Duplicate evidence."}] + } + } + app = FastAPI(title="Repository Ability Registry", version="0.1.0") @@ -184,6 +332,18 @@ def list_analysis_runs( raise HTTPException(status_code=404, detail=str(exc)) from exc +@app.get("/repos/{repository_id}/analysis-runs/{analysis_run_id}") +def get_analysis_run( + repository_id: int, + analysis_run_id: int, + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return asdict(service.get_analysis_run(repository_id, analysis_run_id)) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.get("/repos/{repository_id}/observed-facts") def list_observed_facts( repository_id: int, @@ -611,3 +771,17 @@ def search( service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: return [asdict(result) for result in service.search(q)] + + +@app.get("/abilities") +def list_abilities( + service: RegistryService = Depends(get_service), +) -> list[dict[str, object]]: + return [asdict(ability) for ability in service.list_abilities()] + + +@app.get("/capabilities") +def list_capabilities( + service: RegistryService = Depends(get_service), +) -> list[dict[str, object]]: + return [asdict(capability) for capability in service.list_capabilities()] diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index b12da3a..c0870e3 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -87,6 +87,14 @@ def test_search_matches_approved_abilities_and_capabilities(tmp_path): assert results[0].match_type == "capability" assert results[0].match_name == "Classify Incoming Email" + abilities = service.list_abilities() + capabilities = service.list_capabilities() + + assert abilities[0].repository_name == "MailRouter" + assert abilities[0].name == "Business Email Routing" + assert capabilities[0].ability_name == "Business Email Routing" + assert capabilities[0].name == "Classify Incoming Email" + def test_register_repository_imports_metadata_when_name_is_omitted(tmp_path): source = tmp_path / "metadata-source" diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 4429385..7be01bb 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -129,6 +129,12 @@ def test_api_analysis_run_loop(tmp_path): assert run["analysis_run"]["status"] == "completed" assert run["snapshot"]["file_count"] == 2 + get_run_response = client.get( + f"/repos/{repository_id}/analysis-runs/{run['analysis_run']['id']}" + ) + assert get_run_response.status_code == 200 + assert get_run_response.json()["id"] == run["analysis_run"]["id"] + candidate_response = client.get( f"/repos/{repository_id}/analysis-runs/" f"{run['analysis_run']['id']}/candidate-graph" @@ -206,6 +212,16 @@ def test_api_analysis_run_loop(tmp_path): assert search_response.status_code == 200 assert search_response.json() + abilities_response = client.get("/abilities") + assert abilities_response.status_code == 200 + assert abilities_response.json()[0]["name"] == "Frontend Delivery" + assert abilities_response.json()[0]["repository_name"] == "Frontend" + + capabilities_response = client.get("/capabilities") + assert capabilities_response.status_code == 200 + assert capabilities_response.json()[0]["name"] == "Describe Frontend Stack" + assert capabilities_response.json()[0]["ability_name"] == "Frontend Delivery" + facts_response = client.get(f"/repos/{repository_id}/observed-facts") assert facts_response.status_code == 200 fact_names = {