Transfered deep scope functionality from the custodian

This commit is contained in:
2026-05-01 00:42:10 +02:00
parent b424dea01b
commit 2d9da98257
10 changed files with 1397 additions and 47 deletions

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
import logging
import json
from dataclasses import asdict
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import urlopen
from fastapi import Depends, FastAPI, HTTPException, Query
from fastapi.responses import PlainTextResponse
@@ -13,6 +16,7 @@ from repo_registry.core.service import RegistryService
from repo_registry.llm_extraction import LLMCandidateExtractor, create_llm_connect_adapter
from repo_registry.repo_ingestion.git import GitIngestionService
from repo_registry.semantic import HashingEmbeddingProvider
from repo_registry.scope import ScopeGenerator, ScopeValidator
from repo_registry.storage.sqlite import NotFoundError, RegistryStore
from repo_registry.web_api.schemas import (
AbilityCreate,
@@ -58,6 +62,12 @@ from repo_registry.web_api.schemas import (
)
def slugify(value: str) -> str:
import re
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="REPO_REGISTRY_")
@@ -67,6 +77,7 @@ class Settings(BaseSettings):
llm_provider: str | None = Field(default=None)
llm_model: str | None = Field(default=None)
embedding_provider: str | None = Field(default=None)
state_hub_base_url: str = Field(default="http://127.0.0.1:8000")
log_level: str = Field(default="INFO")
@@ -111,6 +122,7 @@ OPENAPI_TAGS = [
{"name": "analysis", "description": "Repository scans and extracted review inputs."},
{"name": "review", "description": "Candidate graph approval and correction workflow."},
{"name": "registry", "description": "Approved ability maps and manual registry CRUD."},
{"name": "scope", "description": "SCOPE.md generation, diffing, and writing."},
{"name": "search", "description": "Agent-facing discovery endpoints."},
{"name": "discovery", "description": "Comparison, gap analysis, and export helpers."},
]
@@ -1120,6 +1132,144 @@ def export_repository_registry_entry(
return PlainTextResponse(content, media_type="application/x-yaml")
@app.get(
"/repos/{repo_slug}/scope",
tags=["scope"],
response_class=PlainTextResponse,
responses={
200: {
"content": {"text/markdown": {}},
"description": "Generated SCOPE.md preview from approved characteristics.",
}
},
)
def generate_repository_scope(
repo_slug: str,
service: RegistryService = Depends(get_service),
) -> PlainTextResponse:
try:
ensure_scope_generation_ready(service, repo_slug)
content = ScopeGenerator(service).generate(repo_slug)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return PlainTextResponse(content, media_type="text/markdown")
@app.get(
"/repos/{repo_slug}/scope/diff",
tags=["scope"],
)
def diff_repository_scope(
repo_slug: str,
service: RegistryService = Depends(get_service),
settings: Settings = Depends(get_settings),
) -> dict[str, object]:
try:
repository = ensure_scope_generation_ready(service, repo_slug)
scope_path = scope_file_path(service, repository, repo_slug, settings)
diff = ScopeValidator(ScopeGenerator(service)).diff(repo_slug, scope_path)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {
"sections": [asdict(section) for section in diff.sections],
"needs_update": diff.needs_update,
}
@app.post(
"/repos/{repo_slug}/scope/write",
tags=["scope"],
)
def write_repository_scope(
repo_slug: str,
service: RegistryService = Depends(get_service),
settings: Settings = Depends(get_settings),
) -> dict[str, object]:
try:
repository = ensure_scope_generation_ready(service, repo_slug)
scope_path = scope_file_path(service, repository, repo_slug, settings)
content = ScopeGenerator(service).generate(repo_slug)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
scope_path.write_text(content, encoding="utf-8")
return {"written": True, "path": str(scope_path)}
def ensure_scope_generation_ready(
service: RegistryService,
repo_slug: str,
):
repository = repository_by_slug(service, repo_slug)
ability_map = service.ability_map(repository.id)
if not ability_map.abilities:
raise NotFoundError(
f"repository {repo_slug!r} has no approved characteristics"
)
return repository
def repository_by_slug(service: RegistryService, repo_slug: str):
wanted = slugify(repo_slug)
for repository in service.list_repositories():
candidates = {
slugify(repository.name),
slugify(repository.url.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git")),
}
if wanted in candidates:
return repository
raise NotFoundError(f"repository slug {repo_slug!r} was not found")
def scope_file_path(
service: RegistryService,
repository,
repo_slug: str,
settings: Settings,
) -> Path:
state_hub_path = state_hub_scope_file_path(repo_slug, settings)
if state_hub_path is not None:
return state_hub_path
source_path = Path(repository.url)
if source_path.exists() and source_path.is_dir():
return source_path / "SCOPE.md"
checkout = service.ingestion.cached_checkout(repository.url)
if checkout is not None and checkout.source_path.exists():
return checkout.source_path / "SCOPE.md"
raise ValueError(
"repository has no known local checkout path on this host"
)
def state_hub_scope_file_path(repo_slug: str, settings: Settings) -> Path | None:
base_url = settings.state_hub_base_url.rstrip("/")
if not base_url:
return None
try:
with urlopen(f"{base_url}/repos/{repo_slug}/", timeout=2) as response:
repo = json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
if exc.code == 404:
return None
raise ValueError("state hub repository path lookup failed") from exc
except (URLError, TimeoutError, OSError, json.JSONDecodeError):
return None
local_path = repo.get("local_path")
if not local_path:
raise ValueError(
f"state hub repo {repo_slug!r} has no local path on this host"
)
path = Path(local_path)
if path.exists() and path.is_dir():
return path / "SCOPE.md"
raise ValueError(
f"state hub local path for repo {repo_slug!r} is not available: {path}"
)
@app.get(
"/repository-comparisons",
tags=["discovery"],