feat: expose repository navigation queries
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / pytest-smoke (push) Failing after 2s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
This commit is contained in:
tegwick 2026-08-22 00:40:42 +02:00
parent 283bbf048e
commit e3e542f76c
9 changed files with 446 additions and 3 deletions

View file

@ -27,6 +27,8 @@ CORE_TOOL_NAMES = frozenset({
"register_repo",
"update_repo_path",
"list_domain_repos",
"query_repository_navigation",
"get_repository_navigation_facet",
"check_repo_doi",
"get_doi_summary",
"register_service",
@ -298,6 +300,50 @@ class HubCoreMCPServer:
def list_domain_repos(domain_slug: str) -> str:
return self._json(self._get("/repos/", {"domain": domain_slug}))
@register("query_repository_navigation")
def query_repository_navigation(
primary_domain: list[str] | None = None,
secondary_domain: list[str] | None = None,
category: list[str] | None = None,
capability_tag: list[str] | None = None,
business_stake: list[str] | None = None,
business_mechanic: list[str] | None = None,
cursor: str | None = None,
limit: int = 100,
) -> str:
"""Navigate repositories across any combination of classification facets."""
return self._json(
self._get(
"/ports/projections/repository-navigation/repositories",
{
"primary_domain": primary_domain,
"secondary_domain": secondary_domain,
"category": category,
"capability_tag": capability_tag,
"business_stake": business_stake,
"business_mechanic": business_mechanic,
"cursor": cursor,
"limit": limit,
},
)
)
@register("get_repository_navigation_facet")
def get_repository_navigation_facet(
facet_kind: str,
facet_value: str,
cursor: str | None = None,
limit: int = 100,
) -> str:
"""Navigate one domain, category, tag, stake, or mechanic facet."""
return self._json(
self._get(
"/ports/projections/repository-navigation/"
f"facets/{facet_kind}/{facet_value}",
{"cursor": cursor, "limit": limit},
)
)
@register("check_repo_doi")
def check_repo_doi(repo_slug: str, force_refresh: bool = False) -> str:
return self._json(

View file

@ -14,6 +14,9 @@ from hub_core.runtime.repository_navigation import (
RepoProjectionClient,
RepositoryNavigationService,
)
from hub_core.runtime.repository_navigation_routes import (
create_repository_navigation_router,
)
from hub_core.runtime.store import InMemoryPortStore, PortStore
from hub_core.runtime.validation import ContractValidator
@ -88,6 +91,7 @@ def create_app(
)
app.include_router(create_ports_router())
app.include_router(create_repository_navigation_router())
app.include_router(create_compatibility_router())
return app

View file

@ -1,8 +1,10 @@
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import re
from collections.abc import Mapping
from copy import deepcopy
from dataclasses import dataclass
@ -42,6 +44,10 @@ class ProjectionRejected(ValueError):
"""The upstream transfer cannot safely update the active projection."""
class ProjectionCursorMismatch(ValueError):
"""A cursor does not belong to the active generation and normalized filters."""
@dataclass(frozen=True, slots=True)
class NavigationProjection:
projection_status: Literal["current", "stale"]
@ -134,6 +140,53 @@ class RepositoryNavigationService:
self._last_error = None
return result
async def query(
self,
*,
filters: Mapping[str, list[str] | None],
cursor: str | None = None,
limit: int = 100,
) -> dict[str, Any] | None:
if not 1 <= limit <= 500:
raise ValueError("limit must be between 1 and 500")
normalized_filters = _normalize_filters(filters)
projection = await self.store.get_repository_navigation()
if projection is None:
return None
filter_hash = _filter_hash(normalized_filters)
offset = (
_decode_cursor(
cursor,
content_hash=projection.content_hash,
filter_hash=filter_hash,
)
if cursor
else 0
)
matches = tuple(
repository
for repository in projection.repositories
if _matches(repository, normalized_filters)
)
if offset > len(matches):
raise ProjectionCursorMismatch("cursor offset exceeds the result set")
page = matches[offset : offset + limit]
next_offset = offset + len(page)
result = projection.to_contract()
result["repositories"] = deepcopy(list(page))
result["facets"] = deepcopy(list(_derive_facets(matches)))
result["next_cursor"] = (
_encode_cursor(
offset=next_offset,
content_hash=projection.content_hash,
filter_hash=filter_hash,
)
if next_offset < len(matches)
else None
)
result["total_repository_count"] = len(matches)
return result
async def _fetch_transfer(self) -> list[dict[str, Any]]:
assert self.client is not None
pages: list[dict[str, Any]] = []
@ -349,6 +402,107 @@ def _content_hash(repositories: tuple[dict[str, Any], ...]) -> str:
return hashlib.sha256(encoded).hexdigest()
_FILTER_FIELDS = {
"primary_domain": "primary_domain",
"secondary_domain": "secondary_domains",
"category": "category",
"capability_tag": "capability_tags",
"business_stake": "business_stake",
"business_mechanic": "business_mechanics",
}
def _normalize_filters(
filters: Mapping[str, list[str] | None],
) -> dict[str, tuple[str, ...]]:
unknown = set(filters) - set(_FILTER_FIELDS)
if unknown:
raise ValueError(f"unknown repository navigation filters: {', '.join(sorted(unknown))}")
result: dict[str, tuple[str, ...]] = {}
for name, values in filters.items():
if not values:
continue
normalized = tuple(sorted(set(values)))
if any(not _valid_facet_value(name, value) for value in normalized):
raise ValueError(f"invalid {name} filter value")
result[name] = normalized
return result
def _valid_facet_value(kind: str, value: str) -> bool:
controlled = {
"primary_domain": {
"infotech", "financials", "communication", "consumer", "health",
"industrials", "energy", "utilities", "materials", "realestate",
"crypto", "agents", "space", "government",
},
"secondary_domain": {
"infotech", "financials", "communication", "consumer", "health",
"industrials", "energy", "utilities", "materials", "realestate",
"crypto", "agents", "space", "government",
},
"category": {"experimental", "research", "project", "tooling", "product", "business"},
"business_stake": {
"execution", "intelligence", "finance", "legal", "sales", "experience",
"technology", "operations", "product", "people", "procurement",
"sustainability", "automation",
},
"business_mechanic": {"intention", "control", "coordination", "operation", "adaptation"},
}
if kind == "capability_tag":
return bool(
len(value) <= 120 and re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", value)
)
return value in controlled[kind]
def _matches(
repository: Mapping[str, Any], filters: Mapping[str, tuple[str, ...]]
) -> bool:
for name, accepted in filters.items():
value = repository[_FILTER_FIELDS[name]]
actual = {value} if isinstance(value, str) else set(value)
if not actual.intersection(accepted):
return False
return True
def _filter_hash(filters: Mapping[str, tuple[str, ...]]) -> str:
encoded = json.dumps(filters, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(encoded).hexdigest()
def _encode_cursor(*, offset: int, content_hash: str, filter_hash: str) -> str:
payload = json.dumps(
{"content_hash": content_hash, "filter_hash": filter_hash, "offset": offset},
sort_keys=True,
separators=(",", ":"),
).encode()
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
checksum = hashlib.sha256(b"repository-navigation/1.0.0:" + payload).hexdigest()
return f"{encoded}.{checksum}"
def _decode_cursor(cursor: str, *, content_hash: str, filter_hash: str) -> int:
try:
encoded, checksum = cursor.split(".", 1)
payload = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
expected = hashlib.sha256(b"repository-navigation/1.0.0:" + payload).hexdigest()
if checksum != expected:
raise ValueError("checksum")
value = json.loads(payload)
if value["content_hash"] != content_hash or value["filter_hash"] != filter_hash:
raise ValueError("generation or filters")
offset = int(value["offset"])
if offset < 0:
raise ValueError("offset")
return offset
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise ProjectionCursorMismatch(
"cursor_snapshot_mismatch: restart from the first page"
) from exc
def _parse_time(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))

View file

@ -0,0 +1,96 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from hub_core.runtime.repository_navigation import (
ProjectionCursorMismatch,
RepositoryNavigationService,
)
def get_repository_navigation_service(request: Request) -> RepositoryNavigationService:
return request.app.state.repository_navigation
def create_repository_navigation_router() -> APIRouter:
router = APIRouter(prefix="/ports/projections/repository-navigation")
@router.get(
"/repositories",
response_model=dict[str, Any],
tags=["repository-navigation"],
openapi_extra={"x-port-id": "port.projection.query", "x-direction": "out"},
)
async def query_repositories(
primary_domain: list[str] | None = Query(default=None),
secondary_domain: list[str] | None = Query(default=None),
category: list[str] | None = Query(default=None),
capability_tag: list[str] | None = Query(default=None),
business_stake: list[str] | None = Query(default=None),
business_mechanic: list[str] | None = Query(default=None),
cursor: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
service: RepositoryNavigationService = Depends(
get_repository_navigation_service
),
) -> dict[str, Any]:
return await _query(
service,
filters={
"primary_domain": primary_domain,
"secondary_domain": secondary_domain,
"category": category,
"capability_tag": capability_tag,
"business_stake": business_stake,
"business_mechanic": business_mechanic,
},
cursor=cursor,
limit=limit,
)
@router.get(
"/facets/{facet_kind}/{facet_value}",
response_model=dict[str, Any],
tags=["repository-navigation"],
openapi_extra={"x-port-id": "port.projection.query", "x-direction": "out"},
)
async def query_facet(
facet_kind: str,
facet_value: str,
cursor: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
service: RepositoryNavigationService = Depends(
get_repository_navigation_service
),
) -> dict[str, Any]:
return await _query(
service,
filters={facet_kind: [facet_value]},
cursor=cursor,
limit=limit,
)
return router
async def _query(
service: RepositoryNavigationService,
*,
filters: dict[str, list[str] | None],
cursor: str | None,
limit: int,
) -> dict[str, Any]:
try:
result = await service.query(filters=filters, cursor=cursor, limit=limit)
except ProjectionCursorMismatch as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if result is None:
raise HTTPException(
status_code=503,
detail="no accepted repository navigation projection is available",
)
return result