Spaces:
Running
Running
File size: 20,099 Bytes
24480a0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 | """
backend/api/capability_resolver.py β Capability Resolver (ARCH-E3.2)
Mappa le capability richieste dal Brain ai Worker disponibili nel Capability
Catalog (ARCH-E3.1), scegliendo il provider ottimale in base a SLA, GPU,
regione e salute operativa.
Posizione nel flusso:
Brain β Kernel.submit_task(capability) β Resolver.resolve() β provider_hint
β ExecutionFabric.dispatch(provider_hint) β Worker
Differenza con ExecutionFabric._select():
- Resolver: decisione DICHIARATIVA dal Catalog (metadata statici, SLA contratto)
- Fabric._select(): decisione OPERATIVA (health live, circuit breaker, concurrency)
Il resolver fornisce l'hint; il Fabric puΓ² ignorarlo se il provider Γ¨ down.
FunzionalitΓ :
resolve(ResolveRequest) β ResolveResult (migliore provider + alternative)
resolve_many([ResolveReq]) β list[ResolveResult] (bulk per Workflow Engine)
can_resolve(capability) β bool (quick check senza scoring)
HTTP Endpoints (auth: MACHINE):
POST /api/resolver/resolve β risolve una singola capability
POST /api/resolver/resolve-many β risolve N capability in bulk (workflow planning)
GET /api/resolver/status β diagnostica: capabilities risolvibili, contatori
Invarianti ADR:
S4: Brain non conosce l'infrastruttura
S9: ogni servizio ignora l'impl interna degli altri
S19: nessun vendor lock-in β chiunque nel Catalog Γ¨ eleggibile
S20: routing intent-based, non hardcoded
S27: ogni risoluzione tracciata via resolve_id
"""
from __future__ import annotations
import logging
import time
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from .auth_guard import AuthRole, require_role
_logger = logging.getLogger("api.capability_resolver")
# ββ Catalog import (guard) βββββββββββββββββββββββββββββββββββββββββββββββββββββ
try:
from .capability_catalog import catalog as _catalog, CapabilityDescriptor as _CapDesc
_CATALOG_AVAILABLE = True
except Exception:
_catalog = None # type: ignore[assignment]
_CapDesc = None # type: ignore[assignment]
_CATALOG_AVAILABLE = False
# ββ Fabric state import (guard) β per leggere health live senza accoppiamento ββ
try:
from .execution_fabric import fabric as _fabric
_FABRIC_AVAILABLE = True
except Exception:
_fabric = None # type: ignore[assignment]
_FABRIC_AVAILABLE = False
# ββ Scoring weights ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# ARCH-RESOLVER-FB: aggiunto _W_FEEDBACK β pesi bilanciati a 1.00
_W_SLA = 0.35 # peso SLA target (latenza dichiarata)
_W_COST = 0.15 # peso costo (free > paid)
_W_ALWAYS_ON = 0.20 # peso always-on vs on-demand
_W_REGION = 0.15 # peso preferenza regione
_W_FEEDBACK = 0.15 # peso feedback storico real-world (success rate + latency delta)
# ββ Feedback Tracker β EWA in-memory, zero I/O ββββββββββββββββββββββββββββββββ
class _FeedbackRecord:
"""Record EWA (Exponentially Weighted Average) per provider."""
__slots__ = ("success_ewa", "latency_ratio_ewa", "calls", "_alpha")
def __init__(self, alpha: float = 0.2) -> None:
self.success_ewa = 1.0 # parte ottimista (assume ok fino a prova contraria)
self.latency_ratio_ewa = 1.0 # actual_ms / declared_sla_ms (1.0 = rispetta SLA)
self.calls = 0
self._alpha = alpha
def record(self, success: bool, actual_ms: float | None, declared_sla_ms: float) -> None:
a = self._alpha
self.success_ewa = (1 - a) * self.success_ewa + a * (1.0 if success else 0.0)
if actual_ms is not None and declared_sla_ms > 0:
ratio = actual_ms / declared_sla_ms
self.latency_ratio_ewa = (1 - a) * self.latency_ratio_ewa + a * ratio
self.calls += 1
def score(self) -> float:
"""Score [0,1]: 1.0 = perfetto (success rate 100%, rispetta SLA), 0 = pessimo."""
# success rate: 1.0 β bonus, 0.0 β forte penalitΓ
s_score = self.success_ewa
# latency ratio: ratio β€ 1 (batte SLA) β bonus, ratio > 2 β forte penalitΓ
l_score = min(1.0, 1.0 / max(self.latency_ratio_ewa, 0.5))
return 0.7 * s_score + 0.3 * l_score
class FeedbackTracker:
"""Registry in-memory di feedback per provider_id. Thread-safe tramite GIL."""
def __init__(self) -> None:
self._records: dict[str, _FeedbackRecord] = {}
def record(self, provider_id: str, success: bool,
actual_ms: float | None = None, declared_sla_ms: float = 1000.0) -> None:
if provider_id not in self._records:
self._records[provider_id] = _FeedbackRecord()
self._records[provider_id].record(success, actual_ms, declared_sla_ms)
def score(self, provider_id: str) -> float:
"""Restituisce feedback score [0,1]. Default ottimistico 0.85 se nessun dato."""
rec = self._records.get(provider_id)
return rec.score() if rec else 0.85
def stats(self) -> dict:
return {
pid: {"calls": r.calls, "success_ewa": round(r.success_ewa, 3),
"latency_ratio_ewa": round(r.latency_ratio_ewa, 3), "score": round(r.score(), 3)}
for pid, r in self._records.items()
}
# ββ Models βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class ResolveRequest(BaseModel):
"""Richiesta di risoluzione capability da parte del Brain (via Kernel)."""
capability: str = Field(..., description="Nome capability richiesta, es. 'browser'")
require_gpu: bool = Field(False)
max_sla_ms: float | None = Field(None, description="SLA massimo accettato in ms")
prefer_region: str | None = Field(None, description="Regione preferita, es. 'eu'")
tags: list[str] = Field(default_factory=list, description="Tag intent-based extra")
payload_kb: int = Field(0, description="Stima dimensione payload in KB")
exclude_providers: list[str] = Field(default_factory=list, description="Provider da escludere")
correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
class ProviderCandidate(BaseModel):
"""Provider candidato per una capability con score e metadati."""
provider_id: str
provider_name: str
sla_ms: float
version: str
cost_unit: float
region: str
always_on: bool
requires_gpu: bool
score: float = Field(description="Score composito [0,1]")
tags: list[str] = Field(default_factory=list)
class ResolveResult(BaseModel):
"""Risultato della risoluzione β provider ottimale + alternative ordinate."""
resolve_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
capability: str
resolved: bool = False
provider_id: str | None = None
provider_name: str | None = None
sla_ms: float | None = None
version: str = "1.0.0"
score: float = 0.0
alternatives: list[ProviderCandidate] = Field(default_factory=list)
reason: str = ""
resolved_at: float = Field(default_factory=time.time)
catalog_source: bool = True # True = da Catalog, False = fallback fabric
class ResolveManyRequest(BaseModel):
requests: list[ResolveRequest] = Field(..., description="Lista richieste da risolvere in bulk")
# ββ CapabilityResolver singleton βββββββββββββββββββββββββββββββββββββββββββββββ
class CapabilityResolver:
"""
Risolve capability β provider ottimale usando il Capability Catalog.
Algoritmo (puramente dichiarativo, non modifica stato):
1. Query catalog per capability (+ filtri hard: GPU, SLA, payload_kb)
2. Per ogni candidato, calcola score composito:
score = W_SLA * sla_score + W_COST * cost_score
+ W_ALWAYS_ON * aon_score + W_REGION * region_score
3. Ordina candidati per score desc
4. Ritorna best + ordered alternatives
Se il Catalog non Γ¨ disponibile o vuoto, tenta fallback sul Fabric
(usa _fabric._specs per lista provider registrati).
"""
# ββ Resolve (singola) βββββββββββββββββββββββββββββββββββββββββββββββββββββ
def resolve(self, req: ResolveRequest) -> ResolveResult:
"""
Risoluzione sincrona β il Catalog Γ¨ un dict in-memory, nessuna I/O.
Chiamabile sia da codice sync che async.
"""
rid = str(uuid.uuid4())
candidates = self._query_candidates(req)
if not candidates:
# Fallback: prova dal Fabric se il Catalog Γ¨ vuoto
candidates = self._fallback_from_fabric(req)
if not candidates:
_logger.warning("[resolver] no provider for capability=%s", req.capability)
return ResolveResult(
resolve_id=rid, capability=req.capability, resolved=False,
reason=f"Nessun provider disponibile per capability '{req.capability}'",
)
scored = sorted(candidates, key=lambda c: c.score, reverse=True)
best = scored[0]
alts = scored[1:]
_logger.info("[resolver] resolved cap=%s β provider=%s sla=%.0fms score=%.3f alts=%d",
req.capability, best.provider_id, best.sla_ms, best.score, len(alts))
return ResolveResult(
resolve_id = rid,
capability = req.capability,
resolved = True,
provider_id = best.provider_id,
provider_name = best.provider_name,
sla_ms = best.sla_ms,
version = best.version,
score = best.score,
alternatives = alts[:5], # max 5 alternative
reason = "ok",
)
# ββ Resolve Many (bulk, per Workflow Engine) βββββββββββββββββββββββββββββββ
def resolve_many(self, requests: list[ResolveRequest]) -> list[ResolveResult]:
"""Risolve N capability in bulk. Usato dal Workflow Engine (ARCH-I4.2)."""
return [self.resolve(r) for r in requests]
# ββ can_resolve (quick check) βββββββββββββββββββββββββββββββββββββββββββββ
def can_resolve(self, capability: str) -> bool:
"""Ritorna True se esiste almeno un provider vivo per questa capability."""
if _CATALOG_AVAILABLE and _catalog is not None:
return len(_catalog.query(name=capability)) > 0
if _FABRIC_AVAILABLE and _fabric is not None:
return any(
capability in spec.capabilities
for spec in _fabric._specs.values()
if spec.base_url
)
return False
# ββ Internal: query candidates ββββββββββββββββββββββββββββββββββββββββββββ
def _query_candidates(self, req: ResolveRequest) -> list[ProviderCandidate]:
if not _CATALOG_AVAILABLE or _catalog is None:
return []
entries = _catalog.query(
name = req.capability,
requires_gpu = req.require_gpu or None, # None = non filtrare
max_sla_ms = req.max_sla_ms,
region = None, # regione usata solo per scoring, non filtro hard
)
candidates = []
for e in entries:
# Filtri hard addizionali
if req.require_gpu and not e.requires_gpu:
continue
if e.provider_id in req.exclude_providers:
continue
if req.payload_kb and req.payload_kb > e.max_payload_kb:
continue
score = self._score(e, req)
candidates.append(ProviderCandidate(
provider_id = e.provider_id,
provider_name = e.provider_name,
sla_ms = e.sla_ms,
version = e.version,
cost_unit = e.cost_unit,
region = e.region,
always_on = e.always_on,
requires_gpu = e.requires_gpu,
tags = e.tags,
score = score,
))
return candidates
def _fallback_from_fabric(self, req: ResolveRequest) -> list[ProviderCandidate]:
"""
Fallback: legge _specs dal Fabric se il Catalog Γ¨ vuoto o non disponibile.
Usato solo quando il Fabric non ha ancora fatto initialize() + auto-register.
"""
if not _FABRIC_AVAILABLE or _fabric is None:
return []
candidates = []
for pid, spec in _fabric._specs.items():
if req.capability not in spec.capabilities:
continue
if req.require_gpu and not spec.gpu:
continue
if pid in req.exclude_providers:
continue
if not spec.base_url:
continue
sla = 9000.0 # default conservativo
cost = spec.cost_unit
always_on = hasattr(spec, 'always_on') and str(spec.always_on) not in ("on-demand", "no")
score = self._score_raw(sla, cost, always_on, spec.region, req.prefer_region)
candidates.append(ProviderCandidate(
provider_id = spec.provider_id,
provider_name = spec.name,
sla_ms = sla,
version = "1.0.0",
cost_unit = cost,
region = spec.region,
always_on = always_on,
requires_gpu = spec.gpu,
score = score,
))
return candidates
# ββ Scoring βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _score(self, e: "_CapDesc", req: ResolveRequest) -> float: # type: ignore[name-defined]
always_on = e.always_on
feedback_score = self._feedback.score(e.name) # e.name = provider_id nel catalog
return self._score_raw(e.sla_ms, e.cost_unit, always_on, e.region, req.prefer_region, feedback_score)
@staticmethod
def _score_raw(sla_ms: float, cost: float, always_on: bool, region: str,
prefer_region: str | None, feedback_score: float = 0.85) -> float:
# SLA score: SLA bassa β score alto. Riferimento 5000ms.
sla_score = min(1.0, 5000.0 / max(sla_ms, 100.0))
# Cost score: free β 1.0, 1 unit β 0.5
cost_score = 1.0 / (1.0 + cost * 10)
# Always-on score
aon_score = 1.0 if always_on else 0.4
# Region score
region_score = 1.0 if (not prefer_region or region == prefer_region) else 0.7
# Feedback score: EWA di success rate + latency ratio reale (ARCH-RESOLVER-FB)
return (
_W_SLA * sla_score +
_W_COST * cost_score +
_W_ALWAYS_ON * aon_score +
_W_REGION * region_score +
_W_FEEDBACK * feedback_score
)
# ββ Feedback recording ββββββββββββββββββββββββββββββββββββββββββββββββββββ
def record_feedback(self, provider_id: str, success: bool,
actual_ms: float | None = None, declared_sla_ms: float = 1000.0) -> None:
"""
Registra il risultato reale di una chiamata al provider (ARCH-RESOLVER-FB).
Chiamato dall'Executor/Brain dopo ogni tool execution.
"""
self._feedback.record(provider_id, success, actual_ms, declared_sla_ms)
_logger.debug("[resolver] feedback %s β success=%s actual_ms=%s",
provider_id, success, actual_ms)
# ββ Status ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def status(self) -> dict:
resolvable: list[str] = []
if _CATALOG_AVAILABLE and _catalog is not None:
entries = _catalog.all_entries()
resolvable = sorted({e.name for e in entries})
return {
"catalog_available": _CATALOG_AVAILABLE,
"fabric_available": _FABRIC_AVAILABLE,
"resolvable_capabilities": resolvable,
"total_resolvable": len(resolvable),
"weights": {
"sla": _W_SLA,
"cost": _W_COST,
"always_on": _W_ALWAYS_ON,
"region": _W_REGION,
"feedback": _W_FEEDBACK,
},
"feedback_stats": self._feedback.stats(),
}
# ββ Singleton βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
resolver = CapabilityResolver()
# ββ HTTP Router βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
router = APIRouter(
prefix="/api/resolver",
tags=["capability-resolver"],
dependencies=[Depends(require_role(AuthRole.MACHINE))],
)
@router.post("/resolve", summary="Risolve una capability β provider ottimale")
async def route_resolve(req: ResolveRequest) -> ResolveResult:
result = resolver.resolve(req)
if not result.resolved:
raise HTTPException(404, result.reason)
return result
@router.post("/resolve-many", summary="Risolve N capability in bulk (workflow planning)")
async def route_resolve_many(req: ResolveManyRequest) -> dict:
if not req.requests:
raise HTTPException(400, "requests lista vuota")
results = resolver.resolve_many(req.requests)
resolved = sum(1 for r in results if r.resolved)
unresolved = len(results) - resolved
return {
"total": len(results),
"resolved": resolved,
"unresolved": unresolved,
"results": [r.model_dump() for r in results],
}
@router.get("/status", summary="Stato resolver β capabilities risolvibili e pesi scoring")
async def route_status() -> dict:
return resolver.status()
class ResolverFeedback(BaseModel):
"""Feedback da inviare dopo l'esecuzione di un tool (ARCH-RESOLVER-FB)."""
provider_id: str
success: bool
actual_ms: float | None = None
declared_sla_ms: float = 1000.0
@router.post("/feedback", summary="Registra feedback reale su un provider (latenza, successo)")
async def route_feedback(body: ResolverFeedback) -> dict:
"""
Chiamato dall'Executor dopo ogni tool execution per aggiornare lo scoring EWA.
Non-critico: un errore qui non deve mai bloccare l'esecuzione dell'agente.
"""
resolver.record_feedback(body.provider_id, body.success, body.actual_ms, body.declared_sla_ms)
return {"recorded": True, "provider_id": body.provider_id}
|