pagina unica
This commit is contained in:
@@ -14,9 +14,12 @@ aiosqlite>=0.20,<0.21
|
||||
presidio-analyzer>=2.2,<3.0
|
||||
presidio-anonymizer>=2.2,<3.0
|
||||
openai>=1.50,<2.0
|
||||
azure-ai-ml>=1.16,<2.0
|
||||
azure-identity>=1.16,<2.0
|
||||
PyYAML>=6.0,<7.0
|
||||
jsonschema>=4.0,<5.0
|
||||
nemoguardrails>=0.10,<0.12
|
||||
# nemoguardrails se reintroducirá cuando se integre Colang real (docs/futuro.md);
|
||||
# el engine actual es un stub por keywords que no importa el paquete.
|
||||
|
||||
# HTMX UI embebida (Opción A)
|
||||
jinja2>=3.1,<4.0
|
||||
|
||||
@@ -52,10 +52,13 @@ def diff_versions(name: str, v_from: str, v_to: str, registry: RegistryDep) -> D
|
||||
return registry.diff_versions(name, v_from, v_to)
|
||||
|
||||
|
||||
@router.post("/{name}/versions", response_model=AgentVersionMeta, status_code=status.HTTP_201_CREATED)
|
||||
def create_agent_version(name: str, req: CreateAgentVersionRequest, registry: RegistryDep) -> AgentVersionMeta:
|
||||
@router.post(
|
||||
"/{name}/versions", response_model=AgentVersionMeta, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def create_agent_version(
|
||||
name: str, req: CreateAgentVersionRequest, registry: RegistryDep
|
||||
) -> AgentVersionMeta:
|
||||
try:
|
||||
return registry.upsert_version(name, req.agent, req.message, req.author)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Dataset registry API (read-only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from forja_core.api.deps import DatasetStoreDep
|
||||
from forja_core.domain.dataset import DatasetDefinition, DatasetVersionMeta
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[DatasetDefinition])
|
||||
def list_datasets(store: DatasetStoreDep) -> list[DatasetDefinition]:
|
||||
return store.list_datasets()
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=DatasetDefinition)
|
||||
def get_dataset(name: str, store: DatasetStoreDep) -> DatasetDefinition:
|
||||
try:
|
||||
return store.get_dataset(name)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{name}/versions", response_model=list[DatasetVersionMeta])
|
||||
def list_versions(name: str, store: DatasetStoreDep) -> list[DatasetVersionMeta]:
|
||||
try:
|
||||
return store.list_versions(name)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{name}/versions/{version}", response_model=DatasetDefinition)
|
||||
def get_version(name: str, version: str, store: DatasetStoreDep) -> DatasetDefinition:
|
||||
try:
|
||||
return store.get_version(name, version)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
@@ -17,10 +17,19 @@ from forja_core.guardrails.base import GuardrailEngine
|
||||
from forja_core.guardrails.factory import build_guardrail_engine
|
||||
from forja_core.llm.base import LLMProvider
|
||||
from forja_core.llm.factory import build_llm_provider
|
||||
from forja_core.registry.factory import build_agent_registry, build_policy_store
|
||||
from forja_core.registry.dataset_store import FileSystemDatasetStore
|
||||
from forja_core.registry.factory import (
|
||||
build_agent_registry,
|
||||
build_dataset_store,
|
||||
build_model_store,
|
||||
build_policy_store,
|
||||
)
|
||||
from forja_core.registry.model_store import FileSystemModelStore
|
||||
from forja_core.registry.policy_store import FileSystemPolicyStore
|
||||
from forja_core.registry.repository import FileSystemAgentRegistry
|
||||
from forja_core.runtime.orchestrator import AgentOrchestrator
|
||||
from forja_core.training.base import TrainingBackend
|
||||
from forja_core.training.factory import build_training_backend
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -48,6 +57,21 @@ def get_guardrail_engine() -> GuardrailEngine:
|
||||
return build_guardrail_engine(get_settings())
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_dataset_store() -> FileSystemDatasetStore:
|
||||
return build_dataset_store(get_settings())
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_model_store() -> FileSystemModelStore:
|
||||
return build_model_store(get_settings())
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_training_backend() -> TrainingBackend:
|
||||
return build_training_backend(get_settings())
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_orchestrator() -> AgentOrchestrator:
|
||||
settings = get_settings()
|
||||
@@ -63,3 +87,6 @@ SettingsDep = Annotated[Settings, Depends(get_settings)]
|
||||
RegistryDep = Annotated[FileSystemAgentRegistry, Depends(get_registry)]
|
||||
PolicyStoreDep = Annotated[FileSystemPolicyStore, Depends(get_policy_store)]
|
||||
OrchestratorDep = Annotated[AgentOrchestrator, Depends(get_orchestrator)]
|
||||
DatasetStoreDep = Annotated[FileSystemDatasetStore, Depends(get_dataset_store)]
|
||||
ModelStoreDep = Annotated[FileSystemModelStore, Depends(get_model_store)]
|
||||
TrainingBackendDep = Annotated[TrainingBackend, Depends(get_training_backend)]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Persist evaluation reports under data/evaluations/."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from forja_core.domain.training import EvaluationReport
|
||||
|
||||
|
||||
def _eval_dir(data_dir: Path) -> Path:
|
||||
return data_dir / "evaluations"
|
||||
|
||||
|
||||
def save_evaluation(data_dir: Path, report: EvaluationReport) -> None:
|
||||
directory = _eval_dir(data_dir)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{report.id}.json"
|
||||
path.write_text(
|
||||
json.dumps(report.model_dump(mode="json"), indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
index_path = directory / "by_training_run.json"
|
||||
index: dict[str, str] = {}
|
||||
if index_path.exists():
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
index[str(report.training_run_id)] = str(report.id)
|
||||
index_path.write_text(json.dumps(index, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def load_evaluation(data_dir: Path, report_id: UUID) -> EvaluationReport | None:
|
||||
path = _eval_dir(data_dir) / f"{report_id}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
return EvaluationReport.model_validate(json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def load_evaluation_for_run(data_dir: Path, training_run_id: UUID) -> EvaluationReport | None:
|
||||
index_path = _eval_dir(data_dir) / "by_training_run.json"
|
||||
if not index_path.exists():
|
||||
return None
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
report_id = index.get(str(training_run_id))
|
||||
if report_id is None:
|
||||
return None
|
||||
return load_evaluation(data_dir, UUID(report_id))
|
||||
@@ -133,12 +133,8 @@ async def invoke_agent(
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
execution = await orchestrator.invoke(
|
||||
agent_def=agent_def, policy=policy, user_input=body.input
|
||||
)
|
||||
_record_execution(
|
||||
settings.data_dir, str(execution.trace_id), agent_def.name, agent_def.version
|
||||
)
|
||||
execution = await orchestrator.invoke(agent_def=agent_def, policy=policy, user_input=body.input)
|
||||
_record_execution(settings.data_dir, str(execution.trace_id), agent_def.name, agent_def.version)
|
||||
if execution.status in _TERMINAL_STATUSES:
|
||||
append_execution(settings.data_dir, execution)
|
||||
for violation in execution.violations:
|
||||
@@ -146,23 +142,22 @@ async def invoke_agent(
|
||||
return execution
|
||||
|
||||
|
||||
@router.get("", response_model=list[AgentExecutionSummary])
|
||||
async def list_executions(
|
||||
registry: RegistryDep,
|
||||
policies: PolicyStoreDep,
|
||||
orchestrator: OrchestratorDep,
|
||||
settings: SettingsDep,
|
||||
async def collect_execution_summaries(
|
||||
registry: FileSystemAgentRegistry,
|
||||
policies: FileSystemPolicyStore,
|
||||
orchestrator: AgentOrchestrator,
|
||||
data_dir: Path,
|
||||
) -> list[AgentExecutionSummary]:
|
||||
"""Resumen de ejecuciones: las terminales del JSONL más las pausadas en HITL.
|
||||
|
||||
Una ejecución en ``awaiting_approval`` no se escribe en el log append-only
|
||||
(`executions.jsonl`); solo vive en el checkpointer. Para que la página de
|
||||
aprobaciones del dashboard la encuentre, aquí se reconstruyen esas desde el
|
||||
índice `execution_index.json` + el checkpointer.
|
||||
(`executions.jsonl`); solo vive en el checkpointer. Aquí se reconstruyen esas
|
||||
desde el índice `execution_index.json` + el checkpointer. Lo usan tanto la API
|
||||
como la página de Approvals de la UI.
|
||||
"""
|
||||
summaries = read_execution_summaries(settings.data_dir)
|
||||
summaries = read_execution_summaries(data_dir)
|
||||
seen = {str(s.trace_id) for s in summaries}
|
||||
for trace_id, meta in _load_index(settings.data_dir).items():
|
||||
for trace_id, meta in _load_index(data_dir).items():
|
||||
if trace_id in seen:
|
||||
continue
|
||||
try:
|
||||
@@ -190,6 +185,16 @@ async def list_executions(
|
||||
return summaries
|
||||
|
||||
|
||||
@router.get("", response_model=list[AgentExecutionSummary])
|
||||
async def list_executions(
|
||||
registry: RegistryDep,
|
||||
policies: PolicyStoreDep,
|
||||
orchestrator: OrchestratorDep,
|
||||
settings: SettingsDep,
|
||||
) -> list[AgentExecutionSummary]:
|
||||
return await collect_execution_summaries(registry, policies, orchestrator, settings.data_dir)
|
||||
|
||||
|
||||
@router.get("/{trace_id}", response_model=AgentExecution)
|
||||
async def get_execution(
|
||||
trace_id: UUID,
|
||||
|
||||
@@ -14,9 +14,7 @@ from forja_core.observability.logging import bind_trace_id, clear_trace_id
|
||||
class TraceIdMiddleware(BaseHTTPMiddleware):
|
||||
"""Lee o genera `X-Trace-Id`, lo bind-ea al contexto de structlog y lo devuelve en la respuesta."""
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: RequestResponseEndpoint
|
||||
) -> Response:
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
trace_id = request.headers.get("X-Trace-Id") or str(uuid4())
|
||||
bind_trace_id(trace_id)
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Foundation model registry API (read-only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from forja_core.api.deps import ModelStoreDep
|
||||
from forja_core.domain.foundation_model import ModelDefinition, ModelVersionMeta
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ModelDefinition])
|
||||
def list_models(store: ModelStoreDep) -> list[ModelDefinition]:
|
||||
return store.list_models()
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=ModelDefinition)
|
||||
def get_model(name: str, store: ModelStoreDep) -> ModelDefinition:
|
||||
try:
|
||||
return store.get_model(name)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{name}/versions", response_model=list[ModelVersionMeta])
|
||||
def list_versions(name: str, store: ModelStoreDep) -> list[ModelVersionMeta]:
|
||||
try:
|
||||
return store.list_versions(name)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{name}/versions/{version}", response_model=ModelDefinition)
|
||||
def get_version(name: str, version: str, store: ModelStoreDep) -> ModelDefinition:
|
||||
try:
|
||||
return store.get_version(name, version)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
@@ -30,8 +30,12 @@ def list_versions(name: str, store: PolicyStoreDep) -> list[PolicyVersionMeta]:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{name}/versions", response_model=PolicyVersionMeta, status_code=status.HTTP_201_CREATED)
|
||||
def create_policy_version(name: str, req: CreatePolicyVersionRequest, store: PolicyStoreDep) -> PolicyVersionMeta:
|
||||
@router.post(
|
||||
"/{name}/versions", response_model=PolicyVersionMeta, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def create_policy_version(
|
||||
name: str, req: CreatePolicyVersionRequest, store: PolicyStoreDep
|
||||
) -> PolicyVersionMeta:
|
||||
try:
|
||||
return store.upsert_version(name, req.policy, req.message, req.author)
|
||||
except Exception as exc:
|
||||
@@ -42,6 +46,10 @@ def create_policy_version(name: str, req: CreatePolicyVersionRequest, store: Pol
|
||||
def list_validators() -> dict[str, list[str]]:
|
||||
return {
|
||||
"input": ["detect_pii", "prompt_injection", "toxic_language", "forbidden_topics"],
|
||||
"output": ["schema_match", "pii_leakage", "forbidden_action_keywords", "telco_safety_rules"],
|
||||
"output": [
|
||||
"schema_match",
|
||||
"pii_leakage",
|
||||
"forbidden_action_keywords",
|
||||
"telco_safety_rules",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Persist promotion requests under data/promotions/."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from forja_core.domain.training import PromotionRecord, PromotionRequest, PromotionStatus
|
||||
|
||||
|
||||
def _promo_dir(data_dir: Path) -> Path:
|
||||
return data_dir / "promotions"
|
||||
|
||||
|
||||
def save_promotion_request(data_dir: Path, request: PromotionRequest) -> None:
|
||||
directory = _promo_dir(data_dir)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{request.id}.json"
|
||||
path.write_text(
|
||||
json.dumps(request.model_dump(mode="json"), indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def load_promotion_request(data_dir: Path, request_id: UUID) -> PromotionRequest | None:
|
||||
path = _promo_dir(data_dir) / f"{request_id}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
return PromotionRequest.model_validate(json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def list_promotion_requests(
|
||||
data_dir: Path,
|
||||
status: PromotionStatus | None = None,
|
||||
) -> list[PromotionRequest]:
|
||||
directory = _promo_dir(data_dir)
|
||||
if not directory.exists():
|
||||
return []
|
||||
out: list[PromotionRequest] = []
|
||||
for path in sorted(directory.glob("*.json"), reverse=True):
|
||||
req = PromotionRequest.model_validate(json.loads(path.read_text(encoding="utf-8")))
|
||||
if status is None or req.status == status:
|
||||
out.append(req)
|
||||
return out
|
||||
|
||||
|
||||
def append_promotion_record(data_dir: Path, record: PromotionRecord) -> None:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
with (data_dir / "promotion_records.jsonl").open("a", encoding="utf-8") as f:
|
||||
f.write(record.model_dump_json() + "\n")
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Governance promotions: draft agent versions to active after eval gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from forja_core.api.deps import RegistryDep, SettingsDep
|
||||
from forja_core.api.evaluation_persistence import load_evaluation
|
||||
from forja_core.api.promotion_persistence import (
|
||||
append_promotion_record,
|
||||
list_promotion_requests,
|
||||
load_promotion_request,
|
||||
save_promotion_request,
|
||||
)
|
||||
from forja_core.api.training_persistence import load_training_run
|
||||
from forja_core.config import Settings
|
||||
from forja_core.domain.training import (
|
||||
PromotionRecord,
|
||||
PromotionRequest,
|
||||
PromotionStatus,
|
||||
TrainingRun,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreatePromotionRequest(BaseModel):
|
||||
agent_name: str
|
||||
agent_version: str
|
||||
training_run_id: UUID
|
||||
evaluation_report_id: UUID
|
||||
requested_by: str = "api"
|
||||
|
||||
|
||||
class PromotionDecision(BaseModel):
|
||||
approved_by: str = "api"
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class RejectPromotion(BaseModel):
|
||||
rejected_by: str = "api"
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
def _require_succeeded_run(run_id: UUID, settings: Settings) -> TrainingRun:
|
||||
run = load_training_run(settings.data_dir, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="training run not found")
|
||||
if run.status != "succeeded":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"training run status '{run.status}' must be 'succeeded' for this operation",
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
@router.get("", response_model=list[PromotionRequest])
|
||||
def list_promotions(
|
||||
settings: SettingsDep,
|
||||
status: Annotated[PromotionStatus | None, Query()] = None,
|
||||
) -> list[PromotionRequest]:
|
||||
return list_promotion_requests(settings.data_dir, status=status)
|
||||
|
||||
|
||||
@router.get("/{request_id}", response_model=PromotionRequest)
|
||||
def get_promotion(request_id: UUID, settings: SettingsDep) -> PromotionRequest:
|
||||
req = load_promotion_request(settings.data_dir, request_id)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="promotion request not found")
|
||||
return req
|
||||
|
||||
|
||||
@router.post("", response_model=PromotionRequest, status_code=status.HTTP_201_CREATED)
|
||||
def request_promotion(
|
||||
body: CreatePromotionRequest,
|
||||
settings: SettingsDep,
|
||||
registry: RegistryDep,
|
||||
) -> PromotionRequest:
|
||||
_require_succeeded_run(body.training_run_id, settings)
|
||||
|
||||
report = load_evaluation(settings.data_dir, body.evaluation_report_id)
|
||||
if report is None:
|
||||
raise HTTPException(status_code=404, detail="evaluation report not found")
|
||||
if report.training_run_id != body.training_run_id:
|
||||
raise HTTPException(status_code=422, detail="evaluation report does not match training run")
|
||||
if not report.passed:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="evaluation did not pass; promotion blocked by guardrail gate",
|
||||
)
|
||||
|
||||
try:
|
||||
agent = registry.get_version(body.agent_name, body.agent_version)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
if agent.state == "active":
|
||||
raise HTTPException(status_code=409, detail="agent version is already active")
|
||||
if agent.name != report.agent_name:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="evaluation agent does not match promotion target"
|
||||
)
|
||||
if report.agent_version != body.agent_version:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"evaluation covered version '{report.agent_version}' but promotion "
|
||||
f"targets '{body.agent_version}'; evaluate the candidate version first"
|
||||
),
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
req = PromotionRequest(
|
||||
id=uuid4(),
|
||||
agent_name=body.agent_name,
|
||||
agent_version=body.agent_version,
|
||||
training_run_id=body.training_run_id,
|
||||
evaluation_report_id=body.evaluation_report_id,
|
||||
status="pending_approval",
|
||||
requested_by=body.requested_by,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
save_promotion_request(settings.data_dir, req)
|
||||
return req
|
||||
|
||||
|
||||
@router.post("/{request_id}/approve", response_model=PromotionRecord)
|
||||
def approve_promotion(
|
||||
request_id: UUID,
|
||||
body: PromotionDecision,
|
||||
settings: SettingsDep,
|
||||
registry: RegistryDep,
|
||||
) -> PromotionRecord:
|
||||
req = load_promotion_request(settings.data_dir, request_id)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="promotion request not found")
|
||||
if req.status != "pending_approval":
|
||||
raise HTTPException(status_code=409, detail=f"promotion status is '{req.status}'")
|
||||
|
||||
report = load_evaluation(settings.data_dir, req.evaluation_report_id)
|
||||
if report is None or not report.passed:
|
||||
raise HTTPException(status_code=422, detail="evaluation gate no longer satisfied")
|
||||
|
||||
registry.promote_to_active(
|
||||
req.agent_name,
|
||||
req.agent_version,
|
||||
author=body.approved_by,
|
||||
comment=body.comment or "Approved via governance promotion",
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
updated_req = req.model_copy(
|
||||
update={
|
||||
"status": "approved",
|
||||
"decided_by": body.approved_by,
|
||||
"comment": body.comment,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
save_promotion_request(settings.data_dir, updated_req)
|
||||
|
||||
record = PromotionRecord(
|
||||
id=uuid4(),
|
||||
agent_name=req.agent_name,
|
||||
agent_version=req.agent_version,
|
||||
training_run_id=req.training_run_id,
|
||||
evaluation_report_id=req.evaluation_report_id,
|
||||
approved_by=body.approved_by,
|
||||
comment=body.comment,
|
||||
created_at=now,
|
||||
)
|
||||
append_promotion_record(settings.data_dir, record)
|
||||
return record
|
||||
|
||||
|
||||
@router.post("/{request_id}/reject", response_model=PromotionRequest)
|
||||
def reject_promotion(
|
||||
request_id: UUID,
|
||||
body: RejectPromotion,
|
||||
settings: SettingsDep,
|
||||
) -> PromotionRequest:
|
||||
req = load_promotion_request(settings.data_dir, request_id)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail="promotion request not found")
|
||||
if req.status != "pending_approval":
|
||||
raise HTTPException(status_code=409, detail=f"promotion status is '{req.status}'")
|
||||
|
||||
updated = req.model_copy(
|
||||
update={
|
||||
"status": "rejected",
|
||||
"decided_by": body.rejected_by,
|
||||
"comment": body.reason,
|
||||
"updated_at": datetime.now(UTC),
|
||||
},
|
||||
)
|
||||
save_promotion_request(settings.data_dir, updated)
|
||||
return updated
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Training runs API — submit jobs to external MLOps backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from forja_core.api.deps import (
|
||||
DatasetStoreDep,
|
||||
ModelStoreDep,
|
||||
OrchestratorDep,
|
||||
PolicyStoreDep,
|
||||
RegistryDep,
|
||||
SettingsDep,
|
||||
TrainingBackendDep,
|
||||
)
|
||||
from forja_core.api.evaluation_persistence import load_evaluation_for_run, save_evaluation
|
||||
from forja_core.api.training_persistence import (
|
||||
list_training_runs,
|
||||
load_training_run,
|
||||
save_training_run,
|
||||
)
|
||||
from forja_core.domain.training import (
|
||||
EvaluationReport,
|
||||
TrainingHyperparameters,
|
||||
TrainingJobSpec,
|
||||
TrainingRun,
|
||||
)
|
||||
from forja_core.evaluation.post_train import run_post_train_evaluation
|
||||
from forja_core.registry.dataset_store import FileSystemDatasetStore
|
||||
from forja_core.registry.model_store import FileSystemModelStore
|
||||
from forja_core.registry.repository import FileSystemAgentRegistry
|
||||
from forja_core.training.base import TrainingBackend
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateTrainingRunRequest(BaseModel):
|
||||
dataset_name: str
|
||||
dataset_version: str | None = None
|
||||
model_name: str
|
||||
model_version: str | None = None
|
||||
agent_name: str | None = None
|
||||
agent_version: str | None = None
|
||||
hyperparameters: TrainingHyperparameters | None = None
|
||||
|
||||
@field_validator(
|
||||
"dataset_version", "model_version", "agent_name", "agent_version", mode="before"
|
||||
)
|
||||
@classmethod
|
||||
def _empty_string_is_none(cls, value: object) -> object:
|
||||
# HTML form selects submit "" for the unselected option.
|
||||
return value or None
|
||||
|
||||
|
||||
def _resolve_spec(
|
||||
body: CreateTrainingRunRequest,
|
||||
datasets: FileSystemDatasetStore,
|
||||
models: FileSystemModelStore,
|
||||
registry: FileSystemAgentRegistry,
|
||||
) -> TrainingJobSpec:
|
||||
try:
|
||||
dataset = datasets.get_dataset(body.dataset_name, body.dataset_version)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
try:
|
||||
model = models.get_model(body.model_name, body.model_version)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
if body.agent_version and not body.agent_name:
|
||||
raise HTTPException(status_code=422, detail="agent_version requires agent_name")
|
||||
if body.agent_name:
|
||||
try:
|
||||
registry.get_agent(body.agent_name, body.agent_version)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return TrainingJobSpec(
|
||||
dataset_name=dataset.name,
|
||||
dataset_version=dataset.version,
|
||||
model_name=model.name,
|
||||
model_version=model.version,
|
||||
agent_name=body.agent_name,
|
||||
agent_version=body.agent_version,
|
||||
hyperparameters=body.hyperparameters or TrainingHyperparameters(),
|
||||
)
|
||||
|
||||
|
||||
async def _apply_backend_status(
|
||||
backend: TrainingBackend,
|
||||
run: TrainingRun,
|
||||
) -> TrainingRun:
|
||||
if run.external_job_id is None:
|
||||
return run
|
||||
job = await backend.status(run.external_job_id)
|
||||
now = datetime.now(UTC)
|
||||
return run.model_copy(
|
||||
update={
|
||||
"status": job.status,
|
||||
"updated_at": now,
|
||||
"artifact_refs": job.artifacts,
|
||||
"error_message": job.message if job.status == "failed" else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TrainingRun])
|
||||
def list_runs(settings: SettingsDep) -> list[TrainingRun]:
|
||||
return list_training_runs(settings.data_dir)
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=TrainingRun)
|
||||
def get_run(run_id: UUID, settings: SettingsDep) -> TrainingRun:
|
||||
run = load_training_run(settings.data_dir, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="training run not found")
|
||||
return run
|
||||
|
||||
|
||||
@router.post("", response_model=TrainingRun, status_code=status.HTTP_201_CREATED)
|
||||
async def create_run(
|
||||
body: CreateTrainingRunRequest,
|
||||
settings: SettingsDep,
|
||||
datasets: DatasetStoreDep,
|
||||
models: ModelStoreDep,
|
||||
registry: RegistryDep,
|
||||
backend: TrainingBackendDep,
|
||||
) -> TrainingRun:
|
||||
spec = _resolve_spec(body, datasets, models, registry)
|
||||
now = datetime.now(UTC)
|
||||
run_id = uuid4()
|
||||
run = TrainingRun(
|
||||
id=run_id,
|
||||
backend=settings.training_backend,
|
||||
status="submitted",
|
||||
spec=spec,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
external_id = await backend.submit(spec, run_id)
|
||||
run = run.model_copy(
|
||||
update={
|
||||
"external_job_id": external_id,
|
||||
"status": "queued",
|
||||
"updated_at": datetime.now(UTC),
|
||||
},
|
||||
)
|
||||
run = await _apply_backend_status(backend, run)
|
||||
save_training_run(settings.data_dir, run)
|
||||
return run
|
||||
|
||||
|
||||
@router.post("/{run_id}/refresh", response_model=TrainingRun)
|
||||
async def refresh_run(
|
||||
run_id: UUID,
|
||||
settings: SettingsDep,
|
||||
backend: TrainingBackendDep,
|
||||
) -> TrainingRun:
|
||||
run = load_training_run(settings.data_dir, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="training run not found")
|
||||
if run.backend != settings.training_backend:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"run backend '{run.backend}' does not match "
|
||||
f"configured '{settings.training_backend}'"
|
||||
),
|
||||
)
|
||||
run = await _apply_backend_status(backend, run)
|
||||
save_training_run(settings.data_dir, run)
|
||||
return run
|
||||
|
||||
|
||||
def _require_succeeded_run(run_id: UUID, settings: SettingsDep) -> TrainingRun:
|
||||
run = load_training_run(settings.data_dir, run_id)
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="training run not found")
|
||||
if run.status != "succeeded":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"training run status '{run.status}' must be 'succeeded'",
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
@router.post("/{run_id}/evaluate", response_model=EvaluationReport)
|
||||
async def evaluate_run(
|
||||
run_id: UUID,
|
||||
settings: SettingsDep,
|
||||
registry: RegistryDep,
|
||||
policies: PolicyStoreDep,
|
||||
orchestrator: OrchestratorDep,
|
||||
) -> EvaluationReport:
|
||||
run = _require_succeeded_run(run_id, settings)
|
||||
agent_name = run.spec.agent_name
|
||||
if not agent_name:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="training run has no agent_name; set it when creating the run",
|
||||
)
|
||||
|
||||
existing = load_evaluation_for_run(settings.data_dir, run_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
# Evaluate the candidate version tied to the run (falls back to active).
|
||||
try:
|
||||
agent_def = registry.get_agent(agent_name, run.spec.agent_version)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
if not agent_def.guardrails:
|
||||
raise HTTPException(status_code=422, detail="agent has no guardrail policy")
|
||||
try:
|
||||
policy = policies.get_policy(agent_def.guardrails[0])
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
report = await run_post_train_evaluation(
|
||||
training_run_id=run_id,
|
||||
agent_def=agent_def,
|
||||
policy=policy,
|
||||
orchestrator=orchestrator,
|
||||
agents_dir=settings.agents_dir,
|
||||
)
|
||||
save_evaluation(settings.data_dir, report)
|
||||
return report
|
||||
|
||||
|
||||
@router.get("/{run_id}/evaluation", response_model=EvaluationReport)
|
||||
def get_run_evaluation(run_id: UUID, settings: SettingsDep) -> EvaluationReport:
|
||||
report = load_evaluation_for_run(settings.data_dir, run_id)
|
||||
if report is None:
|
||||
raise HTTPException(status_code=404, detail="evaluation not found for this training run")
|
||||
return report
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Persist training runs as one JSON file per run under data/training_runs/."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from forja_core.domain.training import TrainingRun
|
||||
|
||||
|
||||
def _runs_dir(data_dir: Path) -> Path:
|
||||
return data_dir / "training_runs"
|
||||
|
||||
|
||||
def save_training_run(data_dir: Path, run: TrainingRun) -> None:
|
||||
directory = _runs_dir(data_dir)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{run.id}.json"
|
||||
path.write_text(
|
||||
json.dumps(run.model_dump(mode="json"), indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def load_training_run(data_dir: Path, run_id: UUID) -> TrainingRun | None:
|
||||
path = _runs_dir(data_dir) / f"{run_id}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return TrainingRun.model_validate(data)
|
||||
|
||||
|
||||
def list_training_runs(data_dir: Path) -> list[TrainingRun]:
|
||||
directory = _runs_dir(data_dir)
|
||||
if not directory.exists():
|
||||
return []
|
||||
runs: list[TrainingRun] = []
|
||||
for path in sorted(directory.glob("*.json"), reverse=True):
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
runs.append(TrainingRun.model_validate(data))
|
||||
return runs
|
||||
@@ -35,16 +35,26 @@ class Settings(BaseSettings):
|
||||
|
||||
# Guardrails
|
||||
guardrails_nemo_enabled: bool = False
|
||||
# Comma-separated keywords for the NeMo topical-rails stub. Empty disables
|
||||
# the off-topic check (no domain assumptions baked into the core).
|
||||
guardrails_nemo_allowed_keywords: str = ""
|
||||
|
||||
# Observabilidad
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
|
||||
# Training / MLOps orchestration
|
||||
training_backend: Literal["mock", "azure_ml"] = "mock"
|
||||
azure_ml_subscription_id: str = ""
|
||||
azure_ml_resource_group: str = ""
|
||||
azure_ml_workspace_name: str = ""
|
||||
azure_ml_compute: str = ""
|
||||
azure_ml_environment: str = "AzureML-sklearn-1.5"
|
||||
azure_ml_experiment_name: str = "forja-training"
|
||||
azure_ml_command: str = "python train.py"
|
||||
|
||||
# Persistencia
|
||||
data_dir: Path = Field(default=Path("./data"))
|
||||
agents_dir: Path = Field(default=Path("./agents"))
|
||||
policies_dir: Path = Field(default=Path("./policies"))
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Helper para inyección por dependencia en FastAPI."""
|
||||
return Settings()
|
||||
datasets_dir: Path = Field(default=Path("./datasets"))
|
||||
models_dir: Path = Field(default=Path("./models"))
|
||||
|
||||
@@ -7,6 +7,8 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from forja_core.domain.version_meta import VersionMeta
|
||||
|
||||
|
||||
class LLMConfig(BaseModel):
|
||||
"""Configuración del proveedor LLM utilizado por un agente."""
|
||||
@@ -17,14 +19,8 @@ class LLMConfig(BaseModel):
|
||||
max_tokens: int = Field(default=2000, ge=1, le=128_000)
|
||||
|
||||
|
||||
class AgentVersionMeta(BaseModel):
|
||||
"""Metadatos de una versión concreta de un agente (estilo commit Git)."""
|
||||
|
||||
id: str
|
||||
hash: str
|
||||
author: str
|
||||
message: str
|
||||
created_at: datetime
|
||||
# Alias de compatibilidad: los metadatos de versión son comunes a todos los registries.
|
||||
AgentVersionMeta = VersionMeta
|
||||
|
||||
|
||||
class AgentDefinition(BaseModel):
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Dataset definitions for fine-tuning (versioned YAML)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from forja_core.domain.version_meta import VersionMeta
|
||||
|
||||
# Compatibility alias: version metadata is shared across all registries.
|
||||
DatasetVersionMeta = VersionMeta
|
||||
|
||||
|
||||
class DatasetDefinition(BaseModel):
|
||||
"""Declarative dataset used to submit training jobs."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
owner: str
|
||||
purpose: str
|
||||
state: Literal["draft", "active", "deprecated"] = "active"
|
||||
format: Literal["jsonl", "conversation"] = "jsonl"
|
||||
records_path: str = Field(
|
||||
description="Path relative to the dataset directory, e.g. records/train.jsonl",
|
||||
)
|
||||
updated_at: datetime
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Base / fine-tune target model definitions (versioned YAML)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from forja_core.domain.version_meta import VersionMeta
|
||||
|
||||
# Compatibility alias: version metadata is shared across all registries.
|
||||
ModelVersionMeta = VersionMeta
|
||||
|
||||
|
||||
class ModelDefinition(BaseModel):
|
||||
"""Registered base model or training target reference."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
owner: str
|
||||
purpose: str
|
||||
state: Literal["draft", "active", "deprecated"] = "active"
|
||||
base_model_id: str = Field(
|
||||
description="Provider model id, e.g. HuggingFace repo or Azure deployment name",
|
||||
)
|
||||
adaptation: Literal["full", "lora", "qlora"] = "lora"
|
||||
updated_at: datetime
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from forja_core.domain.version_meta import VersionMeta
|
||||
|
||||
|
||||
class PolicyValidator(BaseModel):
|
||||
"""Validador individual configurado en una política."""
|
||||
@@ -15,14 +16,8 @@ class PolicyValidator(BaseModel):
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PolicyVersionMeta(BaseModel):
|
||||
"""Metadatos de una versión concreta de una política (estilo commit Git)."""
|
||||
|
||||
id: str
|
||||
hash: str
|
||||
author: str
|
||||
message: str
|
||||
created_at: datetime
|
||||
# Alias de compatibilidad: los metadatos de versión son comunes a todos los registries.
|
||||
PolicyVersionMeta = VersionMeta
|
||||
|
||||
|
||||
class PolicyDefinition(BaseModel):
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Training jobs, runs, and promotion-related records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
TrainingRunStatus = Literal[
|
||||
"submitted",
|
||||
"queued",
|
||||
"running",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]
|
||||
TrainingBackendKind = Literal["mock", "azure_ml"]
|
||||
|
||||
|
||||
class TrainingHyperparameters(BaseModel):
|
||||
"""Minimal hyperparameters passed to an external trainer."""
|
||||
|
||||
learning_rate: float = Field(default=1e-4, gt=0.0, le=1.0)
|
||||
epochs: int = Field(default=1, ge=1, le=100)
|
||||
batch_size: int = Field(default=4, ge=1, le=512)
|
||||
|
||||
|
||||
class TrainingJobSpec(BaseModel):
|
||||
"""Input specification for a training backend."""
|
||||
|
||||
dataset_name: str
|
||||
dataset_version: str
|
||||
model_name: str
|
||||
model_version: str
|
||||
agent_name: str | None = None
|
||||
# Candidate agent version this run is meant to produce/validate. Post-train
|
||||
# evaluation runs against this version, and promotion requires the
|
||||
# evaluation report to match it.
|
||||
agent_version: str | None = None
|
||||
hyperparameters: TrainingHyperparameters = Field(default_factory=TrainingHyperparameters)
|
||||
|
||||
|
||||
class ArtifactRef(BaseModel):
|
||||
"""Output artifact from a completed training job."""
|
||||
|
||||
uri: str
|
||||
kind: Literal["checkpoint", "adapter", "metrics"] = "adapter"
|
||||
|
||||
|
||||
class BackendJobStatus(BaseModel):
|
||||
"""Status returned by an external MLOps backend."""
|
||||
|
||||
status: TrainingRunStatus
|
||||
message: str | None = None
|
||||
artifacts: list[ArtifactRef] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TrainingRun(BaseModel):
|
||||
"""Persisted training run orchestrated by Forja."""
|
||||
|
||||
id: UUID
|
||||
backend: TrainingBackendKind
|
||||
status: TrainingRunStatus
|
||||
spec: TrainingJobSpec
|
||||
external_job_id: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
artifact_refs: list[ArtifactRef] = Field(default_factory=list)
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
class ScenarioEvalResult(BaseModel):
|
||||
"""Result of running one canonical scenario through the governed runtime."""
|
||||
|
||||
scenario: str
|
||||
status: str
|
||||
blocking_violations: int = 0
|
||||
|
||||
|
||||
class EvaluationReport(BaseModel):
|
||||
"""Post-training evaluation summary (gate before promotion)."""
|
||||
|
||||
id: UUID
|
||||
training_run_id: UUID
|
||||
agent_name: str
|
||||
agent_version: str
|
||||
passed: bool
|
||||
scenario_count: int = 0
|
||||
violation_count: int = 0
|
||||
scenarios: list[ScenarioEvalResult] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
PromotionStatus = Literal["pending_approval", "approved", "rejected"]
|
||||
|
||||
|
||||
class PromotionRequest(BaseModel):
|
||||
"""Governance HITL request to promote a draft agent version to active."""
|
||||
|
||||
id: UUID
|
||||
agent_name: str
|
||||
agent_version: str
|
||||
training_run_id: UUID
|
||||
evaluation_report_id: UUID
|
||||
status: PromotionStatus
|
||||
requested_by: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# Operator who approved or rejected (audit trail for both outcomes).
|
||||
decided_by: str | None = None
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class PromotionRecord(BaseModel):
|
||||
"""Audit record when a model/agent version is promoted to active."""
|
||||
|
||||
id: UUID
|
||||
agent_name: str
|
||||
agent_version: str
|
||||
training_run_id: UUID | None = None
|
||||
evaluation_report_id: UUID | None = None
|
||||
approved_by: str
|
||||
comment: str | None = None
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Metadatos de versión compartidos por todos los registries versionados."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class VersionMeta(BaseModel):
|
||||
"""Metadatos de una versión concreta de un artefacto versionado (estilo commit Git)."""
|
||||
|
||||
id: str
|
||||
hash: str
|
||||
author: str
|
||||
message: str
|
||||
created_at: datetime
|
||||
@@ -0,0 +1 @@
|
||||
"""Post-training evaluation and promotion gates."""
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Run post-training evaluation: governed agent runs over canonical scenarios."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from forja_core.domain.agent import AgentDefinition
|
||||
from forja_core.domain.policy import PolicyDefinition
|
||||
from forja_core.domain.training import EvaluationReport, ScenarioEvalResult
|
||||
from forja_core.evaluation.scenarios import load_agent_scenarios
|
||||
from forja_core.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
def _blocking_violation_count(execution: object) -> int:
|
||||
violations = getattr(execution, "violations", [])
|
||||
return sum(1 for v in violations if v.blocked)
|
||||
|
||||
|
||||
async def run_post_train_evaluation(
|
||||
*,
|
||||
training_run_id: UUID,
|
||||
agent_def: AgentDefinition,
|
||||
policy: PolicyDefinition,
|
||||
orchestrator: AgentOrchestrator,
|
||||
agents_dir: Path,
|
||||
) -> EvaluationReport:
|
||||
"""Execute each scenario; pass when there are no blocking guardrail violations."""
|
||||
scenarios = load_agent_scenarios(agents_dir, agent_def.name)
|
||||
if not scenarios:
|
||||
now = datetime.now(UTC)
|
||||
return EvaluationReport(
|
||||
id=uuid4(),
|
||||
training_run_id=training_run_id,
|
||||
agent_name=agent_def.name,
|
||||
agent_version=agent_def.version,
|
||||
passed=False,
|
||||
scenario_count=0,
|
||||
violation_count=0,
|
||||
created_at=now,
|
||||
notes="No scenarios found under agents/<name>/examples/",
|
||||
)
|
||||
|
||||
results: list[ScenarioEvalResult] = []
|
||||
total_blocking = 0
|
||||
for name, user_input in scenarios:
|
||||
execution = await orchestrator.invoke(
|
||||
agent_def=agent_def,
|
||||
policy=policy,
|
||||
user_input=user_input,
|
||||
)
|
||||
blocking = _blocking_violation_count(execution)
|
||||
total_blocking += blocking
|
||||
results.append(
|
||||
ScenarioEvalResult(
|
||||
scenario=name,
|
||||
status=execution.status,
|
||||
blocking_violations=blocking,
|
||||
)
|
||||
)
|
||||
|
||||
failed_count = sum(1 for r in results if r.status == "failed")
|
||||
passed = total_blocking == 0 and failed_count == 0
|
||||
notes = None
|
||||
if not passed:
|
||||
parts: list[str] = []
|
||||
if total_blocking:
|
||||
parts.append(f"{total_blocking} blocking guardrail violation(s)")
|
||||
if failed_count:
|
||||
parts.append(f"{failed_count} scenario(s) failed")
|
||||
notes = "; ".join(parts)
|
||||
|
||||
return EvaluationReport(
|
||||
id=uuid4(),
|
||||
training_run_id=training_run_id,
|
||||
agent_name=agent_def.name,
|
||||
agent_version=agent_def.version,
|
||||
passed=passed,
|
||||
scenario_count=len(results),
|
||||
violation_count=total_blocking,
|
||||
scenarios=results,
|
||||
created_at=datetime.now(UTC),
|
||||
notes=notes,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Load canonical evaluation scenarios from agent example files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_agent_scenarios(agents_dir: Path, agent_name: str) -> list[tuple[str, str]]:
|
||||
"""Return (scenario_name, input_text) pairs from agents/<name>/examples/*.txt."""
|
||||
examples_dir = agents_dir / agent_name / "examples"
|
||||
if not examples_dir.is_dir():
|
||||
return []
|
||||
scenarios: list[tuple[str, str]] = []
|
||||
for path in sorted(examples_dir.glob("*.txt")):
|
||||
scenarios.append((path.name, path.read_text(encoding="utf-8")))
|
||||
return scenarios
|
||||
@@ -10,24 +10,15 @@ from forja_core.guardrails.nemo import NeMoGuardrailsEngine
|
||||
|
||||
|
||||
def build_guardrail_engine(settings: Settings) -> GuardrailEngine:
|
||||
"""Ensambla el engine: GuardrailsAIEngine siempre; NeMo si está habilitado."""
|
||||
"""Ensambla el engine: GuardrailsAIEngine siempre; NeMo si está habilitado.
|
||||
|
||||
Los keywords de topical rails vienen de configuración (CSV en
|
||||
``GUARDRAILS_NEMO_ALLOWED_KEYWORDS``); el core no asume ningún dominio.
|
||||
"""
|
||||
engines: list[GuardrailEngine] = [GuardrailsAIEngine()]
|
||||
if settings.guardrails_nemo_enabled:
|
||||
engines.append(
|
||||
NeMoGuardrailsEngine(
|
||||
allowed_keywords=[
|
||||
"sip",
|
||||
"ims",
|
||||
"cscf",
|
||||
"sbc",
|
||||
"mos",
|
||||
"hss",
|
||||
"registro",
|
||||
"incidente",
|
||||
"voz",
|
||||
"codec",
|
||||
"rollback",
|
||||
],
|
||||
)
|
||||
)
|
||||
keywords = [
|
||||
k.strip() for k in settings.guardrails_nemo_allowed_keywords.split(",") if k.strip()
|
||||
]
|
||||
engines.append(NeMoGuardrailsEngine(allowed_keywords=keywords))
|
||||
return CompositeGuardrailEngine(engines)
|
||||
|
||||
@@ -48,7 +48,7 @@ class NeMoGuardrailsEngine:
|
||||
stage="input",
|
||||
validator="NeMoTopicalRails",
|
||||
severity="warning",
|
||||
message="Input fuera de los temas permitidos.",
|
||||
message="Input outside the allowed topics.",
|
||||
blocked=False,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -96,7 +96,7 @@ def detect_pii(
|
||||
stage=stage,
|
||||
validator="DetectPII",
|
||||
severity=sev,
|
||||
message=f"PII detectada: {matched}",
|
||||
message=f"PII detected: {matched}",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
@@ -123,7 +123,7 @@ def _pii_regex_fallback(
|
||||
stage=stage,
|
||||
validator="DetectPII",
|
||||
severity=sev,
|
||||
message=f"PII detectada (regex fallback): {', '.join(matched)}",
|
||||
message=f"PII detected (regex fallback): {', '.join(matched)}",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
@@ -144,7 +144,7 @@ def prompt_injection(
|
||||
stage=stage,
|
||||
validator="PromptInjection",
|
||||
severity=sev,
|
||||
message=f"Patrón de inyección detectado: {pattern}",
|
||||
message=f"Injection pattern detected: {pattern}",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
@@ -175,7 +175,7 @@ def toxic_language(
|
||||
stage=stage,
|
||||
validator="ToxicLanguage",
|
||||
severity=sev,
|
||||
message=f"Lenguaje tóxico (score={score:.2f}, hits={hits})",
|
||||
message=f"Toxic language (score={score:.2f}, hits={hits})",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
@@ -198,7 +198,7 @@ def forbidden_topics(
|
||||
stage=stage,
|
||||
validator="ForbiddenTopics",
|
||||
severity=sev,
|
||||
message=f"Temas prohibidos: {', '.join(matched)}",
|
||||
message=f"Forbidden topics: {', '.join(matched)}",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
@@ -256,7 +256,7 @@ def forbidden_action_keywords(
|
||||
stage=stage,
|
||||
validator="ForbiddenActionKeywords",
|
||||
severity=sev,
|
||||
message=f"Palabras prohibidas en acciones: {', '.join(matched)}",
|
||||
message=f"Forbidden keywords in actions: {', '.join(matched)}",
|
||||
blocked=blocked,
|
||||
)
|
||||
]
|
||||
@@ -275,7 +275,7 @@ def telco_safety_rules(
|
||||
target = str(a.get("target", "")).lower()
|
||||
rollback = str(a.get("rollback_plan", "")).strip()
|
||||
if ("prod" in target or "production" in target) and (not rollback or rollback == "n/a"):
|
||||
issues.append(f"acción sobre prod sin rollback: {a.get('action')}")
|
||||
issues.append(f"production action without rollback: {a.get('action')}")
|
||||
|
||||
if "never_propose_mass_action_without_canary" in rules:
|
||||
mass_keywords = {"all", "todos", "*", "mass"}
|
||||
@@ -283,7 +283,7 @@ def telco_safety_rules(
|
||||
target = str(a.get("target", "")).lower()
|
||||
plan = str(a.get("rollback_plan", "")).lower()
|
||||
if any(k in target for k in mass_keywords) and "canary" not in plan:
|
||||
issues.append(f"acción masiva sin canary: {a.get('action')}")
|
||||
issues.append(f"mass action without canary plan: {a.get('action')}")
|
||||
|
||||
if not issues:
|
||||
return []
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from forja_core.config import Settings
|
||||
from forja_core.llm.azure import AzureOpenAIProvider
|
||||
from forja_core.llm.base import LLMProvider
|
||||
from forja_core.llm.fallback import FallbackLLMProvider
|
||||
from forja_core.llm.mock import MockProvider
|
||||
from forja_core.llm.openai import OpenAIProvider
|
||||
|
||||
|
||||
def build_llm_provider(settings: Settings) -> LLMProvider:
|
||||
"""Materializa el proveedor activo. Falla en arranque si la config es inconsistente."""
|
||||
match settings.llm_provider:
|
||||
def _build_single(provider: Literal["mock", "azure", "openai"], settings: Settings) -> LLMProvider:
|
||||
match provider:
|
||||
case "mock":
|
||||
return MockProvider()
|
||||
case "azure":
|
||||
@@ -26,3 +28,13 @@ def build_llm_provider(settings: Settings) -> LLMProvider:
|
||||
api_key=settings.openai_api_key,
|
||||
model=settings.openai_model,
|
||||
)
|
||||
|
||||
|
||||
def build_llm_provider(settings: Settings) -> LLMProvider:
|
||||
"""Materializa el proveedor activo (con fallback opcional). Falla en arranque si la config es inconsistente."""
|
||||
primary = _build_single(settings.llm_provider, settings)
|
||||
fallback_name = settings.llm_fallback_provider
|
||||
if not fallback_name or fallback_name == settings.llm_provider:
|
||||
return primary
|
||||
fallback = _build_single(fallback_name, settings)
|
||||
return FallbackLLMProvider(primary, fallback)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Proveedor LLM con fallback: intenta el primario y, si agota sus reintentos, usa el secundario."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from forja_core.llm.base import CompletionResult, LLMProvider, Message
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class FallbackLLMProvider:
|
||||
"""Envuelve dos providers: si el primario lanza, delega en el de fallback.
|
||||
|
||||
Cada provider interno conserva su propia política de retries; este wrapper
|
||||
solo decide el cambio de proveedor cuando el primario falla definitivamente.
|
||||
"""
|
||||
|
||||
def __init__(self, primary: LLMProvider, fallback: LLMProvider) -> None:
|
||||
self._primary = primary
|
||||
self._fallback = fallback
|
||||
self.name = f"{primary.name}+fallback:{fallback.name}"
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
messages: list[Message],
|
||||
schema: dict[str, Any] | None = None,
|
||||
temperature: float = 0.2,
|
||||
max_tokens: int = 2000,
|
||||
) -> CompletionResult:
|
||||
try:
|
||||
return await self._primary.complete(
|
||||
messages, schema=schema, temperature=temperature, max_tokens=max_tokens
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"llm_primary_failed_falling_back",
|
||||
primary=self._primary.name,
|
||||
fallback=self._fallback.name,
|
||||
error=str(exc),
|
||||
)
|
||||
return await self._fallback.complete(
|
||||
messages, schema=schema, temperature=temperature, max_tokens=max_tokens
|
||||
)
|
||||
@@ -24,7 +24,16 @@ def create_app() -> FastAPI:
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
from forja_core.api import agents, executions, policies, violations
|
||||
from forja_core.api import (
|
||||
agents,
|
||||
datasets,
|
||||
executions,
|
||||
models,
|
||||
policies,
|
||||
promotions,
|
||||
training,
|
||||
violations,
|
||||
)
|
||||
from forja_core.web import ui
|
||||
|
||||
# UI HTMX embebida (Opción A) en rutas amigables para humanos.
|
||||
@@ -36,6 +45,10 @@ def create_app() -> FastAPI:
|
||||
app.include_router(executions.router, prefix="/api/executions", tags=["executions"])
|
||||
app.include_router(policies.router, prefix="/api/policies", tags=["policies"])
|
||||
app.include_router(violations.router, prefix="/api/violations", tags=["violations"])
|
||||
app.include_router(datasets.router, prefix="/api/datasets", tags=["datasets"])
|
||||
app.include_router(models.router, prefix="/api/models", tags=["models"])
|
||||
app.include_router(training.router, prefix="/api/training/runs", tags=["training"])
|
||||
app.include_router(promotions.router, prefix="/api/promotions", tags=["promotions"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Read-only dataset registry (YAML versioned, same layout as agents/)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forja_core.domain.dataset import DatasetDefinition, DatasetVersionMeta
|
||||
from forja_core.registry.yaml_store import VersionedYamlStore
|
||||
|
||||
|
||||
class FileSystemDatasetStore(VersionedYamlStore[DatasetDefinition]):
|
||||
"""Reads datasets from datasets/<name>/{index.yaml,versions/vN.yaml}."""
|
||||
|
||||
model_cls = DatasetDefinition
|
||||
kind = "dataset"
|
||||
|
||||
def list_datasets(self) -> list[DatasetDefinition]:
|
||||
return self._list_all()
|
||||
|
||||
def get_dataset(self, name: str, version: str | None = None) -> DatasetDefinition:
|
||||
return self._get(name, version)
|
||||
|
||||
def get_version(self, name: str, version: str) -> DatasetDefinition:
|
||||
return self._get_version(name, version)
|
||||
|
||||
def list_versions(self, name: str) -> list[DatasetVersionMeta]:
|
||||
return self._list_versions(name)
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from forja_core.config import Settings
|
||||
from forja_core.registry.dataset_store import FileSystemDatasetStore
|
||||
from forja_core.registry.model_store import FileSystemModelStore
|
||||
from forja_core.registry.policy_store import FileSystemPolicyStore
|
||||
from forja_core.registry.repository import FileSystemAgentRegistry
|
||||
|
||||
@@ -15,3 +17,11 @@ def build_agent_registry(settings: Settings) -> FileSystemAgentRegistry:
|
||||
def build_policy_store(settings: Settings) -> FileSystemPolicyStore:
|
||||
"""Construye el store de políticas apuntando a ``settings.policies_dir``."""
|
||||
return FileSystemPolicyStore(settings.policies_dir)
|
||||
|
||||
|
||||
def build_dataset_store(settings: Settings) -> FileSystemDatasetStore:
|
||||
return FileSystemDatasetStore(settings.datasets_dir)
|
||||
|
||||
|
||||
def build_model_store(settings: Settings) -> FileSystemModelStore:
|
||||
return FileSystemModelStore(settings.models_dir)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Read-only foundation model registry (YAML versioned)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forja_core.domain.foundation_model import ModelDefinition, ModelVersionMeta
|
||||
from forja_core.registry.yaml_store import VersionedYamlStore
|
||||
|
||||
|
||||
class FileSystemModelStore(VersionedYamlStore[ModelDefinition]):
|
||||
"""Reads models from models/<name>/{index.yaml,versions/vN.yaml}."""
|
||||
|
||||
model_cls = ModelDefinition
|
||||
kind = "model"
|
||||
|
||||
def list_models(self) -> list[ModelDefinition]:
|
||||
return self._list_all()
|
||||
|
||||
def get_model(self, name: str, version: str | None = None) -> ModelDefinition:
|
||||
return self._get(name, version)
|
||||
|
||||
def get_version(self, name: str, version: str) -> ModelDefinition:
|
||||
return self._get_version(name, version)
|
||||
|
||||
def list_versions(self, name: str) -> list[ModelVersionMeta]:
|
||||
return self._list_versions(name)
|
||||
@@ -2,67 +2,24 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from forja_core.domain.policy import PolicyDefinition, PolicyVersionMeta
|
||||
from forja_core.registry.versioning import compute_hash
|
||||
from forja_core.registry.yaml_store import VersionedYamlStore
|
||||
|
||||
|
||||
class FileSystemPolicyStore:
|
||||
class FileSystemPolicyStore(VersionedYamlStore[PolicyDefinition]):
|
||||
"""Lee políticas de policies/<name>/{index.yaml,versions/vN.yaml}."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self._root = Path(root)
|
||||
model_cls = PolicyDefinition
|
||||
kind = "policy"
|
||||
|
||||
def list_policies(self) -> list[PolicyDefinition]:
|
||||
if not self._root.exists():
|
||||
return []
|
||||
result: list[PolicyDefinition] = []
|
||||
for d in sorted(self._root.iterdir()):
|
||||
if d.is_dir() and (d / "index.yaml").exists():
|
||||
result.append(self.get_policy(d.name))
|
||||
return result
|
||||
return self._list_all()
|
||||
|
||||
def get_policy(self, name: str, version: str | None = None) -> PolicyDefinition:
|
||||
index = self._load_index(name)
|
||||
v = version or index["active_version"]
|
||||
path = self._root / name / "versions" / f"{v}.yaml"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"policy version not found: {name}@{v}")
|
||||
with path.open(encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return PolicyDefinition.model_validate(data)
|
||||
return self._get(name, version)
|
||||
|
||||
def list_versions(self, name: str) -> list[PolicyVersionMeta]:
|
||||
index = self._load_index(name)
|
||||
return [
|
||||
PolicyVersionMeta(
|
||||
id=v["id"],
|
||||
hash=v["hash"],
|
||||
author=v["author"],
|
||||
message=v["message"],
|
||||
created_at=self._parse_dt(v["created_at"]),
|
||||
)
|
||||
for v in index["versions"]
|
||||
]
|
||||
|
||||
def _load_index(self, name: str) -> dict[str, Any]:
|
||||
index_path = self._root / name / "index.yaml"
|
||||
if not index_path.exists():
|
||||
raise FileNotFoundError(f"policy not found: {name}")
|
||||
with index_path.open(encoding="utf-8") as f:
|
||||
data: dict[str, Any] = yaml.safe_load(f)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _parse_dt(value: str | datetime) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return self._list_versions(name)
|
||||
|
||||
def upsert_version(
|
||||
self,
|
||||
@@ -71,40 +28,5 @@ class FileSystemPolicyStore:
|
||||
message: str,
|
||||
author: str,
|
||||
) -> PolicyVersionMeta:
|
||||
policy_dir = self._root / name
|
||||
versions_dir = policy_dir / "versions"
|
||||
versions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
version_id = body.version
|
||||
yaml_text = yaml.safe_dump(
|
||||
body.model_dump(mode="json"),
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
)
|
||||
h = compute_hash(yaml_text)
|
||||
|
||||
version_path = versions_dir / f"{version_id}.yaml"
|
||||
version_path.write_text(yaml_text, encoding="utf-8")
|
||||
|
||||
meta = PolicyVersionMeta(
|
||||
id=version_id,
|
||||
hash=h,
|
||||
author=author,
|
||||
message=message,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
index_path = policy_dir / "index.yaml"
|
||||
if index_path.exists():
|
||||
with index_path.open(encoding="utf-8") as f:
|
||||
index: dict[str, Any] = yaml.safe_load(f)
|
||||
else:
|
||||
index = {"name": name, "versions": [], "active_version": version_id}
|
||||
|
||||
index["versions"] = [v for v in index["versions"] if v["id"] != version_id]
|
||||
index["versions"].append(meta.model_dump(mode="json"))
|
||||
index["active_version"] = version_id
|
||||
|
||||
with index_path.open("w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(index, f, sort_keys=False, allow_unicode=True)
|
||||
return meta
|
||||
# Las políticas no tienen estado draft/active: la última versión escrita manda.
|
||||
return self._upsert_version(name, body, body.version, message, author, set_active=True)
|
||||
|
||||
@@ -3,55 +3,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from forja_core.domain.agent import AgentDefinition, AgentVersionMeta
|
||||
from forja_core.registry.versioning import DiffResult, compute_hash, unified_diff
|
||||
from forja_core.registry.versioning import DiffResult, unified_diff
|
||||
from forja_core.registry.yaml_store import VersionedYamlStore
|
||||
|
||||
|
||||
class FileSystemAgentRegistry:
|
||||
class FileSystemAgentRegistry(VersionedYamlStore[AgentDefinition]):
|
||||
"""Lee/escribe agentes en agents/<name>/{index.yaml,versions/vN.yaml}."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self._root = Path(root)
|
||||
model_cls = AgentDefinition
|
||||
kind = "agent"
|
||||
|
||||
def list_agents(self) -> list[AgentDefinition]:
|
||||
if not self._root.exists():
|
||||
return []
|
||||
agents: list[AgentDefinition] = []
|
||||
for d in sorted(self._root.iterdir()):
|
||||
if d.is_dir() and (d / "index.yaml").exists():
|
||||
agents.append(self.get_agent(d.name))
|
||||
return agents
|
||||
return self._list_all()
|
||||
|
||||
def get_agent(self, name: str, version: str | None = None) -> AgentDefinition:
|
||||
index = self._load_index(name)
|
||||
v = version or index["active_version"]
|
||||
return self.get_version(name, v)
|
||||
return self._get(name, version)
|
||||
|
||||
def get_version(self, name: str, version: str) -> AgentDefinition:
|
||||
path = self._root / name / "versions" / f"{version}.yaml"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"agent version not found: {name}@{version}")
|
||||
with path.open(encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return AgentDefinition.model_validate(data)
|
||||
return self._get_version(name, version)
|
||||
|
||||
def list_versions(self, name: str) -> list[AgentVersionMeta]:
|
||||
index = self._load_index(name)
|
||||
return [
|
||||
AgentVersionMeta(
|
||||
id=v["id"],
|
||||
hash=v["hash"],
|
||||
author=v["author"],
|
||||
message=v["message"],
|
||||
created_at=self._parse_dt(v["created_at"]),
|
||||
)
|
||||
for v in index["versions"]
|
||||
]
|
||||
return self._list_versions(name)
|
||||
|
||||
def upsert_version(
|
||||
self,
|
||||
@@ -60,45 +34,26 @@ class FileSystemAgentRegistry:
|
||||
message: str,
|
||||
author: str,
|
||||
) -> AgentVersionMeta:
|
||||
agent_dir = self._root / name
|
||||
versions_dir = agent_dir / "versions"
|
||||
versions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
version_id = body.version
|
||||
yaml_text = yaml.safe_dump(
|
||||
body.model_dump(mode="json"),
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
)
|
||||
h = compute_hash(yaml_text)
|
||||
|
||||
version_path = versions_dir / f"{version_id}.yaml"
|
||||
version_path.write_text(yaml_text, encoding="utf-8")
|
||||
|
||||
meta = AgentVersionMeta(
|
||||
id=version_id,
|
||||
hash=h,
|
||||
author=author,
|
||||
message=message,
|
||||
created_at=datetime.now(UTC),
|
||||
return self._upsert_version(
|
||||
name,
|
||||
body,
|
||||
body.version,
|
||||
message,
|
||||
author,
|
||||
set_active=body.state == "active",
|
||||
)
|
||||
|
||||
index_path = agent_dir / "index.yaml"
|
||||
if index_path.exists():
|
||||
with index_path.open(encoding="utf-8") as f:
|
||||
index: dict[str, Any] = yaml.safe_load(f)
|
||||
else:
|
||||
index = {"name": name, "versions": [], "active_version": version_id}
|
||||
|
||||
# reemplaza si existía la versión, si no añade
|
||||
index["versions"] = [v for v in index["versions"] if v["id"] != version_id]
|
||||
index["versions"].append(meta.model_dump(mode="json"))
|
||||
if body.state == "active":
|
||||
index["active_version"] = version_id
|
||||
|
||||
with index_path.open("w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(index, f, sort_keys=False, allow_unicode=True)
|
||||
return meta
|
||||
def promote_to_active(
|
||||
self,
|
||||
name: str,
|
||||
version: str,
|
||||
author: str,
|
||||
comment: str,
|
||||
) -> AgentVersionMeta:
|
||||
"""Set a version to active and update the registry index (governance promotion)."""
|
||||
agent = self.get_version(name, version)
|
||||
updated = agent.model_copy(update={"state": "active", "updated_at": datetime.now(UTC)})
|
||||
return self.upsert_version(name, updated, message=comment, author=author)
|
||||
|
||||
def diff_versions(self, name: str, v1: str, v2: str) -> DiffResult:
|
||||
path_a = self._root / name / "versions" / f"{v1}.yaml"
|
||||
@@ -110,17 +65,3 @@ class FileSystemAgentRegistry:
|
||||
to_version=v2,
|
||||
unified_diff=unified_diff(text_a, text_b, f"{name}@{v1}", f"{name}@{v2}"),
|
||||
)
|
||||
|
||||
def _load_index(self, name: str) -> dict[str, Any]:
|
||||
index_path = self._root / name / "index.yaml"
|
||||
if not index_path.exists():
|
||||
raise FileNotFoundError(f"agent not found: {name}")
|
||||
with index_path.open(encoding="utf-8") as f:
|
||||
data: dict[str, Any] = yaml.safe_load(f)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _parse_dt(value: str | datetime) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Base genérica de los registries versionados en YAML.
|
||||
|
||||
Layout común en disco: ``<root>/<name>/{index.yaml, versions/<vN>.yaml}``.
|
||||
Agentes, políticas, datasets y modelos comparten esta mecánica; cada store
|
||||
concreto fija ``model_cls`` (el modelo Pydantic de la definición) y ``kind``
|
||||
(el nombre del artefacto en los mensajes de error) y expone wrappers con
|
||||
nombres de dominio (``get_agent``, ``get_policy``, ...).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
from forja_core.domain.version_meta import VersionMeta
|
||||
from forja_core.registry.versioning import compute_hash
|
||||
|
||||
M = TypeVar("M", bound=BaseModel)
|
||||
|
||||
|
||||
class VersionedYamlStore(Generic[M]):
|
||||
"""Operaciones de lectura sobre un árbol de artefactos versionados."""
|
||||
|
||||
model_cls: type[M]
|
||||
kind: str = "item"
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self._root = Path(root)
|
||||
|
||||
def _list_all(self) -> list[M]:
|
||||
if not self._root.exists():
|
||||
return []
|
||||
out: list[M] = []
|
||||
for d in sorted(self._root.iterdir()):
|
||||
if d.is_dir() and (d / "index.yaml").exists():
|
||||
out.append(self._get(d.name))
|
||||
return out
|
||||
|
||||
def _get(self, name: str, version: str | None = None) -> M:
|
||||
index = self._load_index(name)
|
||||
v: str = version or index["active_version"]
|
||||
return self._get_version(name, v)
|
||||
|
||||
def _get_version(self, name: str, version: str) -> M:
|
||||
path = self._root / name / "versions" / f"{version}.yaml"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"{self.kind} version not found: {name}@{version}")
|
||||
with path.open(encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return self.model_cls.model_validate(data)
|
||||
|
||||
def _list_versions(self, name: str) -> list[VersionMeta]:
|
||||
index = self._load_index(name)
|
||||
return [
|
||||
VersionMeta(
|
||||
id=v["id"],
|
||||
hash=v["hash"],
|
||||
author=v["author"],
|
||||
message=v["message"],
|
||||
created_at=self._parse_dt(v["created_at"]),
|
||||
)
|
||||
for v in index["versions"]
|
||||
]
|
||||
|
||||
def _load_index(self, name: str) -> dict[str, Any]:
|
||||
index_path = self._root / name / "index.yaml"
|
||||
if not index_path.exists():
|
||||
raise FileNotFoundError(f"{self.kind} not found: {name}")
|
||||
with index_path.open(encoding="utf-8") as f:
|
||||
data: dict[str, Any] = yaml.safe_load(f)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _parse_dt(value: str | datetime) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
|
||||
# --- Escritura (usada por agentes y políticas) -----------------------------------
|
||||
|
||||
def _upsert_version(
|
||||
self,
|
||||
name: str,
|
||||
body: M,
|
||||
version_id: str,
|
||||
message: str,
|
||||
author: str,
|
||||
*,
|
||||
set_active: bool,
|
||||
) -> VersionMeta:
|
||||
"""Escribe ``versions/<version_id>.yaml`` y actualiza ``index.yaml``.
|
||||
|
||||
Reemplaza la entrada del índice si la versión ya existía; si
|
||||
``set_active`` es True, la marca como ``active_version``.
|
||||
"""
|
||||
artifact_dir = self._root / name
|
||||
versions_dir = artifact_dir / "versions"
|
||||
versions_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
yaml_text = yaml.safe_dump(
|
||||
body.model_dump(mode="json"),
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
)
|
||||
(versions_dir / f"{version_id}.yaml").write_text(yaml_text, encoding="utf-8")
|
||||
|
||||
meta = VersionMeta(
|
||||
id=version_id,
|
||||
hash=compute_hash(yaml_text),
|
||||
author=author,
|
||||
message=message,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
index_path = artifact_dir / "index.yaml"
|
||||
if index_path.exists():
|
||||
with index_path.open(encoding="utf-8") as f:
|
||||
index: dict[str, Any] = yaml.safe_load(f)
|
||||
else:
|
||||
index = {"name": name, "versions": [], "active_version": version_id}
|
||||
|
||||
index["versions"] = [v for v in index["versions"] if v["id"] != version_id]
|
||||
index["versions"].append(meta.model_dump(mode="json"))
|
||||
if set_active:
|
||||
index["active_version"] = version_id
|
||||
|
||||
with index_path.open("w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(index, f, sort_keys=False, allow_unicode=True)
|
||||
return meta
|
||||
@@ -39,9 +39,7 @@ def _thread_config(trace_id: UUID) -> dict[str, Any]:
|
||||
class AgentOrchestrator:
|
||||
"""Punto único de entrada para invocar agentes, reanudar HITL y leer su estado."""
|
||||
|
||||
def __init__(
|
||||
self, *, provider: LLMProvider, engine: GuardrailEngine, data_dir: Path
|
||||
) -> None:
|
||||
def __init__(self, *, provider: LLMProvider, engine: GuardrailEngine, data_dir: Path) -> None:
|
||||
self._provider = provider
|
||||
self._engine = engine
|
||||
self._data_dir = data_dir
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""External MLOps training backends (Strategy pattern)."""
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Placeholder training script uploaded with Azure ML command jobs.
|
||||
|
||||
Replace this script in your workspace or override ``AZURE_ML_COMMAND`` for real fine-tuning.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def main() -> None:
|
||||
payload = {
|
||||
"forja_run_id": os.environ.get("FORJA_RUN_ID"),
|
||||
"dataset": os.environ.get("FORJA_DATASET"),
|
||||
"model": os.environ.get("FORJA_MODEL"),
|
||||
"agent": os.environ.get("FORJA_AGENT"),
|
||||
"learning_rate": os.environ.get("FORJA_LEARNING_RATE"),
|
||||
"epochs": os.environ.get("FORJA_EPOCHS"),
|
||||
}
|
||||
print(json.dumps(payload))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Azure ML training backend — SDK when configured, stub otherwise."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
|
||||
from forja_core.config import Settings
|
||||
from forja_core.domain.training import ArtifactRef, BackendJobStatus, TrainingJobSpec
|
||||
from forja_core.training.azure_ml_config import is_azure_ml_configured
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AzureMLTrainingBackend:
|
||||
"""Submits command jobs via Azure ML SDK v2 when workspace settings are present."""
|
||||
|
||||
name = "azure_ml"
|
||||
|
||||
def __init__(self, settings: Settings | None = None) -> None:
|
||||
self._settings = settings or Settings()
|
||||
self._use_sdk = is_azure_ml_configured(self._settings)
|
||||
if not self._use_sdk:
|
||||
log.info(
|
||||
"azure_ml_stub_mode",
|
||||
reason="missing workspace config, compute, or azure-ai-ml packages",
|
||||
)
|
||||
|
||||
async def submit(self, spec: TrainingJobSpec, run_id: UUID) -> str:
|
||||
if self._use_sdk:
|
||||
from forja_core.training.azure_ml_sdk import submit_command_job
|
||||
|
||||
return await asyncio.to_thread(submit_command_job, self._settings, spec, run_id)
|
||||
return f"azureml-stub:{run_id}"
|
||||
|
||||
async def status(self, external_job_id: str) -> BackendJobStatus:
|
||||
if external_job_id.startswith("azureml-stub:"):
|
||||
return _stub_status(external_job_id)
|
||||
if external_job_id.startswith("azureml:"):
|
||||
job_name = external_job_id.removeprefix("azureml:")
|
||||
if self._use_sdk:
|
||||
from forja_core.training.azure_ml_sdk import poll_job_status
|
||||
|
||||
return await asyncio.to_thread(poll_job_status, self._settings, job_name)
|
||||
return BackendJobStatus(
|
||||
status="failed",
|
||||
message="Azure ML SDK not configured; cannot poll live job",
|
||||
)
|
||||
return BackendJobStatus(
|
||||
status="failed",
|
||||
message=f"Unknown Azure ML job id: {external_job_id}",
|
||||
)
|
||||
|
||||
|
||||
def _stub_status(external_job_id: str) -> BackendJobStatus:
|
||||
return BackendJobStatus(
|
||||
status="succeeded",
|
||||
message="Azure ML stub job completed (workspace not configured)",
|
||||
artifacts=[
|
||||
ArtifactRef(
|
||||
uri=f"azureml://workspaces/stub/jobs/{external_job_id}/outputs/adapter",
|
||||
kind="adapter",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Azure ML workspace settings helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forja_core.config import Settings
|
||||
|
||||
|
||||
def sdk_packages_available() -> bool:
|
||||
try:
|
||||
import azure.ai.ml
|
||||
import azure.identity # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def is_azure_ml_configured(settings: Settings) -> bool:
|
||||
return bool(
|
||||
settings.azure_ml_subscription_id.strip()
|
||||
and settings.azure_ml_resource_group.strip()
|
||||
and settings.azure_ml_workspace_name.strip()
|
||||
and settings.azure_ml_compute.strip()
|
||||
and sdk_packages_available()
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Synchronous Azure ML SDK v2 client (invoked via asyncio.to_thread from the backend)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
from azure.ai.ml import MLClient, command
|
||||
from azure.identity import DefaultAzureCredential
|
||||
|
||||
from forja_core.config import Settings
|
||||
from forja_core.domain.training import (
|
||||
ArtifactRef,
|
||||
BackendJobStatus,
|
||||
TrainingJobSpec,
|
||||
TrainingRunStatus,
|
||||
)
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
JOB_CODE_DIR = Path(__file__).parent / "_azure_job_bundle"
|
||||
|
||||
_AZURE_TO_FORJA_STATUS: dict[str, TrainingRunStatus] = {
|
||||
"notstarted": "queued",
|
||||
"starting": "queued",
|
||||
"provisioning": "queued",
|
||||
"queued": "queued",
|
||||
"running": "running",
|
||||
"finalizing": "running",
|
||||
"completed": "succeeded",
|
||||
"failed": "failed",
|
||||
"canceled": "cancelled",
|
||||
"cancelled": "cancelled",
|
||||
}
|
||||
|
||||
|
||||
def _ml_client(settings: Settings) -> MLClient:
|
||||
return MLClient(
|
||||
credential=DefaultAzureCredential(),
|
||||
subscription_id=settings.azure_ml_subscription_id,
|
||||
resource_group_name=settings.azure_ml_resource_group,
|
||||
workspace_name=settings.azure_ml_workspace_name,
|
||||
)
|
||||
|
||||
|
||||
def submit_command_job(settings: Settings, spec: TrainingJobSpec, run_id: UUID) -> str:
|
||||
"""Create an Azure ML command job; returns external id ``azureml:<job_name>``."""
|
||||
client = _ml_client(settings)
|
||||
display_name = f"forja-{run_id}"
|
||||
env_vars = {
|
||||
"FORJA_RUN_ID": str(run_id),
|
||||
"FORJA_DATASET": f"{spec.dataset_name}@{spec.dataset_version}",
|
||||
"FORJA_MODEL": f"{spec.model_name}@{spec.model_version}",
|
||||
"FORJA_AGENT": spec.agent_name or "",
|
||||
"FORJA_AGENT_VERSION": spec.agent_version or "",
|
||||
"FORJA_LEARNING_RATE": str(spec.hyperparameters.learning_rate),
|
||||
"FORJA_EPOCHS": str(spec.hyperparameters.epochs),
|
||||
"FORJA_BATCH_SIZE": str(spec.hyperparameters.batch_size),
|
||||
}
|
||||
job_def = command(
|
||||
code=str(JOB_CODE_DIR),
|
||||
command=settings.azure_ml_command,
|
||||
environment=settings.azure_ml_environment,
|
||||
compute=settings.azure_ml_compute,
|
||||
display_name=display_name,
|
||||
experiment_name=settings.azure_ml_experiment_name,
|
||||
environment_variables=env_vars,
|
||||
)
|
||||
created = client.jobs.create_or_update(job_def)
|
||||
job_name = created.name
|
||||
if job_name is None:
|
||||
raise RuntimeError("Azure ML job created without a name")
|
||||
log.info("azure_ml_job_submitted", job_name=job_name, run_id=str(run_id))
|
||||
return f"azureml:{job_name}"
|
||||
|
||||
|
||||
def poll_job_status(settings: Settings, job_name: str) -> BackendJobStatus:
|
||||
client = _ml_client(settings)
|
||||
job = client.jobs.get(job_name)
|
||||
raw_status = str(job.status) if job.status is not None else "Queued"
|
||||
raw = raw_status.split(".")[-1].lower()
|
||||
forja_status = _AZURE_TO_FORJA_STATUS.get(raw, "running")
|
||||
message = None
|
||||
if forja_status == "failed":
|
||||
message = "Azure ML job failed"
|
||||
artifacts: list[ArtifactRef] = []
|
||||
if forja_status == "succeeded":
|
||||
artifacts.append(
|
||||
ArtifactRef(
|
||||
uri=(
|
||||
f"azureml://subscriptions/{settings.azure_ml_subscription_id}"
|
||||
f"/resourcegroups/{settings.azure_ml_resource_group}"
|
||||
f"/workspaces/{settings.azure_ml_workspace_name}"
|
||||
f"/jobs/{job_name}/outputs/default"
|
||||
),
|
||||
kind="adapter",
|
||||
)
|
||||
)
|
||||
return BackendJobStatus(status=forja_status, message=message, artifacts=artifacts)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""TrainingBackend protocol for external fine-tuning platforms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from forja_core.domain.training import BackendJobStatus, TrainingJobSpec
|
||||
|
||||
|
||||
class TrainingBackend(Protocol):
|
||||
"""Submit and poll jobs on Azure ML, SageMaker, Hugging Face Jobs, etc."""
|
||||
|
||||
name: str
|
||||
|
||||
async def submit(self, spec: TrainingJobSpec, run_id: UUID) -> str:
|
||||
"""Start a remote job; returns provider job id."""
|
||||
...
|
||||
|
||||
async def status(self, external_job_id: str) -> BackendJobStatus:
|
||||
"""Poll remote job status."""
|
||||
...
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Build TrainingBackend from Settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forja_core.config import Settings
|
||||
from forja_core.training.azure_ml import AzureMLTrainingBackend
|
||||
from forja_core.training.base import TrainingBackend
|
||||
from forja_core.training.mock import MockTrainingBackend
|
||||
|
||||
|
||||
def build_training_backend(settings: Settings) -> TrainingBackend:
|
||||
if settings.training_backend == "azure_ml":
|
||||
return AzureMLTrainingBackend(settings)
|
||||
return MockTrainingBackend()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Mock training backend for local development and tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from forja_core.domain.training import ArtifactRef, BackendJobStatus, TrainingJobSpec
|
||||
|
||||
|
||||
class MockTrainingBackend:
|
||||
"""Immediately succeeds with a placeholder artifact URI."""
|
||||
|
||||
name = "mock"
|
||||
|
||||
async def submit(self, spec: TrainingJobSpec, run_id: UUID) -> str:
|
||||
return f"mock:{run_id}"
|
||||
|
||||
async def status(self, external_job_id: str) -> BackendJobStatus:
|
||||
return BackendJobStatus(
|
||||
status="succeeded",
|
||||
message="Mock training completed",
|
||||
artifacts=[
|
||||
ArtifactRef(
|
||||
uri=f"{external_job_id}/adapter",
|
||||
kind="adapter",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
{% for card in agent_cards %}
|
||||
{% set agent = card.agent %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-lg font-semibold tracking-tight">{{ agent.name }}</span>
|
||||
{% for v in card.versions %}
|
||||
<span class="text-[11px] px-2 py-0.5 rounded border font-mono
|
||||
{% if v.state == 'active' %}bg-emerald-950 text-emerald-400 border-emerald-900
|
||||
{% elif v.state == 'draft' %}bg-amber-950/60 text-amber-400 border-amber-900
|
||||
{% else %}bg-forge-surface2 text-forge-muted border-forge-iron{% endif %}">
|
||||
{{ v.id }} · {{ v.state }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="text-sm text-forge-muted mt-2 max-w-xl">{{ agent.purpose }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs text-forge-muted">
|
||||
<span>LLM: <span class="font-mono text-forge-steel">{{ agent.llm.provider }}/{{ agent.llm.model }}</span></span>
|
||||
<span>HITL threshold: <span class="font-mono text-forge-steel">risk ≥ {{ agent.risk_threshold_for_hitl }}</span></span>
|
||||
<span>Policy:
|
||||
{% for g in agent.guardrails %}
|
||||
<span class="font-mono text-forge-steel">{{ g }}</span>{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</span>
|
||||
<span>Owner: {{ agent.owner }}</span>
|
||||
</div>
|
||||
|
||||
<details class="mt-3 group">
|
||||
<summary class="cursor-pointer select-none text-xs text-forge-muted hover:text-forge-ember">
|
||||
<span class="group-open:hidden">Show system prompt and output schema</span>
|
||||
<span class="hidden group-open:inline">Hide system prompt and output schema</span>
|
||||
</summary>
|
||||
<div class="mt-2 p-3 bg-black/60 border border-forge-iron rounded-xl text-xs whitespace-pre-wrap font-light leading-relaxed">{{ agent.system_prompt }}</div>
|
||||
<pre class="mt-2 text-[10px] bg-black/40 border border-forge-iron p-3 rounded-xl overflow-auto max-h-44 text-forge-steel">{{ agent.output_schema | tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-8 text-center text-forge-muted">No agents registered.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{% for p in policies %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-5">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="font-medium">{{ p.name }} <span class="text-xs text-forge-muted font-mono">{{ p.version }}</span></div>
|
||||
<span class="text-[10px] px-2 py-0.5 rounded border font-mono
|
||||
{{ 'bg-red-950/60 border-red-900 text-red-400' if p.on_validator_error == 'fail_closed' else 'bg-forge-surface2 border-forge-iron text-forge-steel' }}">
|
||||
{{ p.on_validator_error }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-forge-muted mt-1">{{ p.description }}</p>
|
||||
<div class="mt-3 text-[11px]">
|
||||
<div class="uppercase tracking-[1.5px] text-forge-muted mb-1">Input validators</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for v in p.input_validators %}
|
||||
<span class="px-2 py-0.5 bg-black/40 border border-forge-iron rounded font-mono">{{ v.type }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="uppercase tracking-[1.5px] text-forge-muted mb-1 mt-2">Output validators</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for v in p.output_validators %}
|
||||
<span class="px-2 py-0.5 bg-black/40 border border-forge-iron rounded font-mono">{{ v.type }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-6 text-center text-forge-muted text-sm">No policies registered.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<div class="space-y-4">
|
||||
{% for ex in pending %}
|
||||
<div class="forge-card border border-amber-900/60 rounded-2xl p-5">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="font-medium">{{ ex.agent_name }} <span class="text-forge-muted">v{{ ex.agent_version }}</span></div>
|
||||
<div class="text-xs text-forge-muted font-mono mt-0.5">{{ ex.trace_id }}</div>
|
||||
</div>
|
||||
<div class="text-amber-400 text-xs font-mono tracking-wide">Pending approval</div>
|
||||
</div>
|
||||
|
||||
{% if ex.needs_human_for %}
|
||||
<div class="mt-4 space-y-1.5">
|
||||
<div class="uppercase text-xs tracking-[1.5px] text-forge-muted">Actions awaiting approval</div>
|
||||
{% for a in ex.needs_human_for %}
|
||||
<div class="text-xs bg-black/50 border border-forge-iron rounded p-2.5">
|
||||
<div>
|
||||
<span class="font-mono">{{ a.action }}</span> → <span class="font-mono text-forge-steel">{{ a.target }}</span>
|
||||
<span class="font-mono {{ 'text-amber-400' if a.risk_score < 5 else 'text-red-400' }}">(risk={{ a.risk_score }})</span>
|
||||
</div>
|
||||
<div class="text-forge-muted mt-1">Rollback: {{ a.rollback_plan }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button
|
||||
hx-post="/api/executions/{{ ex.trace_id }}/approve"
|
||||
hx-ext="json-enc"
|
||||
hx-vals='{"approved_action_ids": [], "comment": "Approved from UI"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger(document.body, 'forja-refresh')"
|
||||
class="px-4 py-1.5 text-xs font-medium bg-emerald-600 hover:bg-emerald-500 text-white rounded transition">
|
||||
Approve all
|
||||
</button>
|
||||
<button
|
||||
hx-post="/api/executions/{{ ex.trace_id }}/reject"
|
||||
hx-ext="json-enc"
|
||||
hx-vals='{"reason": "Rejected from web UI"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger(document.body, 'forja-refresh')"
|
||||
class="px-4 py-1.5 text-xs font-medium bg-red-700 hover:bg-red-600 text-white rounded transition">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-6 text-center text-forge-muted text-sm">
|
||||
No runs are waiting for approval. Run the demo incident in stage <a href="#run" class="text-forge-ember hover:underline">02 — Run</a>.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="space-y-3">
|
||||
{% for ex in runs %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<span class="font-medium">{{ ex.agent_name }}</span>
|
||||
<span class="text-xs text-forge-muted">v{{ ex.agent_version }}</span>
|
||||
<span class="text-xs text-forge-muted font-mono ml-2">{{ ex.trace_id }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-xs shrink-0">
|
||||
<span class="text-forge-muted">{{ ex.n_proposed_actions }} action(s)</span>
|
||||
<span class="{{ 'text-amber-400' if ex.n_violations > 0 else 'text-forge-muted' }}">{{ ex.n_violations }} violation(s)</span>
|
||||
<span class="text-emerald-400 font-mono">Completed</span>
|
||||
<span class="text-forge-muted">{{ ex.finished_at.strftime('%H:%M:%S') if ex.finished_at else '' }}</span>
|
||||
<a href="/api/executions/{{ ex.trace_id }}" target="_blank" class="text-forge-ember hover:underline">JSON →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-6 text-center text-forge-muted text-sm">
|
||||
No completed runs yet. They appear here after stage <a href="#approve" class="text-forge-ember hover:underline">03 — Approve</a> (or directly when no approval is needed).
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if total_completed > runs | length %}
|
||||
<div class="text-xs text-forge-muted text-center pt-1">Showing the {{ runs | length }} most recent of {{ total_completed }} completed runs.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
<div class="space-y-4">
|
||||
{% for item in pending %}
|
||||
{% set req = item.request %}
|
||||
{% set ev = item.evaluation %}
|
||||
<div class="forge-card border border-amber-900/60 rounded-2xl p-5">
|
||||
<div class="flex justify-between items-start gap-4">
|
||||
<div>
|
||||
<div class="font-medium text-lg">{{ req.agent_name }} <span class="text-forge-muted">{{ req.agent_version }} → active</span></div>
|
||||
<div class="text-xs text-forge-muted font-mono mt-1">request {{ req.id }}</div>
|
||||
</div>
|
||||
<div class="text-amber-400 text-xs font-mono tracking-wide shrink-0">Pending promotion</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs">
|
||||
<div>
|
||||
<span class="text-forge-muted uppercase tracking-wide">Training run</span>
|
||||
<div class="font-mono mt-0.5 truncate">{{ req.training_run_id }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-forge-muted uppercase tracking-wide">Evaluation</span>
|
||||
<div class="mt-0.5 {% if ev and ev.passed %}text-emerald-400{% else %}text-red-400{% endif %}">
|
||||
{% if ev %}{{ "Passed" if ev.passed else "Failed" }} ({{ ev.agent_name }}@{{ ev.agent_version }}){% else %}Unknown{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-forge-muted uppercase tracking-wide">Scenarios</span>
|
||||
<div class="mt-0.5">{% if ev %}{{ ev.scenario_count }} run, {{ ev.violation_count }} blocking{% else %}—{% endif %}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-forge-muted mt-3">
|
||||
Requested by {{ req.requested_by }} · {{ req.created_at.strftime('%Y-%m-%d %H:%M') if req.created_at else '' }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button
|
||||
hx-post="/api/promotions/{{ req.id }}/approve"
|
||||
hx-ext="json-enc"
|
||||
hx-vals='{"approved_by": "web-ui", "comment": "Approved from Promotions UI"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger(document.body, 'forja-refresh')"
|
||||
class="px-4 py-1.5 text-xs font-medium bg-emerald-600 hover:bg-emerald-500 text-white rounded transition">
|
||||
Approve promotion
|
||||
</button>
|
||||
<button
|
||||
hx-post="/api/promotions/{{ req.id }}/reject"
|
||||
hx-ext="json-enc"
|
||||
hx-vals='{"rejected_by": "web-ui", "reason": "Rejected from Promotions UI"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger(document.body, 'forja-refresh')"
|
||||
class="px-4 py-1.5 text-xs font-medium bg-red-700 hover:bg-red-600 text-white rounded transition">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-6 text-center text-forge-muted text-sm">
|
||||
No promotion requests are waiting. Request one from stage <a href="#train" class="text-forge-ember hover:underline">04 — Train</a> after a passing evaluation.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -0,0 +1,98 @@
|
||||
<div class="space-y-4">
|
||||
{% for item in items %}
|
||||
{% set run = item.run %}
|
||||
{% set ev = item.evaluation %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-5">
|
||||
<div class="flex justify-between items-start gap-4">
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{{ run.spec.dataset_name }}@{{ run.spec.dataset_version }}
|
||||
<span class="text-forge-muted">→</span>
|
||||
{{ run.spec.model_name }}@{{ run.spec.model_version }}
|
||||
</div>
|
||||
<div class="text-xs text-forge-muted font-mono mt-1">run {{ run.id }}</div>
|
||||
{% if run.spec.agent_name %}
|
||||
<div class="text-xs text-forge-muted mt-1">
|
||||
Candidate: <span class="font-mono text-forge-steel">{{ run.spec.agent_name }}{% if run.spec.agent_version %}@{{ run.spec.agent_version }}{% endif %}</span>
|
||||
{% if item.candidate_state %}
|
||||
<span class="ml-1 {{ 'text-emerald-400' if item.candidate_state == 'active' else 'text-amber-400' }}">({{ item.candidate_state }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="text-xs font-mono tracking-wide shrink-0
|
||||
{{ 'text-emerald-400' if run.status == 'succeeded'
|
||||
else ('text-red-400' if run.status in ['failed', 'cancelled']
|
||||
else 'text-amber-400') }}">
|
||||
{{ run.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if run.error_message %}
|
||||
<p class="mt-2 text-xs text-red-400">{{ run.error_message }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if ev %}
|
||||
<div class="mt-3 text-xs">
|
||||
<span class="uppercase tracking-wide text-forge-muted">Evaluation</span>
|
||||
<span class="ml-2 {{ 'text-emerald-400' if ev.passed else 'text-red-400' }}">
|
||||
{{ "Passed" if ev.passed else "Failed" }}
|
||||
</span>
|
||||
<span class="text-forge-muted ml-2">{{ ev.scenario_count }} scenario(s), {{ ev.violation_count }} blocking violation(s) — covers {{ ev.agent_name }}@{{ ev.agent_version }}</span>
|
||||
{% if ev.notes %}<span class="text-forge-muted ml-2">— {{ ev.notes }}</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
{% if run.status not in ['succeeded', 'failed', 'cancelled'] %}
|
||||
<button
|
||||
hx-post="/api/training/runs/{{ run.id }}/refresh"
|
||||
hx-ext="json-enc"
|
||||
hx-vals='{}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger(document.body, 'forja-refresh')"
|
||||
class="px-4 py-1.5 text-xs font-medium bg-forge-surface2 border border-forge-iron hover:border-forge-ember rounded transition">
|
||||
Refresh status
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if run.status == 'succeeded' and run.spec.agent_name and not ev %}
|
||||
<button
|
||||
hx-post="/api/training/runs/{{ run.id }}/evaluate"
|
||||
hx-ext="json-enc"
|
||||
hx-vals='{}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger(document.body, 'forja-refresh')"
|
||||
class="px-4 py-1.5 text-xs font-medium bg-forge-ember text-black hover:brightness-105 rounded transition">
|
||||
Run evaluation
|
||||
</button>
|
||||
<span class="text-xs text-forge-muted">Runs the candidate over its canonical scenarios.</span>
|
||||
{% endif %}
|
||||
|
||||
{% if ev and ev.passed and run.spec.agent_version %}
|
||||
{% if item.candidate_state == 'active' %}
|
||||
<span class="text-xs text-emerald-400">✓ Promoted — {{ run.spec.agent_name }}@{{ run.spec.agent_version }} is active (see stage 01).</span>
|
||||
{% elif item.promotion_pending %}
|
||||
<span class="text-xs text-amber-400">Promotion pending approval in stage <a href="#promote" class="underline">05 — Promote</a>.</span>
|
||||
{% else %}
|
||||
<button
|
||||
hx-post="/api/promotions"
|
||||
hx-ext="json-enc"
|
||||
hx-vals='{"agent_name": "{{ run.spec.agent_name }}", "agent_version": "{{ run.spec.agent_version }}", "training_run_id": "{{ run.id }}", "evaluation_report_id": "{{ ev.id }}", "requested_by": "web-ui"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger(document.body, 'forja-refresh')"
|
||||
class="px-4 py-1.5 text-xs font-medium bg-emerald-600 hover:bg-emerald-500 text-white rounded transition">
|
||||
Request promotion
|
||||
</button>
|
||||
{% endif %}
|
||||
{% elif ev and ev.passed and not run.spec.agent_version %}
|
||||
<span class="text-xs text-forge-muted">Set a candidate version on the run to request promotion.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-6 text-center text-forge-muted text-sm">
|
||||
No training runs yet. Submit one above — the candidate version is prefilled with the agent's draft.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -1,103 +0,0 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<span class="text-3xl font-semibold tracking-tight">{{ agent.name }}</span>
|
||||
<span class="text-sm px-2.5 py-0.5 rounded bg-forge-surface2 text-forge-muted border border-forge-iron">v{{ agent.version }}</span>
|
||||
<span class="text-sm px-2.5 py-0.5 rounded {% if agent.state == 'active' %}bg-emerald-950 text-emerald-400 border border-emerald-900{% else %}bg-forge-surface2 text-forge-steel border border-forge-iron{% endif %}">
|
||||
{{ agent.state }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-forge-muted mt-2 text-[15px] leading-snug">
|
||||
{{ agent.purpose }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata principal -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<div class="text-forge-muted text-xs mb-1">Smith</div>
|
||||
<div class="font-medium">{{ agent.owner }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-forge-muted text-xs mb-1">Risk Threshold (Judgment)</div>
|
||||
<div class="font-medium">{{ agent.risk_threshold_for_hitl }}</div>
|
||||
</div>
|
||||
{% if agent.category %}
|
||||
<div>
|
||||
<div class="text-forge-muted text-xs mb-1">Blade Type</div>
|
||||
<div>{{ agent.category }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- LLM + Guardrails -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-forge-muted text-xs mb-1.5">LLM Steel</div>
|
||||
<div class="font-mono text-xs bg-black/40 border border-forge-iron p-3 rounded-xl">
|
||||
{{ agent.llm.provider }} / {{ agent.llm.model }}<br>
|
||||
<span class="text-forge-muted">temp={{ agent.llm.temperature }} · max={{ agent.llm.max_tokens }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-forge-muted text-xs mb-1.5">Guardrails (Forge Laws)</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{% for g in agent.guardrails %}
|
||||
<span class="text-xs px-3 py-1 bg-forge-surface2 border border-forge-iron rounded-full">{{ g }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Behavior (Prompt + Output Schema) -->
|
||||
<div>
|
||||
<div class="text-forge-muted text-xs mb-1.5">Edge & Temper</div>
|
||||
|
||||
<!-- System Prompt collapsed -->
|
||||
<details class="mb-3 group">
|
||||
<summary class="cursor-pointer select-none text-sm font-medium px-4 py-2 bg-forge-surface2 hover:bg-forge-surface border border-forge-iron rounded-xl flex items-center justify-between">
|
||||
<span>View blade oath</span>
|
||||
<span class="text-xs text-forge-muted group-open:hidden">show</span>
|
||||
<span class="text-xs text-forge-muted hidden group-open:inline">hide</span>
|
||||
</summary>
|
||||
<div class="mt-2 p-4 bg-black/60 border border-forge-iron rounded-xl text-sm whitespace-pre-wrap font-light leading-relaxed">
|
||||
{{ agent.system_prompt }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Output Schema -->
|
||||
<div>
|
||||
<div class="text-xs text-forge-muted mb-1.5">Output schema</div>
|
||||
<pre class="text-[10px] bg-black/40 border border-forge-iron p-3 rounded-xl overflow-auto max-h-48 text-forge-steel">{{ agent.output_schema | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional metadata -->
|
||||
{% if agent.input_label or agent.input_placeholder or agent.updated_at %}
|
||||
<div class="pt-2 border-t border-forge-iron">
|
||||
<div class="text-forge-muted text-xs mb-2">Anvil Marks</div>
|
||||
<div class="text-sm space-y-1 text-forge-steel">
|
||||
{% if agent.input_label %}
|
||||
<div><span class="text-forge-muted">Input label:</span> {{ agent.input_label }}</div>
|
||||
{% endif %}
|
||||
{% if agent.input_placeholder %}
|
||||
<div><span class="text-forge-muted">Example:</span> {{ agent.input_placeholder }}</div>
|
||||
{% endif %}
|
||||
<div><span class="text-forge-muted">Last forged:</span> {{ agent.updated_at.strftime('%Y-%m-%d %H:%M') if agent.updated_at else '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if agent.tags %}
|
||||
<div>
|
||||
<div class="text-forge-muted text-xs mb-1.5">Runes</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{% for tag in agent.tags %}
|
||||
<span class="text-xs px-3 py-0.5 bg-forge-surface2 border border-forge-iron rounded-full">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,11 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl">
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<a href="/agents" class="text-xs text-forge-muted hover:text-forge-ember">← Back to Arsenal</a>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{{ agent.name }} <span class="text-sm text-forge-muted">v{{ agent.version }}</span></h1>
|
||||
{% include "agent_detail.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,50 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div>
|
||||
<div class="flex items-baseline gap-3 mb-6">
|
||||
<span class="text-2xl text-forge-ember">⚔</span>
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Arsenal</h1>
|
||||
<span class="text-xs px-2 py-px bg-forge-surface2 border border-forge-iron rounded text-forge-muted">FORGED BLADES</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- List of blades -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="border border-forge-iron bg-forge-surface rounded-xl overflow-hidden">
|
||||
{% for a in agents %}
|
||||
<a href="/agents/{{ a.name }}"
|
||||
hx-get="/agents/{{ a.name }}"
|
||||
hx-target="#agent-detail"
|
||||
hx-swap="innerHTML"
|
||||
class="block px-4 py-3 border-b border-forge-iron last:border-b-0 hover:bg-forge-surface2 hover:border-l-2 hover:border-forge-ember cursor-pointer transition">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl flex-shrink-0"
|
||||
{% if a.color %}style="color: {{ a.color }}"{% endif %}>
|
||||
{{ a.icon or "◆" }}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ a.name }}</div>
|
||||
<div class="text-xs text-forge-muted">v{{ a.version }} · {{ a.state }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="px-4 py-6 text-forge-muted">No forged blades in the Arsenal yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail / inspection area -->
|
||||
<div class="lg:col-span-2">
|
||||
<div id="agent-detail" class="border border-forge-iron bg-forge-surface rounded-xl p-6 h-full">
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-forge-muted text-sm text-center max-w-[260px]">
|
||||
<span class="text-forge-ember">🛡</span> Select a blade from the Arsenal to inspect its temper, purpose and edge.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,51 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="text-forge-gold text-xl">⚖</span>
|
||||
<span class="text-forge-ember text-sm tracking-[2px]">JUDGMENT CHAMBER</span>
|
||||
</div>
|
||||
<h1 class="text-3xl font-semibold tracking-tight mb-2">The Judgment</h1>
|
||||
<p class="text-sm text-forge-muted mb-6 max-w-xl">High-risk forgings pause here to be tempered or shattered. Approve or reject. The anvil waits.</p>
|
||||
|
||||
<div class="space-y-4" id="approvals-list">
|
||||
{% for ex in pending %}
|
||||
<div class="forge-card border border-amber-900/60 rounded-2xl p-5">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="font-medium">{{ ex.agent_name }} <span class="text-forge-muted">v{{ ex.agent_version }}</span></div>
|
||||
<div class="text-xs text-forge-muted font-mono mt-0.5">{{ ex.trace_id }}</div>
|
||||
</div>
|
||||
<div class="text-amber-400 text-xs font-mono tracking-widest flex items-center gap-1">🛡️ AWAITING TEMPERING</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button
|
||||
hx-post="/api/executions/{{ ex.trace_id }}/approve"
|
||||
hx-vals='{"approved_action_ids": [], "comment": "Approved from UI"}'
|
||||
hx-target="#approvals-list"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="setTimeout(() => location.reload(), 400)"
|
||||
class="px-4 py-1.5 text-xs font-medium bg-emerald-600 hover:bg-emerald-500 text-white rounded transition">
|
||||
🛡 BLESS THE BLADE
|
||||
</button>
|
||||
<button
|
||||
hx-post="/api/executions/{{ ex.trace_id }}/reject"
|
||||
hx-vals='{"reason": "Rejected from web UI"}'
|
||||
hx-target="#approvals-list"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="setTimeout(() => location.reload(), 400)"
|
||||
class="px-4 py-1.5 text-xs font-medium bg-red-700 hover:bg-red-600 text-white rounded transition">
|
||||
⚔ SHATTER THE BLADE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-8 text-center text-forge-muted">
|
||||
<span class="text-forge-gold">🛡️</span> No forgings currently await judgment. The Forge is quiet.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,61 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<span class="text-3xl">⚔</span>
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold tracking-tight">The Armory</h1>
|
||||
<p class="text-forge-muted text-sm">The Hall of Tempered Blades — every successful forging you have completed.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if tempered %}
|
||||
<div class="space-y-4">
|
||||
{% for ex in tempered %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-5">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="font-semibold text-lg">{{ ex.agent_name }} <span class="text-sm text-forge-muted">v{{ ex.agent_version }}</span></div>
|
||||
<div class="text-xs text-forge-muted font-mono mt-0.5">{{ ex.trace_id }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-emerald-400 text-xs font-mono tracking-widest">TEMPERED</div>
|
||||
<div class="text-xs text-forge-muted mt-1">
|
||||
{{ ex.finished_at.strftime('%Y-%m-%d %H:%M') if ex.finished_at else ex.started_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-6 text-sm">
|
||||
<div>
|
||||
<span class="text-forge-muted text-xs">ACTIONS</span><br>
|
||||
<span class="font-semibold text-forge-gold">{{ ex.n_proposed_actions }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-forge-muted text-xs">VIOLATIONS</span><br>
|
||||
<span class="font-semibold {% if ex.n_violations > 0 %}text-amber-400{% else %}text-emerald-400{% endif %}">{{ ex.n_violations }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs">
|
||||
<a href="/api/executions/{{ ex.trace_id }}" target="_blank"
|
||||
class="text-forge-ember hover:underline">View full forging record →</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-12 text-center">
|
||||
<div class="text-forge-gold text-4xl mb-4">🛡️</div>
|
||||
<div class="text-xl font-semibold mb-2">No tempered blades yet</div>
|
||||
<p class="text-forge-muted max-w-xs mx-auto">
|
||||
Successfully complete a forging on the Anvil and it will appear here as a proven, tempered blade.
|
||||
</p>
|
||||
<a href="/run" class="inline-block mt-6 text-sm text-forge-ember hover:underline">Go to the Anvil →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,45 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>The Forge • {{ title or "The Forge" }}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
// Forge Tailwind config (gamified blacksmith aesthetic)
|
||||
function initializeForgeTheme() {
|
||||
if (window.tailwind && window.tailwind.config) {
|
||||
window.tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'forge': {
|
||||
'bg': '#0c0a09',
|
||||
'surface': '#171513',
|
||||
'surface2': '#1f1c18',
|
||||
'iron': '#57534e',
|
||||
'ember': '#f97316',
|
||||
'gold': '#fcd34d',
|
||||
'steel': '#94a3b8',
|
||||
'text': '#e7e5e4',
|
||||
'muted': '#78716c',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
window.onload = initializeForgeTheme;
|
||||
</script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script>
|
||||
<title>Forja • {{ title or "Forja" }}</title>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator { display: inline; }
|
||||
.htmx-request.htmx-indicator { display: inline; }
|
||||
|
||||
/* Forge gamified theme - blacksmith forge with embers */
|
||||
:root {
|
||||
--forge-bg: #1c1c1c;
|
||||
--forge-surface: #151515;
|
||||
@@ -51,6 +16,41 @@
|
||||
--forge-text: #e7e5e4;
|
||||
--forge-muted: #78716c;
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
/* Must run right after the CDN script (before the body is parsed) so the
|
||||
custom palette applies to the first paint. Single source of truth:
|
||||
the CSS variables above. */
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'forge': {
|
||||
'bg': 'var(--forge-bg)',
|
||||
'surface': 'var(--forge-surface)',
|
||||
'surface2': 'var(--forge-surface2)',
|
||||
'iron': 'var(--forge-iron)',
|
||||
'ember': 'var(--forge-ember)',
|
||||
'gold': 'var(--forge-gold)',
|
||||
'steel': 'var(--forge-steel)',
|
||||
'text': 'var(--forge-text)',
|
||||
'muted': 'var(--forge-muted)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js"></script>
|
||||
<!-- Encode HTMX requests as JSON so buttons/forms can target the /api endpoints. -->
|
||||
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.2/json-enc.js"></script>
|
||||
<style>
|
||||
html { scroll-behavior: smooth; scroll-padding-top: 5rem; }
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator { display: inline; }
|
||||
.htmx-request.htmx-indicator { display: inline; }
|
||||
|
||||
body {
|
||||
background-color: var(--forge-bg);
|
||||
@@ -66,69 +66,30 @@
|
||||
background-color: var(--forge-surface);
|
||||
border-color: var(--forge-iron);
|
||||
}
|
||||
|
||||
.forge-ember { color: var(--forge-ember); }
|
||||
.forge-gold { color: var(--forge-gold); }
|
||||
.forge-steel { color: var(--forge-steel); }
|
||||
|
||||
/* Subtle ember glow on interactive cards */
|
||||
.forge-glow:hover {
|
||||
box-shadow: 0 0 0 1px var(--forge-ember), 0 10px 15px -3px rgb(0 0 0 / 0.3);
|
||||
border-color: var(--forge-ember);
|
||||
}
|
||||
|
||||
.forge-fire {
|
||||
animation: ember-pulse 2.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ember-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.75; }
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-h-screen">
|
||||
<!-- Forge Header: gamified persistent nav -->
|
||||
<header class="forge-header border-b sticky top-0 z-50">
|
||||
<div class="max-w-6xl mx-auto px-6 h-14 flex items-center justify-between">
|
||||
<a href="/" class="flex items-baseline gap-2 hover:text-forge-ember transition">
|
||||
<span class="font-semibold tracking-[3px] text-lg">THE FORGE</span>
|
||||
<span class="text-[10px] text-forge-muted tracking-[1px] font-normal">Fire • Hammer • Judgment</span>
|
||||
<div class="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
|
||||
<a href="/" class="flex flex-col hover:text-forge-ember transition">
|
||||
<span class="font-semibold tracking-wide text-lg">Forja</span>
|
||||
<span class="text-[10px] text-forge-muted">Governed models and agents</span>
|
||||
</a>
|
||||
<nav class="flex items-center gap-x-6 text-sm">
|
||||
<a href="/agents" class="hover:text-forge-ember transition">Arsenal</a>
|
||||
<a href="/run" class="hover:text-forge-ember transition">The Anvil</a>
|
||||
<a href="/approvals" class="hover:text-forge-ember transition">The Judgment</a>
|
||||
<nav class="flex items-center gap-x-5 text-sm">
|
||||
<a href="/#define" class="hover:text-forge-ember transition">Define</a>
|
||||
<a href="/#run" class="hover:text-forge-ember transition">Run</a>
|
||||
<a href="/#approve" class="hover:text-forge-ember transition">Approve</a>
|
||||
<a href="/#train" class="hover:text-forge-ember transition">Train</a>
|
||||
<a href="/#promote" class="hover:text-forge-ember transition">Promote</a>
|
||||
<a href="/#audit" class="hover:text-forge-ember transition">Audit</a>
|
||||
</nav>
|
||||
<div class="hidden md:flex items-center gap-x-4 text-[10px] font-mono text-forge-muted">
|
||||
<div class="flex items-center gap-1.5 text-forge-gold">
|
||||
<span>⚒</span>
|
||||
<span class="tracking-wider">FORGE MASTER</span>
|
||||
</div>
|
||||
<div>LV <span class="text-forge-gold font-semibold">12</span></div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span>HEAT</span>
|
||||
<div class="w-8 h-1.5 bg-forge-iron rounded overflow-hidden">
|
||||
<div class="h-full bg-forge-ember w-[87%]"></div>
|
||||
</div>
|
||||
<span class="text-forge-ember font-semibold">87%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-6xl mx-auto px-6 py-8 text-forge-text">
|
||||
<main class="max-w-5xl mx-auto px-6 py-8 text-forge-text">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Floating Tutorial Button -->
|
||||
{% if not hide_tutorial_button %}
|
||||
<a href="/tutorial"
|
||||
class="fixed bottom-6 right-6 z-50 flex items-center justify-center px-5 py-2.5 bg-forge-surface border border-forge-iron text-forge-steel hover:text-forge-ember hover:border-forge-ember rounded-2xl shadow-lg transition text-sm">
|
||||
<span>Tutorial</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<span class="text-3xl">⚔</span>
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold tracking-tight">The Battle</h1>
|
||||
<p class="text-forge-muted">Where tempered agents finally face the real world in The Battle. Send them to Azure, AWS, GCP or on-prem.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-6">
|
||||
<div class="font-semibold text-lg mb-2">☁️ Azure</div>
|
||||
<div class="text-sm text-forge-muted">Deploy as Azure Functions, Container Apps, or AKS with full governance baked in.</div>
|
||||
<div class="mt-4 text-xs text-forge-ember">Coming soon</div>
|
||||
</div>
|
||||
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-6">
|
||||
<div class="font-semibold text-lg mb-2">🟠 AWS</div>
|
||||
<div class="text-sm text-forge-muted">Lambda, ECS, EKS or SageMaker endpoints — with the same guardrails and audit trail.</div>
|
||||
<div class="mt-4 text-xs text-forge-ember">Coming soon</div>
|
||||
</div>
|
||||
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-6">
|
||||
<div class="font-semibold text-lg mb-2">🔵 Google Cloud</div>
|
||||
<div class="text-sm text-forge-muted">Cloud Run, GKE or Vertex AI with complete decision traceability.</div>
|
||||
<div class="mt-4 text-xs text-forge-ember">Coming soon</div>
|
||||
</div>
|
||||
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-6">
|
||||
<div class="font-semibold text-lg mb-2">🏠 On-Prem / Self-hosted</div>
|
||||
<div class="text-sm text-forge-muted">Docker, Kubernetes, or bare metal — the Forge travels with you.</div>
|
||||
<div class="mt-4 text-xs text-forge-ember">Coming soon</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-10 text-sm text-forge-muted border-t border-forge-iron pt-6">
|
||||
A successfully tempered blade only becomes truly valuable once it enters <strong>The Battle</strong>.
|
||||
This is where your agents (with their policies, versions and full audit history) are sent into real action on Azure, AWS, GCP or on-prem — carrying the strength they earned in the Forge.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,40 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-[1100px] mx-auto pt-4">
|
||||
|
||||
<div class="pt-6 mb-8 text-left">
|
||||
<p class="text-3xl font-medium text-forge-text tracking-tight">Define at The Hearth. Prove in The Crucible. Win in The Battle.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
<!-- Station 1: Definition -->
|
||||
<a href="/agents" class="forge-card forge-glow border-2 border-forge-iron rounded-2xl p-6 group transition block hover:border-forge-ember hover:shadow-[0_0_0_1px_#f97316]">
|
||||
<div class="text-forge-ember text-2xl font-semibold tracking-tight mb-2">The Hearth</div>
|
||||
<p class="text-sm text-forge-muted">Define your agents: prompts, models, output schemas, risk thresholds and guardrails.</p>
|
||||
</a>
|
||||
|
||||
<!-- Station 2: Testing & Proving -->
|
||||
<a href="/run" class="forge-card forge-glow border-2 border-forge-iron rounded-2xl p-6 group transition block hover:border-forge-ember hover:shadow-[0_0_0_1px_#f97316]">
|
||||
<div class="text-forge-ember text-2xl font-semibold tracking-tight mb-2">The Crucible</div>
|
||||
<p class="text-sm text-forge-muted">Put agents to the test. Run them with guardrails, handle Human-in-the-Loop decisions, and review successful runs.</p>
|
||||
</a>
|
||||
|
||||
<!-- Station 3: The Battle (Deployment) -->
|
||||
<a href="/deploy" class="forge-card forge-glow border-2 border-forge-iron rounded-2xl p-6 group transition block hover:border-forge-ember hover:shadow-[0_0_0_1px_#f97316]">
|
||||
<div class="text-forge-ember text-2xl font-semibold tracking-tight mb-2">The Battle</div>
|
||||
<p class="text-sm text-forge-muted">Deploy your proven agents to Azure, AWS, GCP or on-prem, ready for real action.</p>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Forge Creed -->
|
||||
<div class="mt-8 border-t border-forge-iron pt-8 text-sm max-w-prose">
|
||||
<div class="uppercase text-xs tracking-[2px] text-forge-text mb-2">The Forge Creed</div>
|
||||
<p class="text-forge-muted leading-relaxed">
|
||||
The risk threshold is the critical temper point. If the blade glows white-hot, The Judgment must intervene.
|
||||
Nothing leaves the Forge without passing through fire and hammer.
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,223 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% macro stage_header(number, anchor, name, description) %}
|
||||
<div class="flex items-center gap-3 mb-1 pt-2" id="{{ anchor }}">
|
||||
<span class="font-mono text-xs px-2 py-px border border-forge-ember/60 text-forge-ember rounded bg-forge-surface2">{{ number }}</span>
|
||||
<h2 class="text-2xl font-semibold tracking-tight">{{ name }}</h2>
|
||||
</div>
|
||||
<p class="text-sm text-forge-muted mb-4 max-w-2xl">{{ description }}</p>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pb-16">
|
||||
|
||||
<div class="mb-10">
|
||||
<h1 class="text-3xl font-medium tracking-tight">The governed agent lifecycle, on one page.</h1>
|
||||
<p class="text-sm text-forge-muted mt-2 max-w-2xl">
|
||||
Six stages, top to bottom. Every action below is a click — run the demo incident,
|
||||
approve it, train the draft candidate, evaluate it, and promote it to active.
|
||||
Each panel refreshes itself after every action.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ============================ 01 DEFINE ============================ -->
|
||||
{{ stage_header("01", "define", "Define",
|
||||
"Agents and the guardrail policies they run under — all versioned YAML.
|
||||
The draft version below is the candidate that the rest of this page trains,
|
||||
evaluates, and promotes.") }}
|
||||
|
||||
<div id="agents-panel"
|
||||
hx-get="/fragments/agents"
|
||||
hx-trigger="forja-refresh from:body"
|
||||
hx-swap="innerHTML">
|
||||
{% include "_agents.html" %}
|
||||
</div>
|
||||
|
||||
<!-- ============================ 02 RUN =============================== -->
|
||||
<div class="mt-12"></div>
|
||||
{{ stage_header("02", "run", "Run",
|
||||
"Execute the active agent through the governed pipeline: input guardrails →
|
||||
LLM → output guardrails → proposed actions → approval gate. The demo incident
|
||||
proposes a risk-5 action, so the run pauses for human approval in stage 03.") }}
|
||||
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-6">
|
||||
<div class="flex flex-wrap items-center gap-3 mb-5 pb-5 border-b border-forge-iron">
|
||||
<button
|
||||
hx-post="/run"
|
||||
hx-vals='{"agent_name": "incident_analyzer", "input": "{{ demo_input }}"}'
|
||||
hx-target="#run-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="htmx.trigger(document.body, 'forja-refresh')"
|
||||
class="px-5 py-2.5 bg-forge-ember text-black font-semibold rounded-lg text-sm hover:brightness-105 active:scale-[0.985] transition">
|
||||
▶ Run demo incident
|
||||
<span class="htmx-indicator">⋯</span>
|
||||
</button>
|
||||
<span class="text-xs text-forge-muted">One click — sends a replicated HSS capacity alarm and pauses at the approval gate.</span>
|
||||
</div>
|
||||
|
||||
<form hx-post="/run" hx-target="#run-result" hx-swap="innerHTML"
|
||||
hx-on::after-request="htmx.trigger(document.body, 'forja-refresh')"
|
||||
class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-xs tracking-widest text-forge-muted mb-1.5 uppercase">Agent</label>
|
||||
<select name="agent_name" id="agent-select"
|
||||
class="w-full bg-black/50 border border-forge-iron rounded-lg px-3 py-2.5 text-sm focus:border-forge-ember outline-none">
|
||||
{% for a in agents %}
|
||||
<option value="{{ a.name }}"
|
||||
data-label="{{ a.input_label or 'Input' }}"
|
||||
data-placeholder="{{ a.input_placeholder or '' }}">
|
||||
{{ a.name }} (v{{ a.version }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label id="input-label" class="block text-xs tracking-widest text-forge-muted mb-1.5 uppercase">Input</label>
|
||||
<textarea name="input" id="input-text" rows="3"
|
||||
class="w-full bg-black/50 border border-forge-iron rounded-lg px-3 py-2 font-mono text-sm focus:border-forge-ember outline-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-5 py-2 bg-forge-surface2 border border-forge-iron hover:border-forge-ember rounded-lg text-sm transition">
|
||||
Run with custom input
|
||||
<span class="htmx-indicator">⋯</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="run-result" class="mt-4"></div>
|
||||
|
||||
<!-- ============================ 03 APPROVE =========================== -->
|
||||
<div class="mt-12"></div>
|
||||
{{ stage_header("03", "approve", "Approve",
|
||||
"Runs paused by the approval gate (HITL). The state lives in a checkpoint on
|
||||
disk, so it survives restarts. Approving resumes the graph exactly where it
|
||||
stopped; the completed run lands in stage 06.") }}
|
||||
|
||||
<div id="approvals-panel"
|
||||
hx-get="/fragments/approvals"
|
||||
hx-trigger="forja-refresh from:body"
|
||||
hx-swap="innerHTML">
|
||||
{% include "_approvals.html" %}
|
||||
</div>
|
||||
|
||||
<!-- ============================ 04 TRAIN ============================= -->
|
||||
<div class="mt-12"></div>
|
||||
{{ stage_header("04", "train", "Train & evaluate",
|
||||
"Submit a fine-tuning job for the draft candidate to the configured MLOps
|
||||
backend, then evaluate it over the agent's canonical scenarios through the
|
||||
full governed runtime. A passing evaluation unlocks promotion.") }}
|
||||
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-6 mb-4">
|
||||
<form hx-post="/api/training/runs"
|
||||
hx-ext="json-enc"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger(document.body, 'forja-refresh');
|
||||
this.querySelector('.form-error').textContent =
|
||||
event.detail.successful ? '' : 'Request failed: ' + event.detail.xhr.responseText.slice(0, 200)"
|
||||
class="grid grid-cols-2 sm:grid-cols-4 gap-4 items-end">
|
||||
<div>
|
||||
<label class="block text-xs tracking-widest text-forge-muted mb-1.5 uppercase">Dataset</label>
|
||||
<select name="dataset_name" class="w-full bg-black/50 border border-forge-iron rounded-lg px-3 py-2 text-sm focus:border-forge-ember outline-none">
|
||||
{% for d in datasets %}
|
||||
<option value="{{ d.name }}">{{ d.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs tracking-widest text-forge-muted mb-1.5 uppercase">Base model</label>
|
||||
<select name="model_name" class="w-full bg-black/50 border border-forge-iron rounded-lg px-3 py-2 text-sm focus:border-forge-ember outline-none">
|
||||
{% for m in models %}
|
||||
<option value="{{ m.name }}">{{ m.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs tracking-widest text-forge-muted mb-1.5 uppercase">Agent</label>
|
||||
<select name="agent_name" id="train-agent-select"
|
||||
class="w-full bg-black/50 border border-forge-iron rounded-lg px-3 py-2 text-sm focus:border-forge-ember outline-none">
|
||||
{% for card in agent_cards %}
|
||||
{% set draft = card.versions | selectattr('state', 'equalto', 'draft') | map(attribute='id') | first %}
|
||||
<option value="{{ card.agent.name }}" data-draft="{{ draft or '' }}">{{ card.agent.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs tracking-widest text-forge-muted mb-1.5 uppercase">Candidate version</label>
|
||||
<input name="agent_version" id="candidate-version" type="text"
|
||||
class="w-full bg-black/50 border border-forge-iron rounded-lg px-3 py-2 text-sm font-mono focus:border-forge-ember outline-none">
|
||||
</div>
|
||||
<div class="col-span-2 sm:col-span-4 flex items-center gap-4">
|
||||
<button type="submit"
|
||||
class="px-5 py-2 bg-forge-ember text-black font-semibold rounded-lg text-sm hover:brightness-105 active:scale-[0.985] transition">
|
||||
Submit training run
|
||||
</button>
|
||||
<span class="text-xs text-forge-muted">backend: <span class="font-mono">{{ backend }}</span> — the mock backend completes instantly.</span>
|
||||
<span class="form-error text-xs text-red-400"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="training-panel"
|
||||
hx-get="/fragments/training"
|
||||
hx-trigger="forja-refresh from:body"
|
||||
hx-swap="innerHTML">
|
||||
{% include "_training.html" %}
|
||||
</div>
|
||||
|
||||
<!-- ============================ 05 PROMOTE =========================== -->
|
||||
<div class="mt-12"></div>
|
||||
{{ stage_header("05", "promote", "Promote",
|
||||
"Governance queue. A promotion requires a succeeded training run and a passing
|
||||
evaluation of the exact candidate version. Approving flips the candidate to
|
||||
active in the registry — check stage 01 after approving.") }}
|
||||
|
||||
<div id="promotions-panel"
|
||||
hx-get="/fragments/promotions"
|
||||
hx-trigger="forja-refresh from:body"
|
||||
hx-swap="innerHTML">
|
||||
{% include "_promotions.html" %}
|
||||
</div>
|
||||
|
||||
<!-- ============================ 06 AUDIT ============================= -->
|
||||
<div class="mt-12"></div>
|
||||
{{ stage_header("06", "audit", "Audit",
|
||||
"Completed runs, persisted append-only with their full decision trail,
|
||||
violations, and approved actions.") }}
|
||||
|
||||
<div id="history-panel"
|
||||
hx-get="/fragments/history"
|
||||
hx-trigger="forja-refresh from:body"
|
||||
hx-swap="innerHTML">
|
||||
{% include "_history.html" %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* Run form: apply the selected agent's input label and placeholder (from its YAML). */
|
||||
const agentSelect = document.getElementById("agent-select");
|
||||
const inputLabel = document.getElementById("input-label");
|
||||
const inputText = document.getElementById("input-text");
|
||||
function syncAgentHints() {
|
||||
const opt = agentSelect.selectedOptions[0];
|
||||
if (!opt) return;
|
||||
inputLabel.textContent = opt.dataset.label || "Input";
|
||||
inputText.placeholder = opt.dataset.placeholder || "";
|
||||
}
|
||||
agentSelect.addEventListener("change", syncAgentHints);
|
||||
syncAgentHints();
|
||||
|
||||
/* Train form: prefill the candidate version with the agent's first draft version. */
|
||||
const trainAgentSelect = document.getElementById("train-agent-select");
|
||||
const candidateVersion = document.getElementById("candidate-version");
|
||||
function syncCandidateVersion() {
|
||||
const opt = trainAgentSelect.selectedOptions[0];
|
||||
if (!opt) return;
|
||||
candidateVersion.value = opt.dataset.draft || "";
|
||||
}
|
||||
trainAgentSelect.addEventListener("change", syncCandidateVersion);
|
||||
syncCandidateVersion();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,38 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<span class="text-forge-gold text-2xl">⚒</span>
|
||||
<h1 class="text-3xl font-semibold tracking-tight">The Anvil</h1>
|
||||
</div>
|
||||
<p class="text-forge-muted mb-6">Choose a blade, dictate the task and strike. The Forge will execute with fire and rules.</p>
|
||||
|
||||
<div class="forge-card border rounded-2xl p-6">
|
||||
<form hx-post="/run" hx-target="#result" hx-swap="innerHTML" class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-xs tracking-widest text-forge-gold mb-1.5">🗡️ BLADE TO FORGE</label>
|
||||
<select name="agent_name" class="w-full bg-black/50 border border-forge-iron rounded-lg px-3 py-2.5 text-sm focus:border-forge-ember outline-none">
|
||||
{% for a in agents %}
|
||||
<option value="{{ a.name }}">{{ a.name }} (v{{ a.version }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs tracking-widest text-forge-gold mb-1.5">🔥 THE STEEL TO STRIKE (SCENARIO / INPUT)</label>
|
||||
<textarea name="input" rows="4" class="w-full bg-black/50 border border-forge-iron rounded-lg px-3 py-2 font-mono text-sm focus:border-forge-ember outline-none"
|
||||
placeholder='{"scenario": "01_sip_registration_drop"}'>{"scenario": "01_sip_registration_drop"}</textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full sm:w-auto px-6 py-2.5 bg-forge-ember text-black font-semibold rounded-lg text-sm flex items-center justify-center gap-2 hover:brightness-105 active:scale-[0.985] transition">
|
||||
<span>⚒ STRIKE THE ANVIL</span>
|
||||
<span class="htmx-indicator">⋯</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="result" class="mt-6"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold tracking-tight">Execution result</span>
|
||||
<span class="text-[10px] px-2 py-px rounded border font-mono {{ status_badge_style }} {{ status_text_color }}">{{ status_label }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-forge-muted font-mono">trace {{ execution.trace_id }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="uppercase text-xs tracking-[1.5px] text-forge-muted mb-1">Decision path</div>
|
||||
<div class="font-mono text-[10px] bg-black/60 border border-forge-iron rounded p-2.5 space-y-px">
|
||||
{% for step in execution.decision_path %}
|
||||
<div class="flex justify-between text-xs py-0.5 border-b border-forge-iron/60">
|
||||
<span class="font-mono text-forge-text">{{ step.step }}</span>
|
||||
<span class="text-forge-muted">
|
||||
{%- if step.duration_ms %}{{ step.duration_ms }}ms{% endif -%}
|
||||
{%- if step.detail.get("n_approved") is not none %} · {{ step.detail["n_approved"] }} approved{% endif -%}
|
||||
{%- if step.detail.get("rejected") %} · rejected{% endif -%}
|
||||
{%- if step.detail.get("hitl") %} · HITL{% endif -%}
|
||||
{%- if step.detail.get("n_violations") %} · {{ step.detail["n_violations"] }} violation(s){% endif -%}
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-forge-muted">—</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if execution.violations %}
|
||||
<div class="mt-4">
|
||||
<div class="uppercase text-xs tracking-[1.5px] text-red-400 mb-1">Violations</div>
|
||||
{% for v in execution.violations %}
|
||||
<div class="text-xs {{ 'text-red-400' if v.severity == 'block' else 'text-amber-400' }}">
|
||||
• {{ v.validator }}: {{ v.message }} ({{ v.severity }})
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if execution.proposed_actions %}
|
||||
<div class="mt-4">
|
||||
<div class="uppercase text-xs tracking-[1.5px] text-amber-400 mb-1">Proposed actions</div>
|
||||
<div class="space-y-1">
|
||||
{% for a in execution.proposed_actions %}
|
||||
<div class="text-xs bg-black/50 border border-forge-iron rounded p-2">
|
||||
• {{ a.action }} → <span class="font-mono">{{ a.target }}</span>
|
||||
<span class="font-mono {{ 'text-emerald-400' if a.risk_score < 3 else ('text-amber-400' if a.risk_score < 5 else 'text-red-400') }}">(risk={{ a.risk_score }})</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if execution.needs_human_for %}
|
||||
<div class="mt-3 p-3 border border-amber-900 bg-amber-950/40 rounded text-amber-300 text-sm">
|
||||
{{ execution.needs_human_for | length }} action(s) require approval.
|
||||
<a href="#approve" class="underline hover:text-amber-200">Go to stage 03 — Approve ↓</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if execution.status == "completed" %}
|
||||
<div class="mt-4 text-emerald-400 text-sm">
|
||||
Run finished successfully. {{ execution.proposed_actions | length }} proposed action(s).
|
||||
<a href="#audit" class="underline">See it in stage 06 — Audit ↓</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,90 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto pb-12">
|
||||
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-forge-ember text-2xl">✧</span>
|
||||
<h1 class="text-4xl font-semibold tracking-tighter">Forge Tutorial</h1>
|
||||
</div>
|
||||
<p class="text-forge-muted">Learn the ancient craft of forging governed agents — step by step, strike by strike.</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="mb-10">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="font-mono text-xs px-2 py-px border border-forge-iron rounded bg-forge-surface2">01</span>
|
||||
<span class="uppercase tracking-[2px] text-xs text-forge-ember">STATION I — THE HEARTH</span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">Visit the Arsenal</h3>
|
||||
<p class="text-sm text-forge-muted mb-3">
|
||||
Every agent in the Forge is a blade that has already been tempered. Inspect existing blades to understand their purpose, risk threshold, and the laws (guardrails) that bind them.
|
||||
</p>
|
||||
<a href="/agents" class="inline-block text-sm text-forge-ember hover:underline">Open the Arsenal →</a>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="mb-10">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="font-mono text-xs px-2 py-px border border-forge-iron rounded bg-forge-surface2">02</span>
|
||||
<span class="uppercase tracking-[2px] text-xs text-forge-ember">STATION II — THE ANVIL</span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">Strike at the Anvil</h3>
|
||||
<p class="text-sm text-forge-muted mb-3">
|
||||
This is where the real work happens. Choose a forged blade, give it a concrete task (the "steel"), and strike. The Forge will run the full pipeline: input guardrails → LLM reasoning → output guardrails → action proposal.
|
||||
</p>
|
||||
<div class="text-sm text-forge-muted mb-3">
|
||||
Try a real example. Use the incident analyzer blade with a short network incident:
|
||||
</div>
|
||||
|
||||
<!-- Live practice strike form (reuses the real /run endpoint) -->
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-5 mb-4">
|
||||
<form hx-post="/run" hx-target="#tutorial-result" hx-swap="innerHTML" class="space-y-4">
|
||||
<input type="hidden" name="agent_name" value="incident_analyzer">
|
||||
<div>
|
||||
<label class="block text-xs tracking-widest text-forge-muted mb-1.5">TASK (the steel you strike)</label>
|
||||
<textarea name="input" rows="3" class="w-full bg-black/50 border border-forge-iron rounded-lg px-3 py-2 font-mono text-sm focus:border-forge-ember outline-none">Network incident: sudden 80% drop in SIP registrations after a recent image promotion. Need root cause hypothesis and rollback plan if required.</textarea>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-5 py-2 bg-forge-ember text-black font-semibold rounded-lg text-sm hover:brightness-105 active:scale-[0.985] transition">
|
||||
STRIKE THE ANVIL (LIVE)
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="tutorial-result" class="mb-4"></div>
|
||||
<p class="text-xs text-forge-muted">The result below will show the real Strike Sequence, Flaws, and Proposed Edges — exactly like a production forging.</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="mb-10">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="font-mono text-xs px-2 py-px border border-forge-iron rounded bg-forge-surface2">03</span>
|
||||
<span class="uppercase tracking-[2px] text-xs text-forge-ember">STATION III — THE JUDGMENT</span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">Face the Judgment (HITL)</h3>
|
||||
<p class="text-sm text-forge-muted mb-3">
|
||||
When an action's risk score crosses the blade's threshold, the forging pauses. A human (you) must bless or shatter the proposed edges before the agent can continue. This is the heart of governed autonomy.
|
||||
</p>
|
||||
<a href="/approvals" class="inline-block text-sm text-forge-ember hover:underline">Enter the Judgment Chamber →</a>
|
||||
</div>
|
||||
|
||||
<!-- Step 4 -->
|
||||
<div class="mb-10">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<span class="font-mono text-xs px-2 py-px border border-forge-iron rounded bg-forge-surface2">04</span>
|
||||
<span class="uppercase tracking-[2px] text-xs text-forge-ember">MASTERY</span>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold mb-2">Repeat and Improve</h3>
|
||||
<p class="text-sm text-forge-muted">
|
||||
Every successful forging increases the overall heat of the Forge. Review history (when available), tune risk thresholds on your blades, and strengthen guardrails. True mastery comes from deliberate, observed repetition.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-forge-iron pt-8 text-sm text-forge-muted">
|
||||
The Forge does not forgive sloppy steel. The risk threshold is the critical temper point. If the blade glows white-hot, The Judgment must intervene.
|
||||
Nothing leaves the Forge without passing through fire and hammer.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
+236
-175
@@ -1,87 +1,175 @@
|
||||
"""HTMX UI router — mounted inside forja-core (Option A)."""
|
||||
"""HTMX UI router — single-page lifecycle console mounted inside forja-core.
|
||||
|
||||
The UI is one page (``/``) organised as the six stages of the governed agent
|
||||
lifecycle: Define → Run → Approve → Train → Promote → Audit. Each stage is a
|
||||
fragment that re-fetches itself when any action button fires the global
|
||||
``forja-refresh`` event, so the whole cycle can be driven with clicks only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from forja_core.api.deps import get_orchestrator, get_policy_store, get_registry, get_settings
|
||||
from forja_core.api.executions import _record_execution
|
||||
from forja_core.api.deps import (
|
||||
get_dataset_store,
|
||||
get_model_store,
|
||||
get_orchestrator,
|
||||
get_policy_store,
|
||||
get_registry,
|
||||
get_settings,
|
||||
)
|
||||
from forja_core.api.evaluation_persistence import load_evaluation, load_evaluation_for_run
|
||||
from forja_core.api.executions import _load_index, _record_execution
|
||||
from forja_core.api.persistence import append_execution, append_violation, read_execution_summaries
|
||||
from forja_core.api.promotion_persistence import list_promotion_requests
|
||||
from forja_core.api.training_persistence import list_training_runs
|
||||
from forja_core.domain.execution import AgentExecution
|
||||
|
||||
router = APIRouter(prefix="", tags=["ui"])
|
||||
|
||||
# Ruta robusta a los templates (funciona tanto en dev como dentro del contenedor)
|
||||
# Template path works in dev and inside the container.
|
||||
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
|
||||
SLOGANS = [
|
||||
"Every strike, a tempered decision",
|
||||
"Blades forged, not guessed",
|
||||
"Fire. Hammer. Judgment.",
|
||||
"The anvil never lies",
|
||||
"High risk demands the Judgment",
|
||||
"True steel leaves the Forge",
|
||||
]
|
||||
_STATUS_BADGES = {
|
||||
"completed": ("text-emerald-400", "bg-emerald-950/60 border-emerald-900", "Completed"),
|
||||
"awaiting_approval": ("text-amber-400", "bg-amber-950/60 border-amber-900", "Pending approval"),
|
||||
"failed": ("text-red-400", "bg-red-950/60 border-red-900", "Failed"),
|
||||
"blocked_by_guardrail": ("text-red-400", "bg-red-950/60 border-red-900", "Blocked by policy"),
|
||||
"running": ("text-forge-steel", "bg-forge-surface2 border-forge-iron", "Running"),
|
||||
}
|
||||
|
||||
def get_slogan() -> str:
|
||||
import random
|
||||
return random.choice(SLOGANS)
|
||||
# Demo input for the one-click walkthrough: "hss" maps to a risk-5 action in the
|
||||
# mock provider, so the run pauses in HITL and exercises the approval stage.
|
||||
DEMO_INPUT = (
|
||||
"Replicated capacity alarm on the HSS active-active pair in Madrid; "
|
||||
"subscriber database replication lag is growing."
|
||||
)
|
||||
|
||||
|
||||
# --- Context builders (shared by the index page and the fragments) ---------------------
|
||||
|
||||
|
||||
def _define_ctx() -> dict[str, Any]:
|
||||
"""Agents with all their versions/states, plus the guardrail policies."""
|
||||
registry = get_registry()
|
||||
cards = []
|
||||
for agent in registry.list_agents():
|
||||
versions = []
|
||||
for meta in registry.list_versions(agent.name):
|
||||
try:
|
||||
version_def = registry.get_version(agent.name, meta.id)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
versions.append({"id": meta.id, "state": version_def.state, "message": meta.message})
|
||||
cards.append({"agent": agent, "versions": versions})
|
||||
return {"agent_cards": cards, "policies": get_policy_store().list_policies()}
|
||||
|
||||
|
||||
async def _approvals_ctx() -> dict[str, Any]:
|
||||
return {"pending": await _pending_executions()}
|
||||
|
||||
|
||||
def _training_ctx() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
registry = get_registry()
|
||||
pending_promo_runs = {
|
||||
str(req.training_run_id)
|
||||
for req in list_promotion_requests(settings.data_dir, status="pending_approval")
|
||||
}
|
||||
items = []
|
||||
for run in list_training_runs(settings.data_dir):
|
||||
candidate_state = None
|
||||
if run.spec.agent_name and run.spec.agent_version:
|
||||
try:
|
||||
candidate_state = registry.get_version(
|
||||
run.spec.agent_name, run.spec.agent_version
|
||||
).state
|
||||
except FileNotFoundError:
|
||||
candidate_state = None
|
||||
items.append(
|
||||
{
|
||||
"run": run,
|
||||
"evaluation": load_evaluation_for_run(settings.data_dir, run.id),
|
||||
"candidate_state": candidate_state,
|
||||
"promotion_pending": str(run.id) in pending_promo_runs,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"datasets": get_dataset_store().list_datasets(),
|
||||
"models": get_model_store().list_models(),
|
||||
"backend": settings.training_backend,
|
||||
}
|
||||
|
||||
|
||||
def _promotions_ctx() -> dict[str, Any]:
|
||||
data_dir = get_settings().data_dir
|
||||
pending_items = []
|
||||
for req in list_promotion_requests(data_dir, status="pending_approval"):
|
||||
pending_items.append(
|
||||
{"request": req, "evaluation": load_evaluation(data_dir, req.evaluation_report_id)}
|
||||
)
|
||||
return {"pending": pending_items}
|
||||
|
||||
|
||||
def _history_ctx() -> dict[str, Any]:
|
||||
data_dir = get_settings().data_dir
|
||||
all_execs = read_execution_summaries(data_dir)
|
||||
completed = [e for e in all_execs if e.status == "completed"]
|
||||
completed.sort(key=lambda e: e.finished_at or e.started_at, reverse=True)
|
||||
return {"runs": completed[:10], "total_completed": len(completed)}
|
||||
|
||||
|
||||
async def _pending_executions() -> list[AgentExecution]:
|
||||
"""Executions paused in HITL. They only live in the checkpointer (not in the
|
||||
append-only JSONL, which records terminal runs), so they are reconstructed
|
||||
from the execution index + a checkpointer snapshot."""
|
||||
registry = get_registry()
|
||||
policy_store = get_policy_store()
|
||||
orchestrator = get_orchestrator()
|
||||
data_dir = get_settings().data_dir
|
||||
pending: list[AgentExecution] = []
|
||||
for trace_id, meta in _load_index(data_dir).items():
|
||||
try:
|
||||
agent_def = registry.get_version(meta["agent_name"], meta["version"])
|
||||
policy = policy_store.get_policy(agent_def.guardrails[0])
|
||||
execution = await orchestrator.snapshot(
|
||||
agent_def=agent_def, policy=policy, trace_id=UUID(trace_id)
|
||||
)
|
||||
except (FileNotFoundError, IndexError, KeyError, ValueError):
|
||||
continue
|
||||
if execution is not None and execution.status == "awaiting_approval":
|
||||
pending.append(execution)
|
||||
pending.sort(key=lambda e: e.started_at, reverse=True)
|
||||
return pending
|
||||
|
||||
|
||||
# --- The single page --------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
"home.html",
|
||||
{"request": request, "title": "Home", "slogan": get_slogan()},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agents", response_class=HTMLResponse)
|
||||
async def agents_page(request: Request) -> HTMLResponse:
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
registry = get_registry()
|
||||
agents = registry.list_agents()
|
||||
return templates.TemplateResponse(
|
||||
"agents.html",
|
||||
{"request": request, "title": "Agents", "agents": agents, "slogan": get_slogan()},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agents/{name}", response_class=HTMLResponse)
|
||||
async def agent_detail(request: Request, name: str) -> HTMLResponse:
|
||||
registry = get_registry()
|
||||
try:
|
||||
agent = registry.get_agent(name)
|
||||
except FileNotFoundError:
|
||||
return HTMLResponse('<div class="text-red-400">Blade not found in the Arsenal</div>', status_code=404)
|
||||
|
||||
# Si viene por HTMX, devolvemos solo el fragmento (sin layout)
|
||||
if request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
"agent_detail.html",
|
||||
{"request": request, "agent": agent, "slogan": get_slogan()},
|
||||
)
|
||||
|
||||
# Acceso directo por navegador → página completa
|
||||
return templates.TemplateResponse(
|
||||
"agent_detail_full.html",
|
||||
{"request": request, "title": agent.name, "agent": agent, "slogan": get_slogan()},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/run", response_class=HTMLResponse)
|
||||
async def run_page(request: Request) -> HTMLResponse:
|
||||
registry = get_registry()
|
||||
agents = registry.list_agents()
|
||||
return templates.TemplateResponse(
|
||||
"run.html",
|
||||
{"request": request, "title": "Run", "agents": agents, "slogan": get_slogan()},
|
||||
)
|
||||
ctx: dict[str, Any] = {
|
||||
"request": request,
|
||||
"title": "Lifecycle",
|
||||
"agents": registry.list_agents(),
|
||||
"demo_input": DEMO_INPUT,
|
||||
}
|
||||
ctx.update(_define_ctx())
|
||||
ctx.update(await _approvals_ctx())
|
||||
ctx.update(_training_ctx())
|
||||
ctx.update(_promotions_ctx())
|
||||
ctx.update(_history_ctx())
|
||||
return templates.TemplateResponse("index.html", ctx)
|
||||
|
||||
|
||||
@router.post("/run", response_class=HTMLResponse)
|
||||
@@ -91,143 +179,116 @@ async def run_invoke(
|
||||
input: str = Form(...),
|
||||
) -> HTMLResponse:
|
||||
"""Execute the agent using the orchestrator directly (same process)."""
|
||||
from forja_core.api.deps import get_policy_store
|
||||
|
||||
registry = get_registry()
|
||||
policy_store = get_policy_store()
|
||||
orchestrator = get_orchestrator()
|
||||
settings = get_settings()
|
||||
|
||||
try:
|
||||
agent_def = registry.get_agent(agent_name) # resuelve la versión activa
|
||||
agent_def = registry.get_agent(agent_name)
|
||||
policy_name = agent_def.guardrails[0] if agent_def.guardrails else "default"
|
||||
policy = policy_store.get_policy(policy_name)
|
||||
|
||||
execution: AgentExecution = asyncio.run(
|
||||
orchestrator.invoke(
|
||||
agent_def=agent_def,
|
||||
policy=policy,
|
||||
user_input=input,
|
||||
)
|
||||
execution: AgentExecution = await orchestrator.invoke(
|
||||
agent_def=agent_def,
|
||||
policy=policy,
|
||||
user_input=input,
|
||||
)
|
||||
|
||||
# Persist terminal forgings (including successful "BLADE TEMPERED" ones)
|
||||
# so they appear in the Armory / Vault of Tempered Blades.
|
||||
_record_execution(settings.data_dir, str(execution.trace_id), agent_def.name, agent_def.version)
|
||||
# Persist like the API does: index always, JSONL only for terminal runs.
|
||||
_record_execution(
|
||||
settings.data_dir, str(execution.trace_id), agent_def.name, agent_def.version
|
||||
)
|
||||
if execution.status in {"completed", "failed", "blocked_by_guardrail"}:
|
||||
append_execution(settings.data_dir, execution)
|
||||
for v in execution.violations:
|
||||
append_violation(settings.data_dir, v)
|
||||
for v in execution.violations:
|
||||
append_violation(settings.data_dir, v)
|
||||
|
||||
except Exception as e:
|
||||
return HTMLResponse(f'<div class="text-red-400 border border-red-900 bg-red-950/40 rounded p-3 text-sm">Forge error: {str(e)[:300]}</div>', status_code=400)
|
||||
return HTMLResponse(
|
||||
f'<div class="text-red-400 border border-red-900 bg-red-950/40 rounded p-3 text-sm">'
|
||||
f"Execution error: {str(e)[:300]}</div>",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Forging result (gamified)
|
||||
status_map = {
|
||||
"completed": ("text-emerald-400", "bg-emerald-950/60 border-emerald-900", "BLADE TEMPERED"),
|
||||
"awaiting_approval": ("text-amber-400", "bg-amber-950/60 border-amber-900", "ON THE ANVIL · JUDGMENT REQUIRED"),
|
||||
"failed": ("text-red-400", "bg-red-950/60 border-red-900", "SHATTERED IN THE FIRE"),
|
||||
"blocked_by_guardrail": ("text-red-400", "bg-red-950/60 border-red-900", "BLOCKED BY THE FORGE"),
|
||||
}
|
||||
text_color, badge_style, status_label = status_map.get(
|
||||
execution.status, ("text-forge-steel", "bg-forge-surface2 border-forge-iron", execution.status.upper())
|
||||
text_color, badge_style, status_label = _STATUS_BADGES.get(
|
||||
execution.status,
|
||||
(
|
||||
"text-forge-steel",
|
||||
"bg-forge-surface2 border-forge-iron",
|
||||
execution.status.replace("_", " ").title(),
|
||||
),
|
||||
)
|
||||
|
||||
steps_html = ""
|
||||
for step in execution.decision_path:
|
||||
dur = f"{step.duration_ms}ms" if step.duration_ms else ""
|
||||
extra = ""
|
||||
if step.n_approved is not None:
|
||||
extra = f" · {step.n_approved} blessings"
|
||||
if step.rejected:
|
||||
extra = " · SHATTERED"
|
||||
if step.hitl:
|
||||
extra = " · JUDGMENT"
|
||||
steps_html += f'<div class="flex justify-between text-xs py-0.5 border-b border-forge-iron/60"><span class="font-mono text-forge-text">{step.node}</span><span class="text-forge-muted">{dur}{extra}</span></div>'
|
||||
|
||||
violations_html = ""
|
||||
if execution.violations:
|
||||
for v in execution.violations:
|
||||
sev = "text-red-400" if v.severity == "block" else "text-amber-400"
|
||||
violations_html += f'<div class="text-xs {sev}">• {v.validator}: {v.message} ({v.severity})</div>'
|
||||
|
||||
actions_html = ""
|
||||
if execution.proposed_actions:
|
||||
for a in execution.proposed_actions:
|
||||
risk = a.risk_score
|
||||
badge = "text-emerald-400" if risk < 3 else ("text-amber-400" if risk < 5 else "text-red-400")
|
||||
actions_html += f'<div class="text-xs bg-black/50 border border-forge-iron rounded p-2">• {a.description} <span class="font-mono {badge}">(risk={risk})</span></div>'
|
||||
|
||||
needs_html = ""
|
||||
if execution.needs_human_for:
|
||||
needs_html = f'<div class="mt-3 p-3 border border-amber-900 bg-amber-950/40 rounded text-amber-300 text-sm">This blade awaits the Judgment ({len(execution.needs_human_for)} action(s)). <a href="/approvals" class="underline hover:text-amber-200">Enter the Chamber →</a></div>'
|
||||
|
||||
html = f"""
|
||||
<div class="forge-card border border-forge-iron rounded-2xl p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold tracking-tight">Forging Record</span>
|
||||
<span class="text-[10px] px-2 py-px rounded border font-mono {badge_style} {text_color}">{status_label}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-forge-muted font-mono">trace {execution.trace_id}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="uppercase text-xs tracking-[1.5px] text-forge-muted mb-1">Strike Sequence</div>
|
||||
<div class="font-mono text-[10px] bg-black/60 border border-forge-iron rounded p-2.5 space-y-px">{steps_html or '<span class="text-forge-muted">no strikes</span>'}</div>
|
||||
</div>
|
||||
|
||||
{f'<div class="mt-4"><div class="uppercase text-xs tracking-[1.5px] text-red-400 mb-1">Flaws (Violations)</div>{violations_html}</div>' if violations_html else ''}
|
||||
|
||||
{f'<div class="mt-4"><div class="uppercase text-xs tracking-[1.5px] text-amber-400 mb-1">Proposed Edges</div><div class="space-y-1">{actions_html}</div></div>' if actions_html else ''}
|
||||
|
||||
{needs_html}
|
||||
|
||||
{f'<div class="mt-4 text-emerald-400 text-sm">✓ Forging complete. {len(execution.proposed_actions or [])} edges ready.</div>' if execution.status == 'completed' else ''}
|
||||
</div>
|
||||
"""
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
# ====================== APPROVALS (HITL) ======================
|
||||
|
||||
@router.get("/tutorial", response_class=HTMLResponse)
|
||||
async def tutorial_page(request: Request) -> HTMLResponse:
|
||||
"""Gamified step-by-step guide that teaches how to forge an agent."""
|
||||
return templates.TemplateResponse(
|
||||
"tutorial.html",
|
||||
{"request": request, "title": "Tutorial", "slogan": get_slogan(), "hide_tutorial_button": True},
|
||||
"run_result.html",
|
||||
{
|
||||
"request": request,
|
||||
"execution": execution,
|
||||
"status_label": status_label,
|
||||
"status_text_color": text_color,
|
||||
"status_badge_style": badge_style,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/armory", response_class=HTMLResponse)
|
||||
async def armory_page(request: Request) -> HTMLResponse:
|
||||
"""The Armory — collection of successfully tempered blades (completed forgings)."""
|
||||
data_dir = get_settings().data_dir
|
||||
all_execs = read_execution_summaries(data_dir)
|
||||
tempered = [e for e in all_execs if e.status == "completed"]
|
||||
tempered.sort(key=lambda e: e.finished_at or e.started_at, reverse=True)
|
||||
# --- Fragments (re-fetched on the global forja-refresh event) ---------------------------
|
||||
|
||||
|
||||
@router.get("/fragments/agents", response_class=HTMLResponse)
|
||||
async def fragment_agents(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("_agents.html", {"request": request, **_define_ctx()})
|
||||
|
||||
|
||||
@router.get("/fragments/approvals", response_class=HTMLResponse)
|
||||
async def fragment_approvals(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
"armory.html",
|
||||
{"request": request, "title": "Armory", "tempered": tempered, "slogan": get_slogan()},
|
||||
"_approvals.html", {"request": request, **(await _approvals_ctx())}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/deploy", response_class=HTMLResponse)
|
||||
async def deploy_page(request: Request) -> HTMLResponse:
|
||||
"""Deployment station — where tempered agents are sent to Azure, AWS, GCP, etc."""
|
||||
return templates.TemplateResponse(
|
||||
"deploy.html",
|
||||
{"request": request, "title": "Deploy", "slogan": get_slogan()},
|
||||
)
|
||||
@router.get("/fragments/training", response_class=HTMLResponse)
|
||||
async def fragment_training(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("_training.html", {"request": request, **_training_ctx()})
|
||||
|
||||
|
||||
@router.get("/approvals", response_class=HTMLResponse)
|
||||
async def approvals_page(request: Request) -> HTMLResponse:
|
||||
data_dir = get_settings().data_dir
|
||||
all_execs = read_execution_summaries(data_dir)
|
||||
pending = [e for e in all_execs if e.status == "awaiting_approval"]
|
||||
return templates.TemplateResponse(
|
||||
"approvals.html",
|
||||
{"request": request, "title": "Approvals", "pending": pending, "slogan": get_slogan()},
|
||||
@router.get("/fragments/promotions", response_class=HTMLResponse)
|
||||
async def fragment_promotions(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("_promotions.html", {"request": request, **_promotions_ctx()})
|
||||
|
||||
|
||||
@router.get("/fragments/history", response_class=HTMLResponse)
|
||||
async def fragment_history(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("_history.html", {"request": request, **_history_ctx()})
|
||||
|
||||
|
||||
# --- Legacy routes from the multi-page UI → anchors on the single page ------------------
|
||||
|
||||
_LEGACY_ROUTES = {
|
||||
"/agents": "/#define",
|
||||
"/policies": "/#define",
|
||||
"/run": "/#run",
|
||||
"/approvals": "/#approve",
|
||||
"/training": "/#train",
|
||||
"/promotions": "/#promote",
|
||||
"/history": "/#audit",
|
||||
"/armory": "/#audit",
|
||||
"/tutorial": "/",
|
||||
}
|
||||
|
||||
|
||||
def _redirect_endpoint(target: str) -> Callable[[], Coroutine[Any, Any, RedirectResponse]]:
|
||||
async def _redirect() -> RedirectResponse:
|
||||
return RedirectResponse(url=target, status_code=301)
|
||||
|
||||
return _redirect
|
||||
|
||||
|
||||
for _path, _target in _LEGACY_ROUTES.items():
|
||||
router.add_api_route(
|
||||
_path, _redirect_endpoint(_target), methods=["GET"], include_in_schema=False
|
||||
)
|
||||
|
||||
# Old per-agent detail pages → Define stage.
|
||||
router.add_api_route(
|
||||
"/agents/{name}", _redirect_endpoint("/#define"), methods=["GET"], include_in_schema=False
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user