first
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
"""asf-ai: ASF AI Gateway — "l'ufficio analisti".
|
||||
|
||||
*Business code NEVER calls a specific LLM provider directly.*
|
||||
It calls typed capability methods on this gateway.
|
||||
|
||||
Provider adapters (Mock, OpenAI-compat, NVIDIA, local, customer-hosted, ...)
|
||||
implement the low-level interface. Routing, priority queues, tenant AI
|
||||
policy, usage metering and cost tracking live here.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from .capabilities import (
|
||||
CapabilityRequest,
|
||||
CapabilityResponse,
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
CodeAnalysisRequest,
|
||||
CodeAnalysisResult,
|
||||
TestGenerationRequest,
|
||||
GeneratedTests,
|
||||
RequirementDecomposeRequest,
|
||||
RequirementDecomposition,
|
||||
TraceContext,
|
||||
)
|
||||
from .gateway import AIGateway, AIGatewayConfig
|
||||
from .providers import (
|
||||
AIProvider,
|
||||
MockProvider,
|
||||
ProviderRegistry,
|
||||
build_default_gateway,
|
||||
)
|
||||
from .usage import UsageRecord
|
||||
|
||||
__all__ = [
|
||||
"CapabilityRequest",
|
||||
"CapabilityResponse",
|
||||
"ChatRequest",
|
||||
"ChatResponse",
|
||||
"CodeAnalysisRequest",
|
||||
"CodeAnalysisResult",
|
||||
"TestGenerationRequest",
|
||||
"GeneratedTests",
|
||||
"RequirementDecomposeRequest",
|
||||
"RequirementDecomposition",
|
||||
"TraceContext",
|
||||
"AIGateway",
|
||||
"AIGatewayConfig",
|
||||
"AIProvider",
|
||||
"MockProvider",
|
||||
"ProviderRegistry",
|
||||
"build_default_gateway",
|
||||
"UsageRecord",
|
||||
]
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Typed capability requests / responses.
|
||||
|
||||
Business services construct these Pydantic models and call methods on the
|
||||
AIGateway. They never know which concrete model or provider was chosen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TraceContext(BaseModel):
|
||||
"""Context propagated through every call for audit / logging."""
|
||||
|
||||
tenant_id: UUID
|
||||
project_id: Optional[UUID] = None
|
||||
user_id: Optional[UUID] = None
|
||||
job_id: Optional[UUID] = None
|
||||
correlation_id: Optional[str] = None
|
||||
|
||||
|
||||
class CapabilityRequest(BaseModel):
|
||||
"""Base marker for every capability request supported by the gateway."""
|
||||
|
||||
trace: TraceContext
|
||||
preferred_model: Optional[str] = Field(
|
||||
None, max_length=128, description="Hint only. Gateway's routing decision is authoritative."
|
||||
)
|
||||
priority: str = "normal"
|
||||
|
||||
|
||||
class CapabilityResponse(BaseModel):
|
||||
"""Every response carries at least trace + usage."""
|
||||
|
||||
provider: str
|
||||
model_used: str
|
||||
input_tokens: Optional[int] = None
|
||||
output_tokens: Optional[int] = None
|
||||
duration_ms: Optional[int] = None
|
||||
estimated_cost: Optional[float] = None
|
||||
execution_id: UUID
|
||||
raw: Optional[dict[str, Any]] = Field(
|
||||
default=None, description="Raw provider output, preserved for debugging."
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Individual capabilities
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str = Field(..., pattern="^(system|user|assistant|tool)$")
|
||||
content: str
|
||||
|
||||
|
||||
class ChatRequest(CapabilityRequest):
|
||||
messages: Sequence[ChatMessage]
|
||||
temperature: float = Field(0.2, ge=0.0, le=2.0)
|
||||
max_tokens: Optional[int] = Field(None, ge=1)
|
||||
context_summary: Optional[str] = Field(
|
||||
None, description="Short plaintext summary of the Context Engine assembly."
|
||||
)
|
||||
|
||||
|
||||
class ChatResponse(CapabilityResponse):
|
||||
reply: str
|
||||
references: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CodeAnalysisRequest(CapabilityRequest):
|
||||
file_path: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
code_snippet: str = Field(..., min_length=1)
|
||||
analysis_types: Sequence[str] = Field(
|
||||
default_factory=lambda: ["bugs", "complexity", "security", "style"],
|
||||
description="Which kinds of analyses to run.",
|
||||
)
|
||||
|
||||
|
||||
class Finding(BaseModel):
|
||||
severity: str = Field(..., pattern="^(info|low|medium|high|critical)$")
|
||||
kind: str
|
||||
message: str
|
||||
line_start: Optional[int] = None
|
||||
line_end: Optional[int] = None
|
||||
suggestion: Optional[str] = None
|
||||
|
||||
|
||||
class CodeAnalysisResult(CapabilityResponse):
|
||||
summary: str
|
||||
findings: list[Finding] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TestGenerationRequest(CapabilityRequest):
|
||||
file_path: Optional[str] = None
|
||||
language: str = "python"
|
||||
test_framework: str = "pytest"
|
||||
code_snippet: str = Field(..., min_length=1)
|
||||
goal: str = "Generate unit tests covering happy path + main failure modes"
|
||||
existing_tests: Optional[str] = None
|
||||
|
||||
|
||||
class GeneratedTests(CapabilityResponse):
|
||||
test_cases: list[str] = Field(default_factory=list)
|
||||
fixtures_needed: list[str] = Field(default_factory=list)
|
||||
coverage_notes: str = ""
|
||||
|
||||
|
||||
class RequirementDecomposeRequest(CapabilityRequest):
|
||||
requirement_text: str = Field(..., min_length=1)
|
||||
context: Optional[str] = None
|
||||
existing_subtasks: Sequence[str] = Field(default_factory=list)
|
||||
max_subtasks: int = Field(20, ge=1, le=100)
|
||||
|
||||
|
||||
class SubTask(BaseModel):
|
||||
title: str
|
||||
description: str
|
||||
estimated_complexity: str = Field(..., pattern="^(xs|s|m|l|xl)$")
|
||||
depends_on: list[int] = Field(
|
||||
default_factory=list,
|
||||
description="Index (0-based) of sibling subtasks this one depends on.",
|
||||
)
|
||||
|
||||
|
||||
class RequirementDecomposition(CapabilityResponse):
|
||||
subtasks: list[SubTask] = Field(default_factory=list)
|
||||
architecture_notes: list[str] = Field(default_factory=list)
|
||||
open_questions: list[str] = Field(default_factory=list)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""asf-ai settings surface (minimal; extensible)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class AISettings(BaseSettings):
|
||||
mongo_uri: str = "mongodb://172.19.40.102:27017,172.19.40.103:27017/asf?replicaSet=rs0"
|
||||
mongo_db: str = "asf"
|
||||
|
||||
default_provider: str = "mock"
|
||||
|
||||
# Provider-specific (optional — only required if the chosen provider is used)
|
||||
openai_api_key: str | None = None
|
||||
anthropic_api_key: str | None = None
|
||||
nvidia_api_key: str | None = None
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> AISettings:
|
||||
return AISettings()
|
||||
@@ -0,0 +1,185 @@
|
||||
"""AIGateway — the high-level capability surface called by business code.
|
||||
|
||||
It handles:
|
||||
1. Routing: picks which provider + model to use given the request.
|
||||
2. Policy enforcement: tenant-level AI policy (which providers/models are
|
||||
allowed; budget caps will plug in here cleanly later).
|
||||
3. Usage recording: emits UsageRecords for metering + future billing.
|
||||
4. Error translation (provider-specific → stable ASF exceptions).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from .capabilities import (
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
CodeAnalysisRequest,
|
||||
CodeAnalysisResult,
|
||||
TestGenerationRequest,
|
||||
GeneratedTests,
|
||||
RequirementDecomposeRequest,
|
||||
RequirementDecomposition,
|
||||
)
|
||||
from .providers import (
|
||||
AIProvider,
|
||||
ProviderRegistry,
|
||||
default_registry,
|
||||
)
|
||||
from .usage import UsageRecord
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIGatewayConfig:
|
||||
default_provider: str = "mock"
|
||||
default_model_by_capability: dict[str, str] = field(default_factory=dict)
|
||||
# Hook for future tenant policy layer: given (tenant_id, capability),
|
||||
# return the provider name to route to. None → use default_provider.
|
||||
policy_selector: Optional[Callable[..., Optional[str]]] = None
|
||||
|
||||
|
||||
class AIGateway:
|
||||
"""Entry point for all ASF AI operations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config: Optional[AIGatewayConfig] = None,
|
||||
registry: Optional[ProviderRegistry] = None,
|
||||
) -> None:
|
||||
self._config = config or AIGatewayConfig()
|
||||
self._registry = registry or default_registry()
|
||||
self._usage_callbacks: list[Callable[[UsageRecord], None]] = []
|
||||
|
||||
# --- callback hooks (future usage metering / billing) ---
|
||||
def on_usage(self, cb: Callable[[UsageRecord], None]) -> None:
|
||||
self._usage_callbacks.append(cb)
|
||||
|
||||
# --- routing helper ---
|
||||
def _route(self, req, capability: str) -> AIProvider:
|
||||
tenant_id = str(req.trace.tenant_id) if req.trace else None
|
||||
chosen: Optional[str] = None
|
||||
if self._config.policy_selector and tenant_id:
|
||||
try:
|
||||
chosen = self._config.policy_selector(
|
||||
tenant_id=req.trace.tenant_id,
|
||||
capability=capability,
|
||||
preferred=req.preferred_model,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("ai_policy_selector_failed")
|
||||
|
||||
chosen = chosen or self._config.default_provider
|
||||
return self._registry.get(chosen)
|
||||
|
||||
def _emit_usage(self, record: UsageRecord) -> None:
|
||||
for cb in self._usage_callbacks:
|
||||
try:
|
||||
cb(record)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("ai_usage_callback_failed", provider=record.provider)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Capability methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def chat(self, req: ChatRequest) -> ChatResponse:
|
||||
provider = self._route(req, "chat")
|
||||
log.debug("ai_chat_start", provider=provider.name, tenant=str(req.trace.tenant_id))
|
||||
resp = await provider.chat(req)
|
||||
self._emit_usage(
|
||||
UsageRecord(
|
||||
tenant_id=req.trace.tenant_id,
|
||||
project_id=req.trace.project_id,
|
||||
job_id=req.trace.job_id,
|
||||
execution_id=resp.execution_id,
|
||||
provider=resp.provider,
|
||||
model=resp.model_used,
|
||||
capability="chat",
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
duration_ms=resp.duration_ms,
|
||||
recorded_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
)
|
||||
return resp
|
||||
|
||||
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult:
|
||||
provider = self._route(req, "analyze_code")
|
||||
resp = await provider.analyze_code(req)
|
||||
self._emit_usage(
|
||||
UsageRecord(
|
||||
tenant_id=req.trace.tenant_id,
|
||||
project_id=req.trace.project_id,
|
||||
job_id=req.trace.job_id,
|
||||
execution_id=resp.execution_id,
|
||||
provider=resp.provider,
|
||||
model=resp.model_used,
|
||||
capability="analyze_code",
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
duration_ms=resp.duration_ms,
|
||||
recorded_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
)
|
||||
return resp
|
||||
|
||||
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests:
|
||||
provider = self._route(req, "generate_tests")
|
||||
resp = await provider.generate_tests(req)
|
||||
self._emit_usage(
|
||||
UsageRecord(
|
||||
tenant_id=req.trace.tenant_id,
|
||||
project_id=req.trace.project_id,
|
||||
job_id=req.trace.job_id,
|
||||
execution_id=resp.execution_id,
|
||||
provider=resp.provider,
|
||||
model=resp.model_used,
|
||||
capability="generate_tests",
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
duration_ms=resp.duration_ms,
|
||||
recorded_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
)
|
||||
return resp
|
||||
|
||||
async def decompose_requirement(
|
||||
self, req: RequirementDecomposeRequest
|
||||
) -> RequirementDecomposition:
|
||||
provider = self._route(req, "decompose_requirement")
|
||||
resp = await provider.decompose_requirement(req)
|
||||
self._emit_usage(
|
||||
UsageRecord(
|
||||
tenant_id=req.trace.tenant_id,
|
||||
project_id=req.trace.project_id,
|
||||
job_id=req.trace.job_id,
|
||||
execution_id=resp.execution_id,
|
||||
provider=resp.provider,
|
||||
model=resp.model_used,
|
||||
capability="decompose_requirement",
|
||||
input_tokens=resp.input_tokens,
|
||||
output_tokens=resp.output_tokens,
|
||||
estimated_cost=resp.estimated_cost,
|
||||
duration_ms=resp.duration_ms,
|
||||
recorded_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def build_default_gateway() -> AIGateway:
|
||||
"""Default bootstrap-configured gateway — uses MockProvider, stdout usage logging."""
|
||||
gw = AIGateway()
|
||||
gw.on_usage(lambda record: log.debug("ai_usage_record", **record.model_dump(mode="json")))
|
||||
return gw
|
||||
@@ -0,0 +1,256 @@
|
||||
"""AIProvider interface + mock + registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol, Optional, Type
|
||||
from uuid import uuid4, UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from .capabilities import (
|
||||
CapabilityRequest,
|
||||
CapabilityResponse,
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
CodeAnalysisRequest,
|
||||
CodeAnalysisResult,
|
||||
TestGenerationRequest,
|
||||
GeneratedTests,
|
||||
RequirementDecomposeRequest,
|
||||
RequirementDecomposition,
|
||||
SubTask,
|
||||
Finding,
|
||||
)
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderCaps:
|
||||
supports_chat: bool = True
|
||||
supports_code_analysis: bool = True
|
||||
supports_test_generation: bool = True
|
||||
supports_decompose: bool = True
|
||||
max_input_chars: int = 200_000
|
||||
|
||||
|
||||
class AIProvider(Protocol):
|
||||
"""Low-level provider interface. The Gateway calls exactly one per request.
|
||||
|
||||
Concrete implementations: MockProvider, OpenAICompatProvider, NVIDIADGXProvider, ...
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str: ...
|
||||
@property
|
||||
def default_model(self) -> str: ...
|
||||
@property
|
||||
def capabilities(self) -> ProviderCaps: ...
|
||||
|
||||
async def chat(self, req: ChatRequest) -> ChatResponse: ...
|
||||
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult: ...
|
||||
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests: ...
|
||||
async def decompose_requirement(
|
||||
self, req: RequirementDecomposeRequest
|
||||
) -> RequirementDecomposition: ...
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# MockProvider — deterministic, canned, latency-simulating responses
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class MockProvider:
|
||||
"""Provider that always returns deterministic, predictable responses.
|
||||
|
||||
Uses:
|
||||
* unit tests of code that depends on the AI Gateway
|
||||
* local dev when no API keys are set
|
||||
* UI demo / smoke tests
|
||||
"""
|
||||
|
||||
name = "mock"
|
||||
default_model = "asf-mock-v1"
|
||||
capabilities = ProviderCaps()
|
||||
|
||||
def __init__(self, *, response_latency_ms: int = 40) -> None:
|
||||
self._latency = max(0, response_latency_ms)
|
||||
|
||||
async def _maybe_sleep(self) -> None:
|
||||
if self._latency <= 0:
|
||||
return
|
||||
import asyncio
|
||||
await asyncio.sleep(self._latency / 1000.0)
|
||||
|
||||
async def chat(self, req: ChatRequest) -> ChatResponse:
|
||||
await self._maybe_sleep()
|
||||
# Mirror the last user message back as a demo reply — never misleadingly smart
|
||||
last_user = next(
|
||||
(m.content for m in reversed(req.messages) if m.role == "user"),
|
||||
"(no user message)",
|
||||
)
|
||||
reply = (
|
||||
"[ASF Mock AI] This is a deterministic response from MockProvider. "
|
||||
f"Your last message was: {last_user[:160]!r}. "
|
||||
"To enable real AI, configure a non-mock provider in the ASF AI Gateway."
|
||||
)
|
||||
return ChatResponse(
|
||||
provider=self.name,
|
||||
model_used=self.default_model,
|
||||
execution_id=uuid4(),
|
||||
reply=reply,
|
||||
references=["mock://no-real-data"],
|
||||
input_tokens=len(last_user) // 4,
|
||||
output_tokens=len(reply) // 4,
|
||||
duration_ms=self._latency,
|
||||
estimated_cost=0.0,
|
||||
)
|
||||
|
||||
async def analyze_code(self, req: CodeAnalysisRequest) -> CodeAnalysisResult:
|
||||
await self._maybe_sleep()
|
||||
lines = [ln for ln in req.code_snippet.splitlines() if ln.strip()]
|
||||
findings: list[Finding] = []
|
||||
if "TODO" in req.code_snippet:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity="info",
|
||||
kind="todo",
|
||||
message="Mock analysis found TODO comment — remember to follow up.",
|
||||
)
|
||||
)
|
||||
if "except Exception" in req.code_snippet:
|
||||
findings.append(
|
||||
Finding(
|
||||
severity="medium",
|
||||
kind="broad_exception",
|
||||
message="Broad except clause detected — consider narrowing exception types.",
|
||||
)
|
||||
)
|
||||
return CodeAnalysisResult(
|
||||
provider=self.name,
|
||||
model_used=self.default_model,
|
||||
execution_id=uuid4(),
|
||||
summary=f"Mock: analyzed {len(lines)} non-empty lines; {len(findings)} informational finding(s).",
|
||||
findings=findings,
|
||||
input_tokens=max(10, len(req.code_snippet) // 4),
|
||||
output_tokens=100,
|
||||
duration_ms=self._latency,
|
||||
estimated_cost=0.0,
|
||||
)
|
||||
|
||||
async def generate_tests(self, req: TestGenerationRequest) -> GeneratedTests:
|
||||
await self._maybe_sleep()
|
||||
test_cases = [
|
||||
"# Generated by MockProvider — placeholder skeleton\n"
|
||||
"def test_mock_happy_path():\n"
|
||||
" # TODO: replace with real assertions from a production AI provider\n"
|
||||
" assert True\n",
|
||||
"# Generated by MockProvider — placeholder skeleton\n"
|
||||
"def test_mock_failure_mode():\n"
|
||||
" # TODO: simulate and assert on failure behavior\n"
|
||||
" assert True\n",
|
||||
]
|
||||
return GeneratedTests(
|
||||
provider=self.name,
|
||||
model_used=self.default_model,
|
||||
execution_id=uuid4(),
|
||||
test_cases=test_cases,
|
||||
fixtures_needed=[
|
||||
"NOTE: Mock provider — fixtures listed are placeholders only.",
|
||||
],
|
||||
coverage_notes="Mock response. Real AI provider will target specific branches.",
|
||||
input_tokens=max(10, len(req.code_snippet) // 4),
|
||||
output_tokens=300,
|
||||
duration_ms=self._latency,
|
||||
estimated_cost=0.0,
|
||||
)
|
||||
|
||||
async def decompose_requirement(
|
||||
self, req: RequirementDecomposeRequest
|
||||
) -> RequirementDecomposition:
|
||||
await self._maybe_sleep()
|
||||
limit = max(1, min(5, req.max_subtasks))
|
||||
subtasks = [
|
||||
SubTask(
|
||||
title=f"(Mock) Clarify scope of requirement",
|
||||
description="Mock placeholder — confirm acceptance criteria with stakeholders.",
|
||||
estimated_complexity="s",
|
||||
),
|
||||
SubTask(
|
||||
title=f"(Mock) Draft architecture",
|
||||
description="Mock placeholder — identify modules, data flow, contracts.",
|
||||
estimated_complexity="m",
|
||||
depends_on=[0],
|
||||
),
|
||||
SubTask(
|
||||
title=f"(Mock) Implement feature",
|
||||
description="Mock placeholder — concrete coding work.",
|
||||
estimated_complexity="l",
|
||||
depends_on=[1],
|
||||
),
|
||||
SubTask(
|
||||
title=f"(Mock) Add tests",
|
||||
description="Mock placeholder — unit + integration coverage.",
|
||||
estimated_complexity="m",
|
||||
depends_on=[2],
|
||||
),
|
||||
SubTask(
|
||||
title=f"(Mock) Deploy + monitor",
|
||||
description="Mock placeholder — staging → production, observability.",
|
||||
estimated_complexity="s",
|
||||
depends_on=[3],
|
||||
),
|
||||
][:limit]
|
||||
return RequirementDecomposition(
|
||||
provider=self.name,
|
||||
model_used=self.default_model,
|
||||
execution_id=uuid4(),
|
||||
subtasks=subtasks,
|
||||
architecture_notes=["(Mock) Decomposition is deterministic placeholder data."],
|
||||
open_questions=[
|
||||
"(Mock) Which non-functional requirements apply?",
|
||||
"(Mock) Any existing integration contracts to preserve?",
|
||||
],
|
||||
input_tokens=max(10, len(req.requirement_text) // 4),
|
||||
output_tokens=400,
|
||||
duration_ms=self._latency,
|
||||
estimated_cost=0.0,
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ProviderRegistry — maps provider name → AIProvider instance
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._providers: dict[str, AIProvider] = {}
|
||||
|
||||
def register(self, name: str, provider: AIProvider) -> None:
|
||||
self._providers[name] = provider
|
||||
|
||||
def get(self, name: str) -> AIProvider:
|
||||
if name not in self._providers:
|
||||
raise KeyError(f"Unknown AI provider: {name}. Registered: {list(self._providers)}")
|
||||
return self._providers[name]
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return list(self._providers.keys())
|
||||
|
||||
|
||||
def default_registry() -> ProviderRegistry:
|
||||
r = ProviderRegistry()
|
||||
r.register("mock", MockProvider(response_latency_ms=40))
|
||||
return r
|
||||
|
||||
|
||||
def build_default_gateway():
|
||||
"""Instantiate the canonical production AIGateway (mock by default).
|
||||
|
||||
Imported lazily to avoid a cycle with ``app.gateway``.
|
||||
"""
|
||||
from .gateway import AIGateway, AIGatewayConfig
|
||||
|
||||
return AIGateway(config=AIGatewayConfig(), registry=default_registry())
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Usage record (for billing / metering later)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UsageRecord(BaseModel):
|
||||
tenant_id: UUID
|
||||
project_id: Optional[UUID] = None
|
||||
job_id: Optional[UUID] = None
|
||||
execution_id: UUID
|
||||
provider: str
|
||||
model: str
|
||||
capability: str
|
||||
input_tokens: Optional[int] = None
|
||||
output_tokens: Optional[int] = None
|
||||
gpu_seconds: Optional[float] = None
|
||||
estimated_cost: Optional[float] = None
|
||||
duration_ms: Optional[int] = None
|
||||
recorded_at: datetime
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
@@ -0,0 +1,14 @@
|
||||
asf-domain==0.1.0
|
||||
asf-events==0.1.0
|
||||
asf-connectors==0.1.0
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic>=2.7
|
||||
pydantic-settings>=2.2
|
||||
aio-pika>=9.4
|
||||
structlog>=24.4
|
||||
python-dotenv>=1.0
|
||||
httpx>=0.27
|
||||
|
||||
pytest>=8
|
||||
pytest-asyncio>=0.23
|
||||
@@ -0,0 +1,130 @@
|
||||
"""asf-ai tests — MockProvider + AIGateway routing, no network calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
AI_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(AI_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(AI_ROOT))
|
||||
|
||||
TENANT_ID = UUID("99999999-0000-0000-0000-000000000001")
|
||||
PROJECT_ID = UUID("88888888-0000-0000-0000-000000000002")
|
||||
JOB_ID = UUID("77777777-0000-0000-0000-000000000003")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trace_ctx():
|
||||
from app.capabilities import TraceContext
|
||||
return TraceContext(
|
||||
tenant_id=TENANT_ID, project_id=PROJECT_ID, job_id=JOB_ID, correlation_id="ai-trace"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider():
|
||||
from app.providers import MockProvider
|
||||
return MockProvider(response_latency_ms=0)
|
||||
|
||||
|
||||
class TestMockProviderChat:
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_mirrors_last_user_in_reply(self, mock_provider, trace_ctx):
|
||||
from app.capabilities import ChatRequest, ChatMessage
|
||||
req = ChatRequest(
|
||||
trace=trace_ctx,
|
||||
messages=[
|
||||
ChatMessage(role="system", content="You are helpful."),
|
||||
ChatMessage(role="user", content="what is REQ-103?"),
|
||||
],
|
||||
)
|
||||
resp = await mock_provider.chat(req)
|
||||
assert resp.provider == "mock"
|
||||
assert resp.model_used == "asf-mock-v1"
|
||||
assert resp.estimated_cost == 0.0
|
||||
assert "what is REQ-103?" in resp.reply
|
||||
assert resp.input_tokens >= 0 and resp.output_tokens > 0
|
||||
assert resp.references == ["mock://no-real-data"]
|
||||
|
||||
|
||||
class TestMockProviderCodeAnalysis:
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_code_detects_todo_and_broad_except(self, mock_provider, trace_ctx):
|
||||
from app.capabilities import CodeAnalysisRequest
|
||||
code = (
|
||||
"def foo():\n"
|
||||
" # TODO: refactor this later\n"
|
||||
" try:\n"
|
||||
" risky_call()\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
)
|
||||
req = CodeAnalysisRequest(trace=trace_ctx, code_snippet=code, language="python")
|
||||
resp = await mock_provider.analyze_code(req)
|
||||
kinds = {f.kind for f in resp.findings}
|
||||
assert "todo" in kinds
|
||||
assert "broad_exception" in kinds
|
||||
sevs = {f.severity for f in resp.findings}
|
||||
assert "medium" in sevs # broad_exception is medium
|
||||
assert resp.summary.startswith("Mock: analyzed ")
|
||||
assert resp.input_tokens > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_code_clean_snippet_zero_findings(self, mock_provider, trace_ctx):
|
||||
from app.capabilities import CodeAnalysisRequest
|
||||
req = CodeAnalysisRequest(trace=trace_ctx, code_snippet="x = 1\ny = 2\n", language="python")
|
||||
resp = await mock_provider.analyze_code(req)
|
||||
assert resp.findings == []
|
||||
assert "0 informational finding" in resp.summary
|
||||
|
||||
|
||||
class TestMockProviderTestGeneration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_tests_returns_placeholders(self, mock_provider, trace_ctx):
|
||||
from app.capabilities import TestGenerationRequest
|
||||
req = TestGenerationRequest(
|
||||
trace=trace_ctx,
|
||||
code_snippet="def add(a, b): return a+b\n",
|
||||
language="python",
|
||||
test_framework="pytest",
|
||||
)
|
||||
resp = await mock_provider.generate_tests(req)
|
||||
assert len(resp.test_cases) >= 2
|
||||
assert any("test_mock_happy_path" in t for t in resp.test_cases)
|
||||
assert resp.fixtures_needed
|
||||
assert resp.coverage_notes.startswith("Mock response.")
|
||||
assert resp.output_tokens > 0
|
||||
|
||||
|
||||
class TestMockProviderDecomposeRequirement:
|
||||
@pytest.mark.asyncio
|
||||
async def test_decompose_respects_max_subtasks_cap(self, mock_provider, trace_ctx):
|
||||
from app.capabilities import RequirementDecomposeRequest
|
||||
req = RequirementDecomposeRequest(
|
||||
trace=trace_ctx, requirement_text="Build a billing page.", max_subtasks=3
|
||||
)
|
||||
resp = await mock_provider.decompose_requirement(req)
|
||||
assert len(resp.subtasks) == 3
|
||||
# graph edges sanity: second subtask depends on first
|
||||
assert 0 in (resp.subtasks[1].depends_on or [])
|
||||
assert resp.open_questions
|
||||
assert resp.architecture_notes
|
||||
|
||||
|
||||
class TestProviderRegistry:
|
||||
def test_registry_register_and_get(self):
|
||||
from app.providers import ProviderRegistry, MockProvider
|
||||
reg = ProviderRegistry()
|
||||
reg.register("m", MockProvider(response_latency_ms=0))
|
||||
assert reg.names() == ["m"]
|
||||
assert reg.get("m").name == "mock"
|
||||
|
||||
def test_registry_get_missing_raises_keyerror(self):
|
||||
from app.providers import ProviderRegistry
|
||||
reg = ProviderRegistry()
|
||||
with pytest.raises(KeyError, match="Unknown AI provider"):
|
||||
reg.get("nope")
|
||||
Reference in New Issue
Block a user