Compare commits
17 Commits
main
...
feat/self-
| Author | SHA1 | Date |
|---|---|---|
|
|
a40e3df6ff | 6 days ago |
|
|
3a7bfcf59b | 6 days ago |
|
|
7e380a9c65 | 1 week ago |
|
|
de455b8566 | 1 week ago |
|
|
640e422498 | 2 weeks ago |
|
|
2e3a695878 | 2 weeks ago |
|
|
3dcf80eaaa | 2 weeks ago |
|
|
b3662906bc | 2 weeks ago |
|
|
d6e765ce3c | 2 weeks ago |
|
|
9a31b0c5ae | 2 weeks ago |
|
|
5ca21b598f | 2 weeks ago |
|
|
bd662f35fa | 2 weeks ago |
|
|
e210b56b37 | 2 weeks ago |
|
|
ed1a36ceb6 | 2 weeks ago |
|
|
82a12ff464 | 2 weeks ago |
|
|
1541948e76 | 2 weeks ago |
|
|
17583236a6 | 2 weeks ago |
@ -0,0 +1,3 @@
|
||||
from admin_app.main import app
|
||||
|
||||
__all__ = ["app"]
|
||||
@ -0,0 +1,306 @@
|
||||
import threading
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from admin_app.api.panel_session import get_panel_access_cookie
|
||||
from admin_app.core import (
|
||||
AdminSecurityService,
|
||||
AdminSettings,
|
||||
AuthenticatedStaffContext,
|
||||
AuthenticatedStaffPrincipal,
|
||||
get_admin_settings,
|
||||
)
|
||||
from admin_app.db.database import get_admin_db_session
|
||||
from admin_app.repositories import (
|
||||
AuditLogRepository,
|
||||
StaffAccountRepository,
|
||||
StaffSessionRepository,
|
||||
ToolArtifactRepository,
|
||||
ToolDraftRepository,
|
||||
ToolMetadataRepository,
|
||||
ToolVersionRepository,
|
||||
)
|
||||
from admin_app.services import (
|
||||
AuditService,
|
||||
AuthService,
|
||||
CollaboratorManagementService,
|
||||
ToolGenerationService,
|
||||
ToolGenerationWorkerService,
|
||||
ToolManagementService,
|
||||
)
|
||||
from shared.contracts import AdminPermission, StaffRole, permissions_for_role, role_has_permission, role_includes
|
||||
|
||||
# Injeta services, repositórios e settings.
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
_tool_generation_worker_lock = threading.Lock()
|
||||
_tool_generation_worker_service: ToolGenerationWorkerService | None = None
|
||||
_tool_generation_worker_config: tuple[int, str, str, int, int, float] | None = None
|
||||
|
||||
|
||||
def get_settings(request: Request) -> AdminSettings:
|
||||
app_settings = getattr(request.app.state, "admin_settings", None)
|
||||
if isinstance(app_settings, AdminSettings):
|
||||
return app_settings
|
||||
return get_admin_settings()
|
||||
|
||||
|
||||
def get_admin_db(db: Session = Depends(get_admin_db_session)) -> Session:
|
||||
return db
|
||||
|
||||
|
||||
def get_security_service(settings: AdminSettings = Depends(get_settings)) -> AdminSecurityService:
|
||||
return AdminSecurityService(settings)
|
||||
|
||||
|
||||
def get_staff_account_repository(db: Session = Depends(get_admin_db)) -> StaffAccountRepository:
|
||||
return StaffAccountRepository(db)
|
||||
|
||||
|
||||
def get_staff_session_repository(db: Session = Depends(get_admin_db)) -> StaffSessionRepository:
|
||||
return StaffSessionRepository(db)
|
||||
|
||||
|
||||
def get_audit_log_repository(db: Session = Depends(get_admin_db)) -> AuditLogRepository:
|
||||
return AuditLogRepository(db)
|
||||
|
||||
|
||||
def get_tool_draft_repository(db: Session = Depends(get_admin_db)) -> ToolDraftRepository:
|
||||
return ToolDraftRepository(db)
|
||||
|
||||
|
||||
def get_tool_version_repository(db: Session = Depends(get_admin_db)) -> ToolVersionRepository:
|
||||
return ToolVersionRepository(db)
|
||||
|
||||
|
||||
def get_tool_metadata_repository(db: Session = Depends(get_admin_db)) -> ToolMetadataRepository:
|
||||
return ToolMetadataRepository(db)
|
||||
|
||||
|
||||
def get_tool_artifact_repository(db: Session = Depends(get_admin_db)) -> ToolArtifactRepository:
|
||||
return ToolArtifactRepository(db)
|
||||
|
||||
|
||||
def get_audit_service(
|
||||
repository: AuditLogRepository = Depends(get_audit_log_repository),
|
||||
) -> AuditService:
|
||||
return AuditService(repository)
|
||||
|
||||
|
||||
def get_auth_service(
|
||||
account_repository: StaffAccountRepository = Depends(get_staff_account_repository),
|
||||
session_repository: StaffSessionRepository = Depends(get_staff_session_repository),
|
||||
security_service: AdminSecurityService = Depends(get_security_service),
|
||||
audit_service: AuditService = Depends(get_audit_service),
|
||||
) -> AuthService:
|
||||
return AuthService(
|
||||
account_repository=account_repository,
|
||||
session_repository=session_repository,
|
||||
security_service=security_service,
|
||||
audit_service=audit_service,
|
||||
)
|
||||
|
||||
|
||||
def get_collaborator_management_service(
|
||||
account_repository: StaffAccountRepository = Depends(get_staff_account_repository),
|
||||
security_service: AdminSecurityService = Depends(get_security_service),
|
||||
audit_service: AuditService = Depends(get_audit_service),
|
||||
) -> CollaboratorManagementService:
|
||||
return CollaboratorManagementService(
|
||||
account_repository=account_repository,
|
||||
security_service=security_service,
|
||||
audit_service=audit_service,
|
||||
)
|
||||
|
||||
|
||||
def get_tool_generation_service(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
) -> ToolGenerationService:
|
||||
"""Instancia o serviço isolado de geração via LLM do runtime administrativo.
|
||||
|
||||
Separado completamente do LLMService do product (app.services.ai.llm_service).
|
||||
Usa as settings admin_tool_generation_model / admin_tool_generation_fallback_model.
|
||||
Mapeado ao tool_generation_runtime_profile do contrato model_runtime_separation.
|
||||
"""
|
||||
return ToolGenerationService(settings)
|
||||
|
||||
|
||||
def get_tool_generation_worker_service(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
) -> ToolGenerationWorkerService:
|
||||
global _tool_generation_worker_service, _tool_generation_worker_config
|
||||
|
||||
config = (
|
||||
int(settings.admin_tool_generation_worker_max_workers),
|
||||
str(settings.admin_tool_generation_model),
|
||||
str(settings.admin_tool_generation_fallback_model),
|
||||
int(settings.admin_tool_generation_timeout_seconds),
|
||||
int(settings.admin_tool_generation_max_output_tokens),
|
||||
float(settings.admin_tool_generation_temperature),
|
||||
)
|
||||
|
||||
with _tool_generation_worker_lock:
|
||||
if _tool_generation_worker_service is None or _tool_generation_worker_config != config:
|
||||
if _tool_generation_worker_service is not None:
|
||||
_tool_generation_worker_service.shutdown(wait=False)
|
||||
_tool_generation_worker_service = ToolGenerationWorkerService(settings)
|
||||
_tool_generation_worker_config = config
|
||||
return _tool_generation_worker_service
|
||||
|
||||
|
||||
def get_tool_management_service(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
draft_repository: ToolDraftRepository = Depends(get_tool_draft_repository),
|
||||
version_repository: ToolVersionRepository = Depends(get_tool_version_repository),
|
||||
metadata_repository: ToolMetadataRepository = Depends(get_tool_metadata_repository),
|
||||
artifact_repository: ToolArtifactRepository = Depends(get_tool_artifact_repository),
|
||||
tool_generation_service: ToolGenerationService = Depends(get_tool_generation_service),
|
||||
tool_generation_worker_service: ToolGenerationWorkerService = Depends(get_tool_generation_worker_service),
|
||||
) -> ToolManagementService:
|
||||
return ToolManagementService(
|
||||
settings=settings,
|
||||
draft_repository=draft_repository,
|
||||
version_repository=version_repository,
|
||||
metadata_repository=metadata_repository,
|
||||
artifact_repository=artifact_repository,
|
||||
tool_generation_service=tool_generation_service,
|
||||
tool_generation_worker_service=tool_generation_worker_service,
|
||||
)
|
||||
|
||||
|
||||
def get_current_staff_context(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
) -> AuthenticatedStaffContext:
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Autenticacao administrativa obrigatoria.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
return auth_service.get_authenticated_context(credentials.credentials)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token administrativo invalido.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from exc
|
||||
|
||||
|
||||
def get_current_panel_staff_context(
|
||||
request: Request,
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
) -> AuthenticatedStaffContext:
|
||||
access_token = get_panel_access_cookie(request)
|
||||
if not access_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Sessao administrativa web obrigatoria.",
|
||||
)
|
||||
|
||||
try:
|
||||
return auth_service.get_authenticated_context(access_token)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Sessao administrativa web invalida.",
|
||||
) from exc
|
||||
|
||||
|
||||
def get_optional_panel_staff_context(
|
||||
request: Request,
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
) -> AuthenticatedStaffContext | None:
|
||||
access_token = get_panel_access_cookie(request)
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
return auth_service.get_authenticated_context(access_token)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_staff_principal(
|
||||
context: AuthenticatedStaffContext = Depends(get_current_staff_context),
|
||||
) -> AuthenticatedStaffPrincipal:
|
||||
return context.principal
|
||||
|
||||
|
||||
def get_current_panel_staff_principal(
|
||||
context: AuthenticatedStaffContext = Depends(get_current_panel_staff_context),
|
||||
) -> AuthenticatedStaffPrincipal:
|
||||
return context.principal
|
||||
|
||||
|
||||
def get_optional_panel_staff_principal(
|
||||
context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context),
|
||||
) -> AuthenticatedStaffPrincipal | None:
|
||||
if context is None:
|
||||
return None
|
||||
return context.principal
|
||||
|
||||
|
||||
def get_current_staff_session_id(
|
||||
context: AuthenticatedStaffContext = Depends(get_current_staff_context),
|
||||
) -> int:
|
||||
return context.session_id
|
||||
|
||||
|
||||
def get_current_panel_staff_session_id(
|
||||
context: AuthenticatedStaffContext = Depends(get_current_panel_staff_context),
|
||||
) -> int:
|
||||
return context.session_id
|
||||
|
||||
|
||||
def require_staff_role(minimum_role: StaffRole):
|
||||
def dependency(
|
||||
current_staff: AuthenticatedStaffPrincipal = Depends(get_current_staff_principal),
|
||||
) -> AuthenticatedStaffPrincipal:
|
||||
if not role_includes(current_staff.role, minimum_role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Acesso administrativo requer papel minimo '{minimum_role.value}'.",
|
||||
)
|
||||
return current_staff
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_admin_permission(permission: AdminPermission):
|
||||
def dependency(
|
||||
current_staff: AuthenticatedStaffPrincipal = Depends(get_current_staff_principal),
|
||||
) -> AuthenticatedStaffPrincipal:
|
||||
if not role_has_permission(current_staff.role, permission):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permissao administrativa insuficiente: '{permission.value}'.",
|
||||
)
|
||||
return current_staff
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def require_panel_admin_permission(permission: AdminPermission):
|
||||
def dependency(
|
||||
current_staff: AuthenticatedStaffPrincipal = Depends(get_current_panel_staff_principal),
|
||||
) -> AuthenticatedStaffPrincipal:
|
||||
if not role_has_permission(current_staff.role, permission):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Permissao administrativa insuficiente: '{permission.value}'.",
|
||||
)
|
||||
return current_staff
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def get_current_staff_permissions(
|
||||
current_staff: AuthenticatedStaffPrincipal = Depends(get_current_staff_principal),
|
||||
) -> tuple[str, ...]:
|
||||
return tuple(permission.value for permission in permissions_for_role(current_staff.role))
|
||||
@ -0,0 +1,26 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from admin_app.api.routes.audit import router as audit_router
|
||||
from admin_app.api.routes.auth import router as auth_router
|
||||
from admin_app.api.routes.collaborators import router as collaborators_router
|
||||
from admin_app.api.routes.panel_auth import router as panel_auth_router
|
||||
from admin_app.api.routes.panel_collaborators import router as panel_collaborators_router
|
||||
from admin_app.api.routes.panel_reports import router as panel_reports_router
|
||||
from admin_app.api.routes.panel_tools import router as panel_tools_router
|
||||
from admin_app.api.routes.reports import router as reports_router
|
||||
from admin_app.api.routes.system import router as system_router
|
||||
from admin_app.api.routes.tools import router as tools_router
|
||||
|
||||
# Agrega as rotas administrativas.
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth_router)
|
||||
api_router.include_router(panel_auth_router)
|
||||
api_router.include_router(panel_collaborators_router)
|
||||
api_router.include_router(panel_reports_router)
|
||||
api_router.include_router(panel_tools_router)
|
||||
api_router.include_router(system_router)
|
||||
api_router.include_router(reports_router)
|
||||
api_router.include_router(collaborators_router)
|
||||
api_router.include_router(tools_router)
|
||||
api_router.include_router(audit_router)
|
||||
@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from admin_app.api.dependencies import get_audit_service, require_admin_permission
|
||||
from admin_app.api.schemas import AdminAuditEntryResponse, AdminAuditListResponse
|
||||
from admin_app.core import AuthenticatedStaffPrincipal
|
||||
from admin_app.services import AuditService
|
||||
from shared.contracts import AdminPermission
|
||||
|
||||
# login/logout da API admin.
|
||||
|
||||
router = APIRouter(prefix="/audit", tags=["audit"])
|
||||
|
||||
|
||||
@router.get("/events", response_model=AdminAuditListResponse)
|
||||
def list_audit_events(
|
||||
audit_service: AuditService = Depends(get_audit_service),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_AUDIT_LOGS)
|
||||
),
|
||||
):
|
||||
events = audit_service.list_recent(limit=50)
|
||||
return AdminAuditListResponse(
|
||||
service="orquestrador-admin",
|
||||
events=[
|
||||
AdminAuditEntryResponse(
|
||||
id=event.id,
|
||||
actor_staff_account_id=event.actor_staff_account_id,
|
||||
event_type=event.event_type,
|
||||
resource_type=event.resource_type,
|
||||
resource_id=event.resource_id,
|
||||
outcome=event.outcome,
|
||||
message=event.message,
|
||||
payload_json=event.payload_json,
|
||||
ip_address=event.ip_address,
|
||||
user_agent=event.user_agent,
|
||||
created_at=event.created_at,
|
||||
)
|
||||
for event in events
|
||||
],
|
||||
)
|
||||
@ -0,0 +1,109 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
from admin_app.api.dependencies import (
|
||||
get_auth_service,
|
||||
get_current_staff_context,
|
||||
get_current_staff_principal,
|
||||
)
|
||||
from admin_app.api.schemas import (
|
||||
AdminAuthenticatedStaffResponse,
|
||||
AdminLoginRequest,
|
||||
AdminLogoutResponse,
|
||||
AdminRefreshTokenRequest,
|
||||
AdminSessionResponse,
|
||||
)
|
||||
from admin_app.core import AuthenticatedStaffContext, AuthenticatedStaffPrincipal
|
||||
from admin_app.services import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _extract_request_metadata(request: Request) -> tuple[str | None, str | None]:
|
||||
ip_address = request.client.host if request.client else None
|
||||
user_agent = request.headers.get("user-agent")
|
||||
return ip_address, user_agent
|
||||
|
||||
|
||||
@router.post("/login", response_model=AdminSessionResponse)
|
||||
def login(
|
||||
payload: AdminLoginRequest,
|
||||
request: Request,
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
ip_address, user_agent = _extract_request_metadata(request)
|
||||
session = auth_service.login(
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
if session is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Credenciais administrativas invalidas.",
|
||||
)
|
||||
|
||||
return AdminSessionResponse(
|
||||
session_id=session.session_id,
|
||||
access_token=session.access_token,
|
||||
refresh_token=session.refresh_token,
|
||||
token_type=session.token_type,
|
||||
expires_in_seconds=session.expires_in_seconds,
|
||||
staff_account=AdminAuthenticatedStaffResponse(**session.principal.model_dump()),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=AdminSessionResponse)
|
||||
def refresh(
|
||||
payload: AdminRefreshTokenRequest,
|
||||
request: Request,
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
ip_address, user_agent = _extract_request_metadata(request)
|
||||
session = auth_service.refresh_session(
|
||||
refresh_token=payload.refresh_token,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
if session is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh token administrativo invalido.",
|
||||
)
|
||||
|
||||
return AdminSessionResponse(
|
||||
session_id=session.session_id,
|
||||
access_token=session.access_token,
|
||||
refresh_token=session.refresh_token,
|
||||
token_type=session.token_type,
|
||||
expires_in_seconds=session.expires_in_seconds,
|
||||
staff_account=AdminAuthenticatedStaffResponse(**session.principal.model_dump()),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=AdminLogoutResponse)
|
||||
def logout(
|
||||
request: Request,
|
||||
current_context: AuthenticatedStaffContext = Depends(get_current_staff_context),
|
||||
auth_service: AuthService = Depends(get_auth_service),
|
||||
):
|
||||
ip_address, user_agent = _extract_request_metadata(request)
|
||||
auth_service.logout(
|
||||
current_context.session_id,
|
||||
actor_staff_account_id=current_context.principal.id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
return AdminLogoutResponse(
|
||||
service="orquestrador-admin",
|
||||
status="ok",
|
||||
message="Sessao administrativa encerrada.",
|
||||
session_id=current_context.session_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AdminAuthenticatedStaffResponse)
|
||||
def current_staff(
|
||||
current_staff_account: AuthenticatedStaffPrincipal = Depends(get_current_staff_principal),
|
||||
):
|
||||
return AdminAuthenticatedStaffResponse(**current_staff_account.model_dump())
|
||||
@ -0,0 +1,98 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
from admin_app.api.dependencies import (
|
||||
get_collaborator_management_service,
|
||||
require_admin_permission,
|
||||
)
|
||||
from admin_app.api.schemas import (
|
||||
AdminCollaboratorCreateRequest,
|
||||
AdminCollaboratorCreateResponse,
|
||||
AdminCollaboratorListResponse,
|
||||
AdminCollaboratorStatusUpdateRequest,
|
||||
AdminCollaboratorStatusUpdateResponse,
|
||||
AdminCollaboratorSummaryResponse,
|
||||
)
|
||||
from admin_app.core import AuthenticatedStaffPrincipal
|
||||
from admin_app.services import CollaboratorManagementService
|
||||
from shared.contracts import AdminPermission
|
||||
|
||||
# Camada HTTP de gestão de colaboradores administrativos
|
||||
|
||||
router = APIRouter(prefix="/colaboradores", tags=["colaboradores"])
|
||||
|
||||
|
||||
@router.get("", response_model=AdminCollaboratorListResponse)
|
||||
def list_collaborators(
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.MANAGE_STAFF_ACCOUNTS)
|
||||
),
|
||||
service: CollaboratorManagementService = Depends(get_collaborator_management_service),
|
||||
):
|
||||
payload = service.list_collaborators()
|
||||
return AdminCollaboratorListResponse(
|
||||
service="orquestrador-admin",
|
||||
total=payload["total"],
|
||||
active_count=payload["active_count"],
|
||||
inactive_count=payload["inactive_count"],
|
||||
collaborators=[AdminCollaboratorSummaryResponse(**account) for account in payload["accounts"]],
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=AdminCollaboratorCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_collaborator(
|
||||
collaborator: AdminCollaboratorCreateRequest,
|
||||
request: Request,
|
||||
current_staff: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.MANAGE_STAFF_ACCOUNTS)
|
||||
),
|
||||
service: CollaboratorManagementService = Depends(get_collaborator_management_service),
|
||||
):
|
||||
try:
|
||||
payload = service.create_collaborator(
|
||||
email=collaborator.email,
|
||||
display_name=collaborator.display_name,
|
||||
password=collaborator.password,
|
||||
is_active=collaborator.is_active,
|
||||
actor_staff_account_id=current_staff.id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
status_code = status.HTTP_409_CONFLICT if detail.startswith("Ja existe") else status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
|
||||
return AdminCollaboratorCreateResponse(
|
||||
service="orquestrador-admin",
|
||||
message="Colaborador administrativo criado com sucesso.",
|
||||
collaborator=AdminCollaboratorSummaryResponse(**payload),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{collaborator_id}/status", response_model=AdminCollaboratorStatusUpdateResponse)
|
||||
def update_collaborator_status(
|
||||
collaborator_id: int,
|
||||
payload: AdminCollaboratorStatusUpdateRequest,
|
||||
request: Request,
|
||||
current_staff: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.MANAGE_STAFF_ACCOUNTS)
|
||||
),
|
||||
service: CollaboratorManagementService = Depends(get_collaborator_management_service),
|
||||
):
|
||||
try:
|
||||
updated = service.update_collaborator_status(
|
||||
collaborator_id=collaborator_id,
|
||||
is_active=payload.is_active,
|
||||
actor_staff_account_id=current_staff.id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
action_label = "ativado" if updated["is_active"] else "desativado"
|
||||
return AdminCollaboratorStatusUpdateResponse(
|
||||
service="orquestrador-admin",
|
||||
message=f"Colaborador administrativo {action_label} com sucesso.",
|
||||
collaborator=AdminCollaboratorSummaryResponse(**updated),
|
||||
)
|
||||
@ -0,0 +1,96 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
from admin_app.api.dependencies import (
|
||||
get_collaborator_management_service,
|
||||
require_panel_admin_permission,
|
||||
)
|
||||
from admin_app.api.schemas import (
|
||||
AdminCollaboratorCreateRequest,
|
||||
AdminCollaboratorCreateResponse,
|
||||
AdminCollaboratorListResponse,
|
||||
AdminCollaboratorStatusUpdateRequest,
|
||||
AdminCollaboratorStatusUpdateResponse,
|
||||
AdminCollaboratorSummaryResponse,
|
||||
)
|
||||
from admin_app.core import AuthenticatedStaffPrincipal
|
||||
from admin_app.services import CollaboratorManagementService
|
||||
from shared.contracts import AdminPermission
|
||||
|
||||
router = APIRouter(prefix="/panel/colaboradores", tags=["panel-colaboradores"])
|
||||
|
||||
|
||||
@router.get("", response_model=AdminCollaboratorListResponse)
|
||||
def panel_list_collaborators(
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_panel_admin_permission(AdminPermission.MANAGE_STAFF_ACCOUNTS)
|
||||
),
|
||||
service: CollaboratorManagementService = Depends(get_collaborator_management_service),
|
||||
):
|
||||
payload = service.list_collaborators()
|
||||
return AdminCollaboratorListResponse(
|
||||
service="orquestrador-admin",
|
||||
total=payload["total"],
|
||||
active_count=payload["active_count"],
|
||||
inactive_count=payload["inactive_count"],
|
||||
collaborators=[AdminCollaboratorSummaryResponse(**account) for account in payload["accounts"]],
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=AdminCollaboratorCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
def panel_create_collaborator(
|
||||
collaborator: AdminCollaboratorCreateRequest,
|
||||
request: Request,
|
||||
current_staff: AuthenticatedStaffPrincipal = Depends(
|
||||
require_panel_admin_permission(AdminPermission.MANAGE_STAFF_ACCOUNTS)
|
||||
),
|
||||
service: CollaboratorManagementService = Depends(get_collaborator_management_service),
|
||||
):
|
||||
try:
|
||||
payload = service.create_collaborator(
|
||||
email=collaborator.email,
|
||||
display_name=collaborator.display_name,
|
||||
password=collaborator.password,
|
||||
is_active=collaborator.is_active,
|
||||
actor_staff_account_id=current_staff.id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
status_code = status.HTTP_409_CONFLICT if detail.startswith("Ja existe") else status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
|
||||
return AdminCollaboratorCreateResponse(
|
||||
service="orquestrador-admin",
|
||||
message="Colaborador administrativo criado com sucesso.",
|
||||
collaborator=AdminCollaboratorSummaryResponse(**payload),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{collaborator_id}/status", response_model=AdminCollaboratorStatusUpdateResponse)
|
||||
def panel_update_collaborator_status(
|
||||
collaborator_id: int,
|
||||
payload: AdminCollaboratorStatusUpdateRequest,
|
||||
request: Request,
|
||||
current_staff: AuthenticatedStaffPrincipal = Depends(
|
||||
require_panel_admin_permission(AdminPermission.MANAGE_STAFF_ACCOUNTS)
|
||||
),
|
||||
service: CollaboratorManagementService = Depends(get_collaborator_management_service),
|
||||
):
|
||||
try:
|
||||
updated = service.update_collaborator_status(
|
||||
collaborator_id=collaborator_id,
|
||||
is_active=payload.is_active,
|
||||
actor_staff_account_id=current_staff.id,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
action_label = "ativado" if updated["is_active"] else "desativado"
|
||||
return AdminCollaboratorStatusUpdateResponse(
|
||||
service="orquestrador-admin",
|
||||
message=f"Colaborador administrativo {action_label} com sucesso.",
|
||||
collaborator=AdminCollaboratorSummaryResponse(**updated),
|
||||
)
|
||||
@ -0,0 +1,477 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from admin_app.api.dependencies import get_settings, require_admin_permission
|
||||
from admin_app.api.schemas import (
|
||||
AdminBotFlowReportCatalogResponse,
|
||||
AdminBotFlowReportOverviewResponse,
|
||||
AdminBotFlowReportResponse,
|
||||
AdminConversationTelemetryReportCatalogResponse,
|
||||
AdminConversationTelemetryReportOverviewResponse,
|
||||
AdminConversationTelemetryReportResponse,
|
||||
AdminRentalReportCatalogResponse,
|
||||
AdminRentalReportOverviewResponse,
|
||||
AdminRentalReportResponse,
|
||||
AdminReportDatasetListResponse,
|
||||
AdminReportDatasetResponse,
|
||||
AdminReportOverviewResponse,
|
||||
AdminRevenueReportCatalogResponse,
|
||||
AdminRevenueReportOverviewResponse,
|
||||
AdminRevenueReportResponse,
|
||||
AdminSalesReportCatalogResponse,
|
||||
AdminSalesReportOverviewResponse,
|
||||
AdminSalesReportResponse,
|
||||
)
|
||||
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal
|
||||
from admin_app.services import ReportService
|
||||
from shared.contracts import AdminPermission
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["reports"])
|
||||
|
||||
|
||||
def _build_service(settings: AdminSettings) -> ReportService:
|
||||
return ReportService(settings)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/overview",
|
||||
response_model=AdminReportOverviewResponse,
|
||||
)
|
||||
def reports_overview(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.build_overview_payload()
|
||||
return AdminReportOverviewResponse(
|
||||
service="orquestrador-admin",
|
||||
mode=payload["mode"],
|
||||
metrics=payload["metrics"],
|
||||
materialization=payload["materialization"],
|
||||
report_families=payload["report_families"],
|
||||
next_steps=payload["next_steps"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/datasets",
|
||||
response_model=AdminReportDatasetListResponse,
|
||||
)
|
||||
def report_datasets(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.list_datasets_payload()
|
||||
return AdminReportDatasetListResponse(
|
||||
service="orquestrador-admin",
|
||||
source=payload["source"],
|
||||
materialization=payload["materialization"],
|
||||
datasets=payload["datasets"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/datasets/{dataset_key}",
|
||||
response_model=AdminReportDatasetResponse,
|
||||
)
|
||||
def report_dataset_detail(
|
||||
dataset_key: str,
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.get_dataset_payload(dataset_key)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Dataset operacional nao encontrado para relatorio.",
|
||||
)
|
||||
|
||||
return AdminReportDatasetResponse(
|
||||
service="orquestrador-admin",
|
||||
source=payload["source"],
|
||||
materialization=payload["materialization"],
|
||||
dataset=payload["dataset"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sales/overview",
|
||||
response_model=AdminSalesReportOverviewResponse,
|
||||
)
|
||||
def sales_reports_overview(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.build_sales_overview_payload()
|
||||
return AdminSalesReportOverviewResponse(
|
||||
service="orquestrador-admin",
|
||||
domain=payload["domain"],
|
||||
mode=payload["mode"],
|
||||
source_dataset_keys=payload["source_dataset_keys"],
|
||||
metrics=payload["metrics"],
|
||||
materialization=payload["materialization"],
|
||||
reports=payload["reports"],
|
||||
next_steps=payload["next_steps"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sales/reports",
|
||||
response_model=AdminSalesReportCatalogResponse,
|
||||
)
|
||||
def sales_reports_catalog(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.list_sales_reports_payload()
|
||||
return AdminSalesReportCatalogResponse(
|
||||
service="orquestrador-admin",
|
||||
domain=payload["domain"],
|
||||
source=payload["source"],
|
||||
materialization=payload["materialization"],
|
||||
reports=payload["reports"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sales/reports/{report_key}",
|
||||
response_model=AdminSalesReportResponse,
|
||||
)
|
||||
def sales_report_detail(
|
||||
report_key: str,
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.get_sales_report_payload(report_key)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Relatorio de vendas nao encontrado.",
|
||||
)
|
||||
|
||||
return AdminSalesReportResponse(
|
||||
service="orquestrador-admin",
|
||||
domain=payload["domain"],
|
||||
source=payload["source"],
|
||||
materialization=payload["materialization"],
|
||||
report=payload["report"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/arrecadacao/overview",
|
||||
response_model=AdminRevenueReportOverviewResponse,
|
||||
)
|
||||
def revenue_reports_overview(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.build_revenue_overview_payload()
|
||||
return AdminRevenueReportOverviewResponse(
|
||||
service="orquestrador-admin",
|
||||
area=payload["area"],
|
||||
source_domain=payload["source_domain"],
|
||||
mode=payload["mode"],
|
||||
source_dataset_keys=payload["source_dataset_keys"],
|
||||
metrics=payload["metrics"],
|
||||
materialization=payload["materialization"],
|
||||
reports=payload["reports"],
|
||||
next_steps=payload["next_steps"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/arrecadacao/reports",
|
||||
response_model=AdminRevenueReportCatalogResponse,
|
||||
)
|
||||
def revenue_reports_catalog(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.list_revenue_reports_payload()
|
||||
return AdminRevenueReportCatalogResponse(
|
||||
service="orquestrador-admin",
|
||||
area=payload["area"],
|
||||
source_domain=payload["source_domain"],
|
||||
source=payload["source"],
|
||||
materialization=payload["materialization"],
|
||||
reports=payload["reports"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/arrecadacao/reports/{report_key}",
|
||||
response_model=AdminRevenueReportResponse,
|
||||
)
|
||||
def revenue_report_detail(
|
||||
report_key: str,
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.get_revenue_report_payload(report_key)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Relatorio de arrecadacao nao encontrado.",
|
||||
)
|
||||
|
||||
return AdminRevenueReportResponse(
|
||||
service="orquestrador-admin",
|
||||
area=payload["area"],
|
||||
source_domain=payload["source_domain"],
|
||||
source=payload["source"],
|
||||
materialization=payload["materialization"],
|
||||
report=payload["report"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/locacao/overview",
|
||||
response_model=AdminRentalReportOverviewResponse,
|
||||
)
|
||||
def rental_reports_overview(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.build_rental_overview_payload()
|
||||
return AdminRentalReportOverviewResponse(
|
||||
service="orquestrador-admin",
|
||||
area=payload["area"],
|
||||
source_domain=payload["source_domain"],
|
||||
mode=payload["mode"],
|
||||
source_dataset_keys=payload["source_dataset_keys"],
|
||||
metrics=payload["metrics"],
|
||||
materialization=payload["materialization"],
|
||||
reports=payload["reports"],
|
||||
next_steps=payload["next_steps"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/locacao/reports",
|
||||
response_model=AdminRentalReportCatalogResponse,
|
||||
)
|
||||
def rental_reports_catalog(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.list_rental_reports_payload()
|
||||
return AdminRentalReportCatalogResponse(
|
||||
service="orquestrador-admin",
|
||||
area=payload["area"],
|
||||
source_domain=payload["source_domain"],
|
||||
source=payload["source"],
|
||||
materialization=payload["materialization"],
|
||||
reports=payload["reports"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/locacao/reports/{report_key}",
|
||||
response_model=AdminRentalReportResponse,
|
||||
)
|
||||
def rental_report_detail(
|
||||
report_key: str,
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.get_rental_report_payload(report_key)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Relatorio de locacao nao encontrado.",
|
||||
)
|
||||
|
||||
return AdminRentalReportResponse(
|
||||
service="orquestrador-admin",
|
||||
area=payload["area"],
|
||||
source_domain=payload["source_domain"],
|
||||
source=payload["source"],
|
||||
materialization=payload["materialization"],
|
||||
report=payload["report"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/fluxo-bot/overview",
|
||||
response_model=AdminBotFlowReportOverviewResponse,
|
||||
)
|
||||
def bot_flow_reports_overview(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.build_bot_flow_overview_payload()
|
||||
return AdminBotFlowReportOverviewResponse(
|
||||
service="orquestrador-admin",
|
||||
area=payload["area"],
|
||||
source_domain=payload["source_domain"],
|
||||
mode=payload["mode"],
|
||||
source_dataset_keys=payload["source_dataset_keys"],
|
||||
metrics=payload["metrics"],
|
||||
materialization=payload["materialization"],
|
||||
reports=payload["reports"],
|
||||
next_steps=payload["next_steps"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/fluxo-bot/reports",
|
||||
response_model=AdminBotFlowReportCatalogResponse,
|
||||
)
|
||||
def bot_flow_reports_catalog(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.list_bot_flow_reports_payload()
|
||||
return AdminBotFlowReportCatalogResponse(
|
||||
service="orquestrador-admin",
|
||||
area=payload["area"],
|
||||
source_domain=payload["source_domain"],
|
||||
source=payload["source"],
|
||||
materialization=payload["materialization"],
|
||||
reports=payload["reports"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/fluxo-bot/reports/{report_key}",
|
||||
response_model=AdminBotFlowReportResponse,
|
||||
)
|
||||
def bot_flow_report_detail(
|
||||
report_key: str,
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.get_bot_flow_report_payload(report_key)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Relatorio operacional do fluxo do bot nao encontrado.",
|
||||
)
|
||||
|
||||
return AdminBotFlowReportResponse(
|
||||
service="orquestrador-admin",
|
||||
area=payload["area"],
|
||||
source_domain=payload["source_domain"],
|
||||
source=payload["source"],
|
||||
materialization=payload["materialization"],
|
||||
report=payload["report"],
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/telemetria-conversacional/overview",
|
||||
response_model=AdminConversationTelemetryReportOverviewResponse,
|
||||
)
|
||||
def conversation_telemetry_reports_overview(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.build_conversation_telemetry_overview_payload()
|
||||
return AdminConversationTelemetryReportOverviewResponse(
|
||||
service="orquestrador-admin",
|
||||
area=payload["area"],
|
||||
source_domain=payload["source_domain"],
|
||||
mode=payload["mode"],
|
||||
source_dataset_keys=payload["source_dataset_keys"],
|
||||
metrics=payload["metrics"],
|
||||
materialization=payload["materialization"],
|
||||
reports=payload["reports"],
|
||||
next_steps=payload["next_steps"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/telemetria-conversacional/reports",
|
||||
response_model=AdminConversationTelemetryReportCatalogResponse,
|
||||
)
|
||||
def conversation_telemetry_reports_catalog(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.list_conversation_telemetry_reports_payload()
|
||||
return AdminConversationTelemetryReportCatalogResponse(
|
||||
service="orquestrador-admin",
|
||||
area=payload["area"],
|
||||
source_domain=payload["source_domain"],
|
||||
source=payload["source"],
|
||||
materialization=payload["materialization"],
|
||||
reports=payload["reports"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/telemetria-conversacional/reports/{report_key}",
|
||||
response_model=AdminConversationTelemetryReportResponse,
|
||||
)
|
||||
def conversation_telemetry_report_detail(
|
||||
report_key: str,
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_REPORTS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings)
|
||||
payload = service.get_conversation_telemetry_report_payload(report_key)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Relatorio de telemetria conversacional nao encontrado.",
|
||||
)
|
||||
|
||||
return AdminConversationTelemetryReportResponse(
|
||||
service="orquestrador-admin",
|
||||
area=payload["area"],
|
||||
source_domain=payload["source_domain"],
|
||||
source=payload["source"],
|
||||
materialization=payload["materialization"],
|
||||
report=payload["report"],
|
||||
)
|
||||
@ -0,0 +1,308 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
|
||||
from admin_app.api.dependencies import (
|
||||
get_current_staff_permissions,
|
||||
get_security_service,
|
||||
get_settings,
|
||||
require_admin_permission,
|
||||
)
|
||||
from admin_app.api.panel_session import (
|
||||
PANEL_ACCESS_COOKIE_NAME,
|
||||
PANEL_COOKIE_SAMESITE,
|
||||
PANEL_REFRESH_COOKIE_NAME,
|
||||
build_panel_cookie_path,
|
||||
should_use_secure_cookies,
|
||||
)
|
||||
from admin_app.api.schemas import (
|
||||
AdminCapabilityResponse,
|
||||
AdminCurrentAccessResponse,
|
||||
AdminHealthResponse,
|
||||
AdminSystemBotGovernedConfigurationResponse,
|
||||
AdminSystemConfigurationResponse,
|
||||
AdminSystemFunctionalConfigurationCatalogResponse,
|
||||
AdminSystemFunctionalConfigurationDetailResponse,
|
||||
AdminSystemInfoResponse,
|
||||
AdminSystemModelRuntimeSeparationResponse,
|
||||
AdminSystemRuntimeConfigurationResponse,
|
||||
AdminSystemSecurityConfigurationResponse,
|
||||
AdminSystemWriteGovernanceResponse,
|
||||
)
|
||||
from admin_app.core import AdminSecurityService, AuthenticatedStaffPrincipal
|
||||
from admin_app.core.settings import AdminSettings
|
||||
from admin_app.services.system_service import SystemService
|
||||
from shared.contracts import AdminPermission
|
||||
|
||||
# governança e configuração do sistema.
|
||||
|
||||
router = APIRouter(tags=["system"])
|
||||
|
||||
|
||||
def _build_service(
|
||||
settings: AdminSettings,
|
||||
security_service: AdminSecurityService,
|
||||
) -> SystemService:
|
||||
return SystemService(settings=settings, security_service=security_service)
|
||||
|
||||
|
||||
@router.get("/", response_model=None)
|
||||
def root(
|
||||
request: Request,
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
) -> Response | dict:
|
||||
if "text/html" in request.headers.get("accept", ""):
|
||||
return RedirectResponse(
|
||||
url=_build_prefixed_path(settings.admin_api_prefix, "/login"),
|
||||
status_code=302,
|
||||
)
|
||||
return SystemService(settings=settings).build_root_payload()
|
||||
|
||||
|
||||
@router.get("/health", response_model=AdminHealthResponse)
|
||||
def health_check(settings: AdminSettings = Depends(get_settings)):
|
||||
return SystemService(settings=settings).build_health_payload()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/info",
|
||||
response_model=AdminSystemInfoResponse,
|
||||
)
|
||||
def system_info(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
security_service: AdminSecurityService = Depends(get_security_service),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_SYSTEM)
|
||||
),
|
||||
):
|
||||
return _build_service(settings, security_service).build_system_info_payload()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/access",
|
||||
response_model=AdminCurrentAccessResponse,
|
||||
)
|
||||
def current_access(
|
||||
current_staff: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_SYSTEM)
|
||||
),
|
||||
permissions: tuple[str, ...] = Depends(get_current_staff_permissions),
|
||||
):
|
||||
return AdminCurrentAccessResponse(
|
||||
service="orquestrador-admin",
|
||||
staff_account={
|
||||
"id": current_staff.id,
|
||||
"email": current_staff.email,
|
||||
"display_name": current_staff.display_name,
|
||||
"role": current_staff.role,
|
||||
"is_active": current_staff.is_active,
|
||||
},
|
||||
permissions=list(permissions),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/admin-capabilities",
|
||||
response_model=AdminCapabilityResponse,
|
||||
)
|
||||
def admin_capabilities(
|
||||
current_staff: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.MANAGE_SETTINGS)
|
||||
),
|
||||
):
|
||||
return AdminCapabilityResponse(
|
||||
service="orquestrador-admin",
|
||||
action="manage_settings",
|
||||
allowed=True,
|
||||
role=current_staff.role,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/configuration",
|
||||
response_model=AdminSystemConfigurationResponse,
|
||||
)
|
||||
def system_configuration(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
security_service: AdminSecurityService = Depends(get_security_service),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.MANAGE_SETTINGS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings, security_service)
|
||||
runtime_payload = _build_runtime_configuration_payload(service, settings)
|
||||
return AdminSystemConfigurationResponse(
|
||||
service="orquestrador-admin",
|
||||
runtime=runtime_payload,
|
||||
security=service.build_security_configuration_payload(),
|
||||
model_runtimes=service.build_model_runtime_separation_payload(),
|
||||
write_governance=service.build_write_governance_payload(),
|
||||
sources=service.build_configuration_sources_payload(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/configuration/runtime",
|
||||
response_model=AdminSystemRuntimeConfigurationResponse,
|
||||
)
|
||||
def system_runtime_configuration(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
security_service: AdminSecurityService = Depends(get_security_service),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.MANAGE_SETTINGS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings, security_service)
|
||||
return AdminSystemRuntimeConfigurationResponse(
|
||||
service="orquestrador-admin",
|
||||
runtime=_build_runtime_configuration_payload(service, settings),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/configuration/security",
|
||||
response_model=AdminSystemSecurityConfigurationResponse,
|
||||
)
|
||||
def system_security_configuration(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
security_service: AdminSecurityService = Depends(get_security_service),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.MANAGE_SETTINGS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings, security_service)
|
||||
return AdminSystemSecurityConfigurationResponse(
|
||||
service="orquestrador-admin",
|
||||
security=service.build_security_configuration_payload(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/configuration/model-runtimes",
|
||||
response_model=AdminSystemModelRuntimeSeparationResponse,
|
||||
)
|
||||
def system_model_runtime_separation(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
security_service: AdminSecurityService = Depends(get_security_service),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.MANAGE_SETTINGS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings, security_service)
|
||||
return AdminSystemModelRuntimeSeparationResponse(
|
||||
service="orquestrador-admin",
|
||||
model_runtimes=service.build_model_runtime_separation_payload(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/configuration/write-governance",
|
||||
response_model=AdminSystemWriteGovernanceResponse,
|
||||
)
|
||||
def system_write_governance_configuration(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
security_service: AdminSecurityService = Depends(get_security_service),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.MANAGE_SETTINGS)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings, security_service)
|
||||
return AdminSystemWriteGovernanceResponse(
|
||||
service="orquestrador-admin",
|
||||
write_governance=service.build_write_governance_payload(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/configuration/functional",
|
||||
response_model=AdminSystemFunctionalConfigurationCatalogResponse,
|
||||
)
|
||||
def system_functional_configuration_catalog(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
security_service: AdminSecurityService = Depends(get_security_service),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_SYSTEM)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings, security_service)
|
||||
payload = service.build_functional_configuration_catalog_payload()
|
||||
return AdminSystemFunctionalConfigurationCatalogResponse(
|
||||
service="orquestrador-admin",
|
||||
mode=payload["mode"],
|
||||
configurations=payload["configurations"],
|
||||
bot_governed_parent_config_keys=payload["bot_governed_parent_config_keys"],
|
||||
next_steps=payload["next_steps"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/configuration/functional/bot-governance",
|
||||
response_model=AdminSystemBotGovernedConfigurationResponse,
|
||||
)
|
||||
def system_bot_governed_configuration(
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
security_service: AdminSecurityService = Depends(get_security_service),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_SYSTEM)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings, security_service)
|
||||
payload = service.build_bot_governed_configuration_payload()
|
||||
return AdminSystemBotGovernedConfigurationResponse(
|
||||
service="orquestrador-admin",
|
||||
parent_config_keys=payload["parent_config_keys"],
|
||||
settings=payload["settings"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/configuration/functional/{config_key}",
|
||||
response_model=AdminSystemFunctionalConfigurationDetailResponse,
|
||||
)
|
||||
def system_functional_configuration_detail(
|
||||
config_key: str,
|
||||
settings: AdminSettings = Depends(get_settings),
|
||||
security_service: AdminSecurityService = Depends(get_security_service),
|
||||
_: AuthenticatedStaffPrincipal = Depends(
|
||||
require_admin_permission(AdminPermission.VIEW_SYSTEM)
|
||||
),
|
||||
):
|
||||
service = _build_service(settings, security_service)
|
||||
payload = service.get_functional_configuration_payload(config_key)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Configuracao funcional do sistema nao encontrada.",
|
||||
)
|
||||
|
||||
return AdminSystemFunctionalConfigurationDetailResponse(
|
||||
service="orquestrador-admin",
|
||||
configuration=payload["configuration"],
|
||||
linked_bot_settings=payload["linked_bot_settings"],
|
||||
related_runtime_profile=payload["related_runtime_profile"],
|
||||
managed_by_bot_governance=payload["managed_by_bot_governance"],
|
||||
)
|
||||
|
||||
|
||||
def _build_runtime_configuration_payload(
|
||||
service: SystemService,
|
||||
settings: AdminSettings,
|
||||
) -> dict:
|
||||
runtime_payload = service.build_runtime_configuration_payload()
|
||||
runtime_payload["panel_session"] = {
|
||||
"access_cookie_name": PANEL_ACCESS_COOKIE_NAME,
|
||||
"refresh_cookie_name": PANEL_REFRESH_COOKIE_NAME,
|
||||
"cookie_path": build_panel_cookie_path(settings),
|
||||
"same_site": PANEL_COOKIE_SAMESITE,
|
||||
"secure_cookies": should_use_secure_cookies(settings),
|
||||
}
|
||||
return runtime_payload
|
||||
|
||||
|
||||
def _build_prefixed_path(api_prefix: str, path: str) -> str:
|
||||
normalized_prefix = api_prefix.rstrip("/")
|
||||
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||
if not normalized_prefix:
|
||||
return normalized_path
|
||||
if normalized_path == "/":
|
||||
return f"{normalized_prefix}/"
|
||||
return f"{normalized_prefix}{normalized_path}"
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,13 @@
|
||||
from admin_app.catalogs.tool_governance_catalog import (
|
||||
BOOTSTRAP_TOOL_CATALOG,
|
||||
INTAKE_DOMAIN_OPTIONS,
|
||||
BootstrapToolCatalogEntry,
|
||||
ToolIntakeDomainOption,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BOOTSTRAP_TOOL_CATALOG",
|
||||
"INTAKE_DOMAIN_OPTIONS",
|
||||
"BootstrapToolCatalogEntry",
|
||||
"ToolIntakeDomainOption",
|
||||
]
|
||||
@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BootstrapToolCatalogEntry:
|
||||
tool_name: str
|
||||
display_name: str
|
||||
description: str
|
||||
domain: str
|
||||
parameter_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolIntakeDomainOption:
|
||||
value: str
|
||||
label: str
|
||||
description: str
|
||||
|
||||
|
||||
BOOTSTRAP_TOOL_CATALOG: tuple[BootstrapToolCatalogEntry, ...] = (
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="consultar_estoque",
|
||||
display_name="Consultar estoque",
|
||||
description="Consulta veiculos disponiveis no estoque comercial.",
|
||||
domain="vendas",
|
||||
parameter_count=4,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="validar_cliente_venda",
|
||||
display_name="Validar cliente para venda",
|
||||
description="Avalia elegibilidade de credito para operacoes de venda.",
|
||||
domain="vendas",
|
||||
parameter_count=2,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="avaliar_veiculo_troca",
|
||||
display_name="Avaliar veiculo de troca",
|
||||
description="Estima o valor de entrada de um veiculo usado.",
|
||||
domain="vendas",
|
||||
parameter_count=3,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="agendar_revisao",
|
||||
display_name="Agendar revisao",
|
||||
description="Abre um agendamento de revisao ou manutencao.",
|
||||
domain="revisao",
|
||||
parameter_count=6,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="listar_agendamentos_revisao",
|
||||
display_name="Listar agendamentos de revisao",
|
||||
description="Consulta a fila de agendamentos de revisao do cliente.",
|
||||
domain="revisao",
|
||||
parameter_count=3,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="cancelar_agendamento_revisao",
|
||||
display_name="Cancelar agendamento de revisao",
|
||||
description="Cancela um agendamento existente por protocolo.",
|
||||
domain="revisao",
|
||||
parameter_count=2,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="editar_data_revisao",
|
||||
display_name="Editar data de revisao",
|
||||
description="Remarca uma revisao para um novo horario.",
|
||||
domain="revisao",
|
||||
parameter_count=2,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="realizar_pedido",
|
||||
display_name="Realizar pedido",
|
||||
description="Efetiva um pedido de compra com o veiculo escolhido.",
|
||||
domain="vendas",
|
||||
parameter_count=2,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="listar_pedidos",
|
||||
display_name="Listar pedidos",
|
||||
description="Consulta pedidos ja abertos pelo cliente.",
|
||||
domain="vendas",
|
||||
parameter_count=3,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="cancelar_pedido",
|
||||
display_name="Cancelar pedido",
|
||||
description="Cancela um pedido existente com motivo registrado.",
|
||||
domain="vendas",
|
||||
parameter_count=2,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="consultar_frota_aluguel",
|
||||
display_name="Consultar frota de aluguel",
|
||||
description="Lista veiculos disponiveis para locacao.",
|
||||
domain="locacao",
|
||||
parameter_count=6,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="abrir_locacao_aluguel",
|
||||
display_name="Abrir locacao de aluguel",
|
||||
description="Inicia um contrato de locacao de veiculo.",
|
||||
domain="locacao",
|
||||
parameter_count=7,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="registrar_devolucao_aluguel",
|
||||
display_name="Registrar devolucao de aluguel",
|
||||
description="Fecha uma locacao e devolve o veiculo para a frota.",
|
||||
domain="locacao",
|
||||
parameter_count=4,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="registrar_pagamento_aluguel",
|
||||
display_name="Registrar pagamento de aluguel",
|
||||
description="Registra comprovantes e pagamentos de contratos de locacao.",
|
||||
domain="locacao",
|
||||
parameter_count=7,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="limpar_contexto_conversa",
|
||||
display_name="Limpar contexto de conversa",
|
||||
description="Reinicia o contexto operacional atual do atendimento.",
|
||||
domain="orquestracao",
|
||||
parameter_count=1,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="continuar_proximo_pedido",
|
||||
display_name="Continuar proximo pedido",
|
||||
description="Retoma o proximo pedido pendente do fluxo atual.",
|
||||
domain="orquestracao",
|
||||
parameter_count=0,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="descartar_pedidos_pendentes",
|
||||
display_name="Descartar pedidos pendentes",
|
||||
description="Descarta apenas a fila pendente de pedidos do contexto.",
|
||||
domain="orquestracao",
|
||||
parameter_count=1,
|
||||
),
|
||||
BootstrapToolCatalogEntry(
|
||||
tool_name="cancelar_fluxo_atual",
|
||||
display_name="Cancelar fluxo atual",
|
||||
description="Interrompe o fluxo corrente sem apagar todo o contexto.",
|
||||
domain="orquestracao",
|
||||
parameter_count=1,
|
||||
),
|
||||
)
|
||||
|
||||
INTAKE_DOMAIN_OPTIONS: tuple[ToolIntakeDomainOption, ...] = (
|
||||
ToolIntakeDomainOption(
|
||||
value="vendas",
|
||||
label="Vendas",
|
||||
description="Ferramentas para estoque, negociacao, pedido e conversao comercial.",
|
||||
),
|
||||
ToolIntakeDomainOption(
|
||||
value="revisao",
|
||||
label="Revisao",
|
||||
description="Ferramentas para agendamento, remarcacao e operacao da oficina.",
|
||||
),
|
||||
ToolIntakeDomainOption(
|
||||
value="locacao",
|
||||
label="Locacao",
|
||||
description="Ferramentas para frota, contratos, devolucao e arrecadacao de aluguel.",
|
||||
),
|
||||
ToolIntakeDomainOption(
|
||||
value="orquestracao",
|
||||
label="Orquestracao",
|
||||
description="Ferramentas internas para fluxo conversacional, contexto e decisao do bot.",
|
||||
),
|
||||
)
|
||||
@ -0,0 +1,22 @@
|
||||
"""Configuracoes centrais do servico administrativo."""
|
||||
|
||||
from admin_app.core.security import (
|
||||
AdminAccessTokenClaims,
|
||||
AdminAuthenticatedSession,
|
||||
AdminCredentialStrategy,
|
||||
AdminSecurityService,
|
||||
AuthenticatedStaffContext,
|
||||
AuthenticatedStaffPrincipal,
|
||||
)
|
||||
from admin_app.core.settings import AdminSettings, get_admin_settings
|
||||
|
||||
__all__ = [
|
||||
"AdminAccessTokenClaims",
|
||||
"AdminAuthenticatedSession",
|
||||
"AdminCredentialStrategy",
|
||||
"AdminSecurityService",
|
||||
"AdminSettings",
|
||||
"AuthenticatedStaffContext",
|
||||
"AuthenticatedStaffPrincipal",
|
||||
"get_admin_settings",
|
||||
]
|
||||
@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from admin_app.core.settings import AdminSettings
|
||||
from shared.contracts import StaffRole, normalize_staff_role
|
||||
|
||||
|
||||
class AdminPasswordPolicy(BaseModel):
|
||||
hash_scheme: str
|
||||
hash_iterations: int
|
||||
min_length: int
|
||||
require_uppercase: bool
|
||||
require_lowercase: bool
|
||||
require_digit: bool
|
||||
require_symbol: bool
|
||||
pepper_configured: bool
|
||||
|
||||
|
||||
class AdminTokenPolicy(BaseModel):
|
||||
access_token_ttl_minutes: int
|
||||
refresh_token_ttl_days: int
|
||||
refresh_token_bytes: int
|
||||
issuer: str
|
||||
|
||||
|
||||
class AdminBootstrapPolicy(BaseModel):
|
||||
enabled: bool
|
||||
email: str | None
|
||||
display_name: str | None
|
||||
role: str
|
||||
password_configured: bool
|
||||
|
||||
|
||||
class AdminCredentialStrategy(BaseModel):
|
||||
password: AdminPasswordPolicy
|
||||
tokens: AdminTokenPolicy
|
||||
bootstrap: AdminBootstrapPolicy
|
||||
|
||||
|
||||
class AuthenticatedStaffPrincipal(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
display_name: str
|
||||
role: StaffRole
|
||||
is_active: bool
|
||||
|
||||
@field_validator("role", mode="before")
|
||||
@classmethod
|
||||
def normalize_role(cls, value):
|
||||
return normalize_staff_role(value)
|
||||
|
||||
|
||||
class AuthenticatedStaffContext(BaseModel):
|
||||
principal: AuthenticatedStaffPrincipal
|
||||
session_id: int
|
||||
|
||||
|
||||
class AdminAccessTokenClaims(BaseModel):
|
||||
sub: str
|
||||
sid: int
|
||||
email: str
|
||||
role: StaffRole
|
||||
token_type: str
|
||||
iss: str
|
||||
iat: int
|
||||
exp: int
|
||||
|
||||
@field_validator("role", mode="before")
|
||||
@classmethod
|
||||
def normalize_role(cls, value):
|
||||
return normalize_staff_role(value)
|
||||
|
||||
|
||||
class AdminAuthenticatedSession(BaseModel):
|
||||
session_id: int
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in_seconds: int
|
||||
principal: AuthenticatedStaffPrincipal
|
||||
|
||||
|
||||
class AdminSecurityService:
|
||||
def __init__(self, settings: AdminSettings):
|
||||
self.settings = settings
|
||||
|
||||
def build_credential_strategy(self) -> AdminCredentialStrategy:
|
||||
return AdminCredentialStrategy(
|
||||
password=AdminPasswordPolicy(
|
||||
hash_scheme=self.settings.admin_auth_password_hash_scheme,
|
||||
hash_iterations=self.settings.admin_auth_password_hash_iterations,
|
||||
min_length=self.settings.admin_auth_password_min_length,
|
||||
require_uppercase=self.settings.admin_auth_password_require_uppercase,
|
||||
require_lowercase=self.settings.admin_auth_password_require_lowercase,
|
||||
require_digit=self.settings.admin_auth_password_require_digit,
|
||||
require_symbol=self.settings.admin_auth_password_require_symbol,
|
||||
pepper_configured=bool(self.settings.admin_auth_password_pepper),
|
||||
),
|
||||
tokens=AdminTokenPolicy(
|
||||
access_token_ttl_minutes=self.settings.admin_auth_access_token_ttl_minutes,
|
||||
refresh_token_ttl_days=self.settings.admin_auth_refresh_token_ttl_days,
|
||||
refresh_token_bytes=self.settings.admin_auth_refresh_token_bytes,
|
||||
issuer=self.settings.admin_auth_token_issuer,
|
||||
),
|
||||
bootstrap=AdminBootstrapPolicy(
|
||||
enabled=self.settings.admin_bootstrap_enabled,
|
||||
email=self.settings.admin_bootstrap_email,
|
||||
display_name=self.settings.admin_bootstrap_display_name,
|
||||
role=normalize_staff_role(self.settings.admin_bootstrap_role or StaffRole.DIRETOR.value).value,
|
||||
password_configured=bool(self.settings.admin_bootstrap_password),
|
||||
),
|
||||
)
|
||||
|
||||
def validate_password_strength(self, password: str) -> None:
|
||||
if len(password) < self.settings.admin_auth_password_min_length:
|
||||
raise ValueError("Password does not meet minimum length policy")
|
||||
if self.settings.admin_auth_password_require_uppercase and not any(char.isupper() for char in password):
|
||||
raise ValueError("Password must include an uppercase letter")
|
||||
if self.settings.admin_auth_password_require_lowercase and not any(char.islower() for char in password):
|
||||
raise ValueError("Password must include a lowercase letter")
|
||||
if self.settings.admin_auth_password_require_digit and not any(char.isdigit() for char in password):
|
||||
raise ValueError("Password must include a digit")
|
||||
if self.settings.admin_auth_password_require_symbol and not any(not char.isalnum() for char in password):
|
||||
raise ValueError("Password must include a symbol")
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
self.validate_password_strength(password)
|
||||
if self.settings.admin_auth_password_hash_scheme != "pbkdf2_sha256":
|
||||
raise ValueError("Unsupported password hash scheme")
|
||||
|
||||
salt = secrets.token_hex(16)
|
||||
digest = self._pbkdf2_digest(password=password, salt=salt, iterations=self.settings.admin_auth_password_hash_iterations)
|
||||
return f"pbkdf2_sha256${self.settings.admin_auth_password_hash_iterations}${salt}${digest}"
|
||||
|
||||
def verify_password(self, password: str, stored_hash: str) -> bool:
|
||||
try:
|
||||
scheme, iterations_raw, salt, digest = stored_hash.split("$", 3)
|
||||
except ValueError:
|
||||
return False
|
||||
if scheme != "pbkdf2_sha256":
|
||||
return False
|
||||
try:
|
||||
iterations = int(iterations_raw)
|
||||
except ValueError:
|
||||
return False
|
||||
expected_digest = self._pbkdf2_digest(password=password, salt=salt, iterations=iterations)
|
||||
return hmac.compare_digest(expected_digest, digest)
|
||||
|
||||
def issue_access_token(self, principal: AuthenticatedStaffPrincipal, session_id: int) -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = now + timedelta(minutes=self.settings.admin_auth_access_token_ttl_minutes)
|
||||
payload = {
|
||||
"sub": str(principal.id),
|
||||
"sid": session_id,
|
||||
"email": principal.email,
|
||||
"role": principal.role.value,
|
||||
"token_type": "access",
|
||||
"iss": self.settings.admin_auth_token_issuer,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expires_at.timestamp()),
|
||||
}
|
||||
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
signature = hmac.new(self.settings.admin_auth_token_secret.encode("utf-8"), payload_bytes, hashlib.sha256).digest()
|
||||
return f"{self._urlsafe_b64encode(payload_bytes)}.{self._urlsafe_b64encode(signature)}"
|
||||
|
||||
def decode_access_token(self, token: str) -> AdminAccessTokenClaims:
|
||||
try:
|
||||
encoded_payload, encoded_signature = token.split(".", 1)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid token format") from exc
|
||||
|
||||
payload_bytes = self._urlsafe_b64decode(encoded_payload)
|
||||
provided_signature = self._urlsafe_b64decode(encoded_signature)
|
||||
expected_signature = hmac.new(self.settings.admin_auth_token_secret.encode("utf-8"), payload_bytes, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(provided_signature, expected_signature):
|
||||
raise ValueError("Invalid token signature")
|
||||
|
||||
payload = json.loads(payload_bytes.decode("utf-8"))
|
||||
claims = AdminAccessTokenClaims.model_validate(payload)
|
||||
if claims.iss != self.settings.admin_auth_token_issuer:
|
||||
raise ValueError("Invalid token issuer")
|
||||
if claims.token_type != "access":
|
||||
raise ValueError("Invalid token type")
|
||||
if claims.exp < int(datetime.now(timezone.utc).timestamp()):
|
||||
raise ValueError("Expired token")
|
||||
return claims
|
||||
|
||||
def generate_refresh_token(self) -> str:
|
||||
return secrets.token_urlsafe(self.settings.admin_auth_refresh_token_bytes)
|
||||
|
||||
def hash_refresh_token(self, refresh_token: str) -> str:
|
||||
return hmac.new(
|
||||
self.settings.admin_auth_token_secret.encode("utf-8"),
|
||||
refresh_token.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
def build_refresh_token_expiry(self) -> datetime:
|
||||
return datetime.now(timezone.utc) + timedelta(days=self.settings.admin_auth_refresh_token_ttl_days)
|
||||
|
||||
def _pbkdf2_digest(self, password: str, salt: str, iterations: int) -> str:
|
||||
material = password
|
||||
if self.settings.admin_auth_password_pepper:
|
||||
material = f"{material}{self.settings.admin_auth_password_pepper}"
|
||||
digest = hashlib.pbkdf2_hmac("sha256", material.encode("utf-8"), salt.encode("utf-8"), iterations)
|
||||
return digest.hex()
|
||||
|
||||
@staticmethod
|
||||
def _urlsafe_b64encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||
|
||||
@staticmethod
|
||||
def _urlsafe_b64decode(value: str) -> bytes:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(f"{value}{padding}")
|
||||
@ -0,0 +1,60 @@
|
||||
"""
|
||||
Rotina dedicada de bootstrap do banco administrativo.
|
||||
Cria tabelas do dominio administrativo de forma explicita, fora do startup do app.
|
||||
"""
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from admin_app.db.database import AdminBase, admin_engine
|
||||
from admin_app.db.models import AuditLog, StaffAccount, StaffSession, ToolArtifact, ToolDraft, ToolMetadata, ToolVersion
|
||||
|
||||
_REGISTERED_MODELS = (AuditLog, StaffAccount, StaffSession, ToolArtifact, ToolDraft, ToolMetadata, ToolVersion)
|
||||
|
||||
|
||||
def _ensure_admin_schema_evolution() -> None:
|
||||
inspector = inspect(admin_engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
|
||||
if "tool_drafts" in table_names:
|
||||
tool_draft_columns = {column["name"] for column in inspector.get_columns("tool_drafts")}
|
||||
statements: list[str] = []
|
||||
if "current_version_number" not in tool_draft_columns:
|
||||
statements.append("ALTER TABLE tool_drafts ADD COLUMN current_version_number INT NOT NULL DEFAULT 1")
|
||||
if "version_count" not in tool_draft_columns:
|
||||
statements.append("ALTER TABLE tool_drafts ADD COLUMN version_count INT NOT NULL DEFAULT 1")
|
||||
if "generation_model" not in tool_draft_columns:
|
||||
statements.append("ALTER TABLE tool_drafts ADD COLUMN generation_model VARCHAR(120)")
|
||||
if statements:
|
||||
with admin_engine.begin() as connection:
|
||||
for statement in statements:
|
||||
connection.execute(text(statement))
|
||||
|
||||
if "tool_versions" in table_names:
|
||||
tool_version_columns = {column["name"] for column in inspector.get_columns("tool_versions")}
|
||||
statements = []
|
||||
if "generation_model" not in tool_version_columns:
|
||||
statements.append("ALTER TABLE tool_versions ADD COLUMN generation_model VARCHAR(120)")
|
||||
if statements:
|
||||
with admin_engine.begin() as connection:
|
||||
for statement in statements:
|
||||
connection.execute(text(statement))
|
||||
|
||||
|
||||
def bootstrap_admin_database() -> None:
|
||||
"""Cria o schema administrativo sem executar seed implicita."""
|
||||
print("Inicializando schema administrativo...")
|
||||
try:
|
||||
AdminBase.metadata.create_all(bind=admin_engine)
|
||||
_ensure_admin_schema_evolution()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Falha ao inicializar banco administrativo: {exc}") from exc
|
||||
|
||||
print("Schema administrativo inicializado com sucesso!")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
bootstrap_admin_database()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -0,0 +1,55 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session, declarative_base, sessionmaker
|
||||
|
||||
from admin_app.core.settings import get_admin_settings
|
||||
from admin_app.db.write_governance import enforce_admin_session_write_governance
|
||||
|
||||
# monta a conexão do banco administrativo e expõe get_admin_db_session(). Esse generator é o que alimenta as dependências FastAPI para repositórios e serviços.
|
||||
|
||||
settings = get_admin_settings()
|
||||
admin_cloud_sql = settings.admin_db_cloud_sql_connection_name
|
||||
|
||||
if admin_cloud_sql:
|
||||
ADMIN_DATABASE_URL = (
|
||||
f"mysql+pymysql://{settings.admin_db_user}:{settings.admin_db_password}@/{settings.admin_db_name}"
|
||||
f"?unix_socket=/cloudsql/{admin_cloud_sql}"
|
||||
)
|
||||
else:
|
||||
ADMIN_DATABASE_URL = (
|
||||
f"mysql+pymysql://{settings.admin_db_user}:{settings.admin_db_password}@"
|
||||
f"{settings.admin_db_host}:{settings.admin_db_port}/{settings.admin_db_name}"
|
||||
)
|
||||
|
||||
admin_engine = create_engine(
|
||||
ADMIN_DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
connect_args={"connect_timeout": 5},
|
||||
)
|
||||
|
||||
AdminSessionLocal = sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=admin_engine,
|
||||
)
|
||||
|
||||
|
||||
@event.listens_for(AdminSessionLocal, "before_flush")
|
||||
def _block_unguarded_admin_writes(session, flush_context, instances):
|
||||
enforce_admin_session_write_governance(
|
||||
new=session.new,
|
||||
dirty=session.dirty,
|
||||
deleted=session.deleted,
|
||||
)
|
||||
|
||||
|
||||
AdminBase = declarative_base()
|
||||
|
||||
|
||||
def get_admin_db_session() -> Generator[Session, None, None]:
|
||||
db = AdminSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@ -0,0 +1,12 @@
|
||||
"""Alias legado para o bootstrap explicito do banco administrativo."""
|
||||
|
||||
from admin_app.db.bootstrap import bootstrap_admin_database
|
||||
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
bootstrap_admin_database()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
@ -0,0 +1,19 @@
|
||||
from admin_app.db.models.audit_log import AuditLog
|
||||
from admin_app.db.models.base import AdminTimestampedModel
|
||||
from admin_app.db.models.staff_account import StaffAccount
|
||||
from admin_app.db.models.staff_session import StaffSession
|
||||
from admin_app.db.models.tool_artifact import ToolArtifact
|
||||
from admin_app.db.models.tool_draft import ToolDraft
|
||||
from admin_app.db.models.tool_metadata import ToolMetadata
|
||||
from admin_app.db.models.tool_version import ToolVersion
|
||||
|
||||
__all__ = [
|
||||
"AdminTimestampedModel",
|
||||
"AuditLog",
|
||||
"StaffAccount",
|
||||
"StaffSession",
|
||||
"ToolArtifact",
|
||||
"ToolDraft",
|
||||
"ToolMetadata",
|
||||
"ToolVersion",
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from admin_app.db.models.base import AdminTimestampedModel
|
||||
|
||||
|
||||
class AuditLog(AdminTimestampedModel):
|
||||
__tablename__ = "admin_audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
actor_staff_account_id: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("staff_accounts.id"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
resource_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
outcome: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
@ -0,0 +1,17 @@
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from admin_app.db.database import AdminBase
|
||||
|
||||
|
||||
# Base abstrata com timestamps para futuras entidades administrativas.
|
||||
class AdminTimestampedModel(AdminBase):
|
||||
__abstract__ = True
|
||||
|
||||
created_at: Mapped[object] = mapped_column(DateTime, server_default=func.current_timestamp())
|
||||
updated_at: Mapped[object] = mapped_column(
|
||||
DateTime,
|
||||
server_default=func.current_timestamp(),
|
||||
onupdate=func.current_timestamp(),
|
||||
)
|
||||
@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
from admin_app.db.models.base import AdminTimestampedModel
|
||||
from shared.contracts import StaffRole, normalize_staff_role
|
||||
|
||||
# Modelo da conta administrativa
|
||||
# Ele representa o usuario interno do painel.
|
||||
|
||||
|
||||
class StaffRoleType(TypeDecorator):
|
||||
impl = String(32)
|
||||
cache_ok = True
|
||||
|
||||
@property
|
||||
def python_type(self):
|
||||
return StaffRole
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_staff_role(value).value
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
return normalize_staff_role(value)
|
||||
|
||||
|
||||
class StaffAccount(AdminTimestampedModel):
|
||||
__tablename__ = "staff_accounts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[StaffRole] = mapped_column(
|
||||
StaffRoleType(),
|
||||
nullable=False,
|
||||
default=StaffRole.COLABORADOR,
|
||||
)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from admin_app.db.models.base import AdminTimestampedModel
|
||||
|
||||
|
||||
class StaffSession(AdminTimestampedModel):
|
||||
__tablename__ = "staff_sessions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
staff_account_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("staff_accounts.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
refresh_token_hash: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
unique=True,
|
||||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
from admin_app.db.models.base import AdminTimestampedModel
|
||||
|
||||
|
||||
class ToolArtifactStage(str, Enum):
|
||||
GENERATION = "generation"
|
||||
VALIDATION = "validation"
|
||||
GOVERNANCE = "governance"
|
||||
|
||||
|
||||
class ToolArtifactKind(str, Enum):
|
||||
GENERATION_REQUEST = "generation_request"
|
||||
VALIDATION_REPORT = "validation_report"
|
||||
GENERATION_AUTHORIZATION = "generation_authorization"
|
||||
GENERATION_CHANGE_REQUEST = "generation_change_request"
|
||||
PROPOSAL_CLOSURE = "proposal_closure"
|
||||
DIRECTOR_REVIEW = "director_review"
|
||||
DIRECTOR_APPROVAL = "director_approval"
|
||||
PUBLICATION_RELEASE = "publication_release"
|
||||
PUBLICATION_DEACTIVATION = "publication_deactivation"
|
||||
PUBLICATION_ROLLBACK = "publication_rollback"
|
||||
|
||||
|
||||
class ToolArtifactStorageKind(str, Enum):
|
||||
INLINE_JSON = "inline_json"
|
||||
|
||||
|
||||
class ToolArtifactStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class ToolArtifactEnumType(TypeDecorator):
|
||||
impl = String(40)
|
||||
cache_ok = True
|
||||
|
||||
def __init__(self, enum_cls: type[Enum], *, length: int = 40):
|
||||
super().__init__(length=length)
|
||||
self.enum_cls = enum_cls
|
||||
|
||||
@property
|
||||
def python_type(self):
|
||||
return self.enum_cls
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, self.enum_cls):
|
||||
return value.value
|
||||
return self.enum_cls(str(value).strip().lower()).value
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
return self.enum_cls(str(value).strip().lower())
|
||||
|
||||
|
||||
class ToolArtifact(AdminTimestampedModel):
|
||||
__tablename__ = "tool_artifacts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tool_version_id",
|
||||
"artifact_kind",
|
||||
name="uq_tool_artifacts_tool_version_kind",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
artifact_id: Mapped[str] = mapped_column(String(140), unique=True, index=True, nullable=False)
|
||||
draft_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("tool_drafts.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
tool_version_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("tool_versions.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
tool_name: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
artifact_stage: Mapped[ToolArtifactStage] = mapped_column(
|
||||
ToolArtifactEnumType(ToolArtifactStage),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
artifact_kind: Mapped[ToolArtifactKind] = mapped_column(
|
||||
ToolArtifactEnumType(ToolArtifactKind),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
artifact_status: Mapped[ToolArtifactStatus] = mapped_column(
|
||||
ToolArtifactEnumType(ToolArtifactStatus),
|
||||
nullable=False,
|
||||
default=ToolArtifactStatus.PENDING,
|
||||
index=True,
|
||||
)
|
||||
storage_kind: Mapped[ToolArtifactStorageKind] = mapped_column(
|
||||
ToolArtifactEnumType(ToolArtifactStorageKind),
|
||||
nullable=False,
|
||||
default=ToolArtifactStorageKind.INLINE_JSON,
|
||||
)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
payload_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
checksum: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
author_staff_account_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("staff_accounts.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
author_display_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
from admin_app.db.models.base import AdminTimestampedModel
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class ToolLifecycleStatusType(TypeDecorator):
|
||||
impl = String(32)
|
||||
cache_ok = True
|
||||
|
||||
@property
|
||||
def python_type(self):
|
||||
return ToolLifecycleStatus
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, ToolLifecycleStatus):
|
||||
return value.value
|
||||
return ToolLifecycleStatus(str(value).strip().lower()).value
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return None
|
||||
return ToolLifecycleStatus(str(value).strip().lower())
|
||||
|
||||
|
||||
class ToolDraft(AdminTimestampedModel):
|
||||
__tablename__ = "tool_drafts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
draft_id: Mapped[str] = mapped_column(String(40), unique=True, index=True, nullable=False)
|
||||
tool_name: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
domain: Mapped[str] = mapped_column(String(40), index=True, nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
business_goal: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[ToolLifecycleStatus] = mapped_column(
|
||||
ToolLifecycleStatusType(),
|
||||
nullable=False,
|
||||
default=ToolLifecycleStatus.DRAFT,
|
||||
index=True,
|
||||
)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
parameters_json: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
|
||||
required_parameter_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
current_version_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
version_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
requires_director_approval: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
)
|
||||
generation_model: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
owner_staff_account_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("staff_accounts.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
owner_display_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from admin_app.db.models.base import AdminTimestampedModel
|
||||
from admin_app.db.models.tool_draft import ToolLifecycleStatusType
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class ToolMetadata(AdminTimestampedModel):
|
||||
__tablename__ = "tool_metadata"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tool_name",
|
||||
"version_number",
|
||||
name="uq_tool_metadata_tool_name_version_number",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
metadata_id: Mapped[str] = mapped_column(String(120), unique=True, index=True, nullable=False)
|
||||
draft_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("tool_drafts.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
tool_version_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("tool_versions.id"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
index=True,
|
||||
)
|
||||
tool_name: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
domain: Mapped[str] = mapped_column(String(40), index=True, nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
parameters_json: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
|
||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[ToolLifecycleStatus] = mapped_column(
|
||||
ToolLifecycleStatusType(),
|
||||
nullable=False,
|
||||
default=ToolLifecycleStatus.DRAFT,
|
||||
index=True,
|
||||
)
|
||||
author_staff_account_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("staff_accounts.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
author_display_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from admin_app.db.models.base import AdminTimestampedModel
|
||||
from admin_app.db.models.tool_draft import ToolLifecycleStatusType
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class ToolVersion(AdminTimestampedModel):
|
||||
__tablename__ = "tool_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tool_name",
|
||||
"version_number",
|
||||
name="uq_tool_versions_tool_name_version_number",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
version_id: Mapped[str] = mapped_column(String(120), unique=True, index=True, nullable=False)
|
||||
draft_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("tool_drafts.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
tool_name: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[ToolLifecycleStatus] = mapped_column(
|
||||
ToolLifecycleStatusType(),
|
||||
nullable=False,
|
||||
default=ToolLifecycleStatus.DRAFT,
|
||||
index=True,
|
||||
)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
business_goal: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
parameters_json: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
|
||||
required_parameter_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
requires_director_approval: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
)
|
||||
generation_model: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
owner_staff_account_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("staff_accounts.id"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
owner_display_name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from shared.contracts import (
|
||||
PRODUCT_OPERATIONAL_DATASETS,
|
||||
SYSTEM_FUNCTIONAL_CONFIGURATIONS,
|
||||
FunctionalConfigurationPropagation,
|
||||
)
|
||||
|
||||
ALLOWED_ADMIN_WRITE_TABLES: tuple[str, ...] = (
|
||||
"admin_audit_logs",
|
||||
"staff_accounts",
|
||||
"staff_sessions",
|
||||
"tool_drafts",
|
||||
"tool_versions",
|
||||
"tool_metadata",
|
||||
"tool_artifacts",
|
||||
)
|
||||
|
||||
|
||||
class AdminWriteGovernanceViolation(RuntimeError):
|
||||
"""Raised when the admin runtime attempts an ungoverned direct write."""
|
||||
|
||||
|
||||
def ensure_direct_admin_write_allowed(table_name: str) -> None:
|
||||
normalized_table_name = str(table_name or "").strip().lower()
|
||||
if normalized_table_name in ALLOWED_ADMIN_WRITE_TABLES:
|
||||
return
|
||||
|
||||
raise AdminWriteGovernanceViolation(
|
||||
"Escrita direta do admin bloqueada para a tabela "
|
||||
f"'{normalized_table_name or 'desconhecida'}'. "
|
||||
"Use um fluxo governado, versionado e auditavel antes de publicar qualquer efeito no product."
|
||||
)
|
||||
|
||||
|
||||
def enforce_admin_session_write_governance(
|
||||
*,
|
||||
new: Iterable[object] = (),
|
||||
dirty: Iterable[object] = (),
|
||||
deleted: Iterable[object] = (),
|
||||
) -> None:
|
||||
seen_tables: set[str] = set()
|
||||
for instance in (*tuple(new), *tuple(dirty), *tuple(deleted)):
|
||||
table_name = _resolve_table_name(instance)
|
||||
if table_name is None or table_name in seen_tables:
|
||||
continue
|
||||
ensure_direct_admin_write_allowed(table_name)
|
||||
seen_tables.add(table_name)
|
||||
|
||||
|
||||
def build_admin_write_governance_payload() -> dict:
|
||||
governed_configuration_keys = sorted(
|
||||
configuration.config_key
|
||||
for configuration in SYSTEM_FUNCTIONAL_CONFIGURATIONS
|
||||
if configuration.propagation == FunctionalConfigurationPropagation.VERSIONED_PUBLICATION
|
||||
)
|
||||
return {
|
||||
"mode": "admin_internal_tables_only",
|
||||
"allowed_direct_write_tables": list(ALLOWED_ADMIN_WRITE_TABLES),
|
||||
"blocked_operational_dataset_keys": sorted(
|
||||
dataset.dataset_key for dataset in PRODUCT_OPERATIONAL_DATASETS
|
||||
),
|
||||
"blocked_product_source_tables": sorted(
|
||||
{dataset.source_table for dataset in PRODUCT_OPERATIONAL_DATASETS}
|
||||
),
|
||||
"governed_configuration_keys": governed_configuration_keys,
|
||||
"enforcement_points": [
|
||||
"AdminSession.before_flush bloqueia escrita ORM fora do allowlist interno do admin.",
|
||||
"Contratos compartilhados mantem datasets operacionais com write_allowed=false.",
|
||||
"Configuracoes que afetam o runtime do product seguem versioned_publication antes de qualquer efeito operacional.",
|
||||
],
|
||||
"governance_rules": [
|
||||
"O admin nao escreve diretamente nas tabelas operacionais do product.",
|
||||
"Toda alteracao com efeito no product nasce como estado administrativo versionado.",
|
||||
"O product consome apenas configuracao publicada e aprovada.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_admin_write_governance_source_payload() -> dict:
|
||||
return {
|
||||
"key": "admin_write_governance",
|
||||
"source": "runtime_guard",
|
||||
"mutable": False,
|
||||
"description": (
|
||||
"Guard no AdminSession bloqueia escrita ORM fora das tabelas internas do admin e preserva a governanca versionada antes de qualquer efeito no product."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_table_name(instance: object) -> str | None:
|
||||
table = getattr(instance, "__table__", None)
|
||||
if table is not None:
|
||||
table_name = getattr(table, "name", None)
|
||||
if table_name:
|
||||
return str(table_name).strip().lower()
|
||||
|
||||
class_table_name = getattr(type(instance), "__tablename__", None)
|
||||
if class_table_name:
|
||||
return str(class_table_name).strip().lower()
|
||||
|
||||
instance_table_name = getattr(instance, "__tablename__", None)
|
||||
if instance_table_name:
|
||||
return str(instance_table_name).strip().lower()
|
||||
|
||||
return None
|
||||
@ -0,0 +1,3 @@
|
||||
from admin_app.app_factory import create_app
|
||||
|
||||
app = create_app()
|
||||
@ -0,0 +1,19 @@
|
||||
from admin_app.repositories.audit_log_repository import AuditLogRepository
|
||||
from admin_app.repositories.base_repository import BaseRepository
|
||||
from admin_app.repositories.staff_account_repository import StaffAccountRepository
|
||||
from admin_app.repositories.staff_session_repository import StaffSessionRepository
|
||||
from admin_app.repositories.tool_artifact_repository import ToolArtifactRepository
|
||||
from admin_app.repositories.tool_draft_repository import ToolDraftRepository
|
||||
from admin_app.repositories.tool_metadata_repository import ToolMetadataRepository
|
||||
from admin_app.repositories.tool_version_repository import ToolVersionRepository
|
||||
|
||||
__all__ = [
|
||||
"AuditLogRepository",
|
||||
"BaseRepository",
|
||||
"StaffAccountRepository",
|
||||
"StaffSessionRepository",
|
||||
"ToolArtifactRepository",
|
||||
"ToolDraftRepository",
|
||||
"ToolMetadataRepository",
|
||||
"ToolVersionRepository",
|
||||
]
|
||||
@ -0,0 +1,39 @@
|
||||
from sqlalchemy import desc, select
|
||||
|
||||
from admin_app.db.models import AuditLog
|
||||
from admin_app.repositories.base_repository import BaseRepository
|
||||
|
||||
|
||||
class AuditLogRepository(BaseRepository):
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
actor_staff_account_id: int | None,
|
||||
event_type: str,
|
||||
resource_type: str,
|
||||
resource_id: str | None,
|
||||
outcome: str,
|
||||
message: str | None,
|
||||
payload_json: dict | None,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> AuditLog:
|
||||
audit_log = AuditLog(
|
||||
actor_staff_account_id=actor_staff_account_id,
|
||||
event_type=event_type,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
outcome=outcome,
|
||||
message=message,
|
||||
payload_json=payload_json,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
self.db.add(audit_log)
|
||||
self.db.commit()
|
||||
self.db.refresh(audit_log)
|
||||
return audit_log
|
||||
|
||||
def list_recent(self, limit: int = 50) -> list[AuditLog]:
|
||||
statement = select(AuditLog).order_by(desc(AuditLog.created_at)).limit(limit)
|
||||
return list(self.db.execute(statement).scalars().all())
|
||||
@ -0,0 +1,6 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
class BaseRepository:
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
@ -0,0 +1,53 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from admin_app.db.models import StaffAccount
|
||||
from admin_app.repositories.base_repository import BaseRepository
|
||||
from shared.contracts import StaffRole
|
||||
|
||||
|
||||
class StaffAccountRepository(BaseRepository):
|
||||
def get_by_email(self, email: str) -> StaffAccount | None:
|
||||
statement = select(StaffAccount).where(StaffAccount.email == email)
|
||||
return self.db.execute(statement).scalar_one_or_none()
|
||||
|
||||
def get_by_id(self, staff_account_id: int) -> StaffAccount | None:
|
||||
statement = select(StaffAccount).where(StaffAccount.id == staff_account_id)
|
||||
return self.db.execute(statement).scalar_one_or_none()
|
||||
|
||||
def list_by_role(self, role: StaffRole) -> list[StaffAccount]:
|
||||
statement = (
|
||||
select(StaffAccount)
|
||||
.where(StaffAccount.role == role)
|
||||
.order_by(StaffAccount.display_name.asc(), StaffAccount.email.asc())
|
||||
)
|
||||
return list(self.db.execute(statement).scalars().all())
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
display_name: str,
|
||||
password_hash: str,
|
||||
role: StaffRole,
|
||||
is_active: bool,
|
||||
) -> StaffAccount:
|
||||
staff_account = StaffAccount(
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
password_hash=password_hash,
|
||||
role=role,
|
||||
is_active=is_active,
|
||||
)
|
||||
self.db.add(staff_account)
|
||||
self.db.commit()
|
||||
self.db.refresh(staff_account)
|
||||
return staff_account
|
||||
|
||||
def save(self, staff_account: StaffAccount) -> StaffAccount:
|
||||
self.db.add(staff_account)
|
||||
self.db.commit()
|
||||
self.db.refresh(staff_account)
|
||||
return staff_account
|
||||
|
||||
def update_last_login(self, staff_account: StaffAccount) -> StaffAccount:
|
||||
return self.save(staff_account)
|
||||
@ -0,0 +1,45 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from admin_app.db.models import StaffSession
|
||||
from admin_app.repositories.base_repository import BaseRepository
|
||||
|
||||
|
||||
class StaffSessionRepository(BaseRepository):
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
staff_account_id: int,
|
||||
refresh_token_hash: str,
|
||||
expires_at: datetime,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> StaffSession:
|
||||
session = StaffSession(
|
||||
staff_account_id=staff_account_id,
|
||||
refresh_token_hash=refresh_token_hash,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
self.db.add(session)
|
||||
self.db.commit()
|
||||
self.db.refresh(session)
|
||||
return session
|
||||
|
||||
def get_by_id(self, session_id: int) -> StaffSession | None:
|
||||
statement = select(StaffSession).where(StaffSession.id == session_id)
|
||||
return self.db.execute(statement).scalar_one_or_none()
|
||||
|
||||
def get_by_refresh_token_hash(self, refresh_token_hash: str) -> StaffSession | None:
|
||||
statement = select(StaffSession).where(
|
||||
StaffSession.refresh_token_hash == refresh_token_hash
|
||||
)
|
||||
return self.db.execute(statement).scalar_one_or_none()
|
||||
|
||||
def save(self, staff_session: StaffSession) -> StaffSession:
|
||||
self.db.add(staff_session)
|
||||
self.db.commit()
|
||||
self.db.refresh(staff_session)
|
||||
return staff_session
|
||||
@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from admin_app.db.models import ToolArtifact
|
||||
from admin_app.db.models.tool_artifact import (
|
||||
ToolArtifactKind,
|
||||
ToolArtifactStage,
|
||||
ToolArtifactStatus,
|
||||
ToolArtifactStorageKind,
|
||||
)
|
||||
from admin_app.repositories.base_repository import BaseRepository
|
||||
|
||||
|
||||
class ToolArtifactRepository(BaseRepository):
|
||||
def list_artifacts(
|
||||
self,
|
||||
*,
|
||||
tool_name: str | None = None,
|
||||
tool_version_id: int | None = None,
|
||||
artifact_stage: ToolArtifactStage | str | None = None,
|
||||
artifact_kind: ToolArtifactKind | str | None = None,
|
||||
) -> list[ToolArtifact]:
|
||||
statement = select(ToolArtifact).order_by(
|
||||
ToolArtifact.version_number.desc(),
|
||||
ToolArtifact.updated_at.desc(),
|
||||
ToolArtifact.created_at.desc(),
|
||||
)
|
||||
if tool_name:
|
||||
statement = statement.where(ToolArtifact.tool_name == str(tool_name).strip().lower())
|
||||
if tool_version_id is not None:
|
||||
statement = statement.where(ToolArtifact.tool_version_id == tool_version_id)
|
||||
if artifact_stage:
|
||||
statement = statement.where(
|
||||
ToolArtifact.artifact_stage == self._normalize_stage(artifact_stage)
|
||||
)
|
||||
if artifact_kind:
|
||||
statement = statement.where(
|
||||
ToolArtifact.artifact_kind == self._normalize_kind(artifact_kind)
|
||||
)
|
||||
return list(self.db.execute(statement).scalars().all())
|
||||
|
||||
def get_by_tool_version_and_kind(
|
||||
self,
|
||||
tool_version_id: int,
|
||||
artifact_kind: ToolArtifactKind | str,
|
||||
) -> ToolArtifact | None:
|
||||
statement = select(ToolArtifact).where(
|
||||
ToolArtifact.tool_version_id == tool_version_id,
|
||||
ToolArtifact.artifact_kind == self._normalize_kind(artifact_kind),
|
||||
)
|
||||
return self.db.execute(statement).scalar_one_or_none()
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
draft_id: int,
|
||||
tool_version_id: int,
|
||||
tool_name: str,
|
||||
version_number: int,
|
||||
artifact_stage: ToolArtifactStage | str,
|
||||
artifact_kind: ToolArtifactKind | str,
|
||||
artifact_status: ToolArtifactStatus | str,
|
||||
summary: str,
|
||||
payload_json: dict,
|
||||
author_staff_account_id: int,
|
||||
author_display_name: str,
|
||||
storage_kind: ToolArtifactStorageKind | str = ToolArtifactStorageKind.INLINE_JSON,
|
||||
checksum: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> ToolArtifact:
|
||||
normalized_kind = self._normalize_kind(artifact_kind)
|
||||
artifact = ToolArtifact(
|
||||
artifact_id=self.build_artifact_id(tool_name, version_number, normalized_kind),
|
||||
draft_id=draft_id,
|
||||
tool_version_id=tool_version_id,
|
||||
tool_name=str(tool_name or "").strip().lower(),
|
||||
version_number=int(version_number),
|
||||
artifact_stage=self._normalize_stage(artifact_stage),
|
||||
artifact_kind=normalized_kind,
|
||||
artifact_status=self._normalize_status(artifact_status),
|
||||
storage_kind=self._normalize_storage_kind(storage_kind),
|
||||
summary=str(summary or "").strip(),
|
||||
payload_json=dict(payload_json or {}),
|
||||
checksum=checksum or self._build_payload_checksum(payload_json),
|
||||
author_staff_account_id=author_staff_account_id,
|
||||
author_display_name=author_display_name,
|
||||
)
|
||||
self.db.add(artifact)
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(artifact)
|
||||
else:
|
||||
self.db.flush()
|
||||
return artifact
|
||||
|
||||
def update_artifact(
|
||||
self,
|
||||
artifact: ToolArtifact,
|
||||
*,
|
||||
artifact_status: ToolArtifactStatus | str,
|
||||
summary: str,
|
||||
payload_json: dict,
|
||||
author_staff_account_id: int,
|
||||
author_display_name: str,
|
||||
storage_kind: ToolArtifactStorageKind | str = ToolArtifactStorageKind.INLINE_JSON,
|
||||
checksum: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> ToolArtifact:
|
||||
artifact.artifact_status = self._normalize_status(artifact_status)
|
||||
artifact.storage_kind = self._normalize_storage_kind(storage_kind)
|
||||
artifact.summary = str(summary or "").strip()
|
||||
artifact.payload_json = dict(payload_json or {})
|
||||
artifact.checksum = checksum or self._build_payload_checksum(payload_json)
|
||||
artifact.author_staff_account_id = author_staff_account_id
|
||||
artifact.author_display_name = author_display_name
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(artifact)
|
||||
else:
|
||||
self.db.flush()
|
||||
return artifact
|
||||
|
||||
def upsert_version_artifact(
|
||||
self,
|
||||
*,
|
||||
draft_id: int,
|
||||
tool_version_id: int,
|
||||
tool_name: str,
|
||||
version_number: int,
|
||||
artifact_stage: ToolArtifactStage | str,
|
||||
artifact_kind: ToolArtifactKind | str,
|
||||
artifact_status: ToolArtifactStatus | str,
|
||||
summary: str,
|
||||
payload_json: dict,
|
||||
author_staff_account_id: int,
|
||||
author_display_name: str,
|
||||
storage_kind: ToolArtifactStorageKind | str = ToolArtifactStorageKind.INLINE_JSON,
|
||||
checksum: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> ToolArtifact:
|
||||
normalized_kind = self._normalize_kind(artifact_kind)
|
||||
existing = self.get_by_tool_version_and_kind(tool_version_id, normalized_kind)
|
||||
if existing is None:
|
||||
return self.create(
|
||||
draft_id=draft_id,
|
||||
tool_version_id=tool_version_id,
|
||||
tool_name=tool_name,
|
||||
version_number=version_number,
|
||||
artifact_stage=artifact_stage,
|
||||
artifact_kind=normalized_kind,
|
||||
artifact_status=artifact_status,
|
||||
summary=summary,
|
||||
payload_json=payload_json,
|
||||
author_staff_account_id=author_staff_account_id,
|
||||
author_display_name=author_display_name,
|
||||
storage_kind=storage_kind,
|
||||
checksum=checksum,
|
||||
commit=commit,
|
||||
)
|
||||
return self.update_artifact(
|
||||
existing,
|
||||
artifact_status=artifact_status,
|
||||
summary=summary,
|
||||
payload_json=payload_json,
|
||||
author_staff_account_id=author_staff_account_id,
|
||||
author_display_name=author_display_name,
|
||||
storage_kind=storage_kind,
|
||||
checksum=checksum,
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_artifact_id(
|
||||
tool_name: str,
|
||||
version_number: int,
|
||||
artifact_kind: ToolArtifactKind | str,
|
||||
) -> str:
|
||||
normalized_tool_name = str(tool_name or "").strip().lower()
|
||||
normalized_kind = ToolArtifactRepository._normalize_kind(artifact_kind)
|
||||
return f"tool_artifact::{normalized_tool_name}::v{int(version_number)}::{normalized_kind.value}"
|
||||
|
||||
@staticmethod
|
||||
def _build_payload_checksum(payload_json: dict | None) -> str:
|
||||
canonical_payload = json.dumps(payload_json or {}, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical_payload.encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_stage(value: ToolArtifactStage | str) -> ToolArtifactStage:
|
||||
if isinstance(value, ToolArtifactStage):
|
||||
return value
|
||||
return ToolArtifactStage(str(value or "").strip().lower())
|
||||
|
||||
@staticmethod
|
||||
def _normalize_kind(value: ToolArtifactKind | str) -> ToolArtifactKind:
|
||||
if isinstance(value, ToolArtifactKind):
|
||||
return value
|
||||
return ToolArtifactKind(str(value or "").strip().lower())
|
||||
|
||||
@staticmethod
|
||||
def _normalize_status(value: ToolArtifactStatus | str) -> ToolArtifactStatus:
|
||||
if isinstance(value, ToolArtifactStatus):
|
||||
return value
|
||||
return ToolArtifactStatus(str(value or "").strip().lower())
|
||||
|
||||
@staticmethod
|
||||
def _normalize_storage_kind(value: ToolArtifactStorageKind | str) -> ToolArtifactStorageKind:
|
||||
if isinstance(value, ToolArtifactStorageKind):
|
||||
return value
|
||||
return ToolArtifactStorageKind(str(value or "").strip().lower())
|
||||
@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from admin_app.db.models import ToolDraft
|
||||
from admin_app.repositories.base_repository import BaseRepository
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class ToolDraftRepository(BaseRepository):
|
||||
def list_drafts(
|
||||
self,
|
||||
*,
|
||||
statuses: tuple[ToolLifecycleStatus, ...] | None = None,
|
||||
) -> list[ToolDraft]:
|
||||
statement = select(ToolDraft).order_by(
|
||||
ToolDraft.updated_at.desc(),
|
||||
ToolDraft.created_at.desc(),
|
||||
)
|
||||
if statuses:
|
||||
statement = statement.where(ToolDraft.status.in_(statuses))
|
||||
return list(self.db.execute(statement).scalars().all())
|
||||
|
||||
def get_by_tool_name(self, tool_name: str) -> ToolDraft | None:
|
||||
statement = select(ToolDraft).where(ToolDraft.tool_name == str(tool_name or "").strip().lower())
|
||||
return self.db.execute(statement).scalar_one_or_none()
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
display_name: str,
|
||||
domain: str,
|
||||
description: str,
|
||||
business_goal: str,
|
||||
summary: str,
|
||||
parameters_json: list[dict],
|
||||
required_parameter_count: int,
|
||||
current_version_number: int,
|
||||
version_count: int,
|
||||
owner_staff_account_id: int,
|
||||
owner_display_name: str,
|
||||
generation_model: str | None = None,
|
||||
requires_director_approval: bool = True,
|
||||
commit: bool = True,
|
||||
) -> ToolDraft:
|
||||
draft = ToolDraft(
|
||||
draft_id=self._build_draft_id(),
|
||||
tool_name=tool_name,
|
||||
display_name=display_name,
|
||||
domain=domain,
|
||||
description=description,
|
||||
business_goal=business_goal,
|
||||
status=ToolLifecycleStatus.DRAFT,
|
||||
summary=summary,
|
||||
parameters_json=parameters_json,
|
||||
required_parameter_count=required_parameter_count,
|
||||
current_version_number=current_version_number,
|
||||
version_count=version_count,
|
||||
generation_model=generation_model,
|
||||
requires_director_approval=requires_director_approval,
|
||||
owner_staff_account_id=owner_staff_account_id,
|
||||
owner_display_name=owner_display_name,
|
||||
)
|
||||
self.db.add(draft)
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(draft)
|
||||
else:
|
||||
self.db.flush()
|
||||
return draft
|
||||
|
||||
def update_submission(
|
||||
self,
|
||||
draft: ToolDraft,
|
||||
*,
|
||||
display_name: str,
|
||||
domain: str,
|
||||
description: str,
|
||||
business_goal: str,
|
||||
summary: str,
|
||||
parameters_json: list[dict],
|
||||
required_parameter_count: int,
|
||||
current_version_number: int,
|
||||
version_count: int,
|
||||
owner_staff_account_id: int,
|
||||
owner_display_name: str,
|
||||
generation_model: str | None = None,
|
||||
requires_director_approval: bool = True,
|
||||
commit: bool = True,
|
||||
) -> ToolDraft:
|
||||
draft.display_name = display_name
|
||||
draft.domain = domain
|
||||
draft.description = description
|
||||
draft.business_goal = business_goal
|
||||
draft.status = ToolLifecycleStatus.DRAFT
|
||||
draft.summary = summary
|
||||
draft.parameters_json = parameters_json
|
||||
draft.required_parameter_count = required_parameter_count
|
||||
draft.current_version_number = current_version_number
|
||||
draft.version_count = version_count
|
||||
draft.generation_model = generation_model
|
||||
draft.requires_director_approval = requires_director_approval
|
||||
draft.owner_staff_account_id = owner_staff_account_id
|
||||
draft.owner_display_name = owner_display_name
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(draft)
|
||||
else:
|
||||
self.db.flush()
|
||||
return draft
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
draft: ToolDraft,
|
||||
*,
|
||||
status: ToolLifecycleStatus,
|
||||
commit: bool = True,
|
||||
) -> ToolDraft:
|
||||
draft.status = status
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(draft)
|
||||
else:
|
||||
self.db.flush()
|
||||
return draft
|
||||
|
||||
@staticmethod
|
||||
def _build_draft_id() -> str:
|
||||
return f"draft_{uuid4().hex[:24]}"
|
||||
|
||||
@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from admin_app.db.models import ToolMetadata
|
||||
from admin_app.repositories.base_repository import BaseRepository
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class ToolMetadataRepository(BaseRepository):
|
||||
def list_metadata(
|
||||
self,
|
||||
*,
|
||||
tool_name: str | None = None,
|
||||
statuses: tuple[ToolLifecycleStatus, ...] | None = None,
|
||||
) -> list[ToolMetadata]:
|
||||
statement = select(ToolMetadata).order_by(
|
||||
ToolMetadata.version_number.desc(),
|
||||
ToolMetadata.updated_at.desc(),
|
||||
ToolMetadata.created_at.desc(),
|
||||
)
|
||||
if tool_name:
|
||||
statement = statement.where(ToolMetadata.tool_name == str(tool_name).strip().lower())
|
||||
if statuses:
|
||||
statement = statement.where(ToolMetadata.status.in_(statuses))
|
||||
return list(self.db.execute(statement).scalars().all())
|
||||
|
||||
def get_by_tool_version_id(self, tool_version_id: int) -> ToolMetadata | None:
|
||||
statement = select(ToolMetadata).where(ToolMetadata.tool_version_id == tool_version_id)
|
||||
return self.db.execute(statement).scalar_one_or_none()
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
draft_id: int,
|
||||
tool_version_id: int,
|
||||
tool_name: str,
|
||||
display_name: str,
|
||||
domain: str,
|
||||
description: str,
|
||||
parameters_json: list[dict],
|
||||
version_number: int,
|
||||
status: ToolLifecycleStatus,
|
||||
author_staff_account_id: int,
|
||||
author_display_name: str,
|
||||
commit: bool = True,
|
||||
) -> ToolMetadata:
|
||||
metadata = ToolMetadata(
|
||||
metadata_id=self.build_metadata_id(tool_name, version_number),
|
||||
draft_id=draft_id,
|
||||
tool_version_id=tool_version_id,
|
||||
tool_name=tool_name,
|
||||
display_name=display_name,
|
||||
domain=domain,
|
||||
description=description,
|
||||
parameters_json=parameters_json,
|
||||
version_number=version_number,
|
||||
status=status,
|
||||
author_staff_account_id=author_staff_account_id,
|
||||
author_display_name=author_display_name,
|
||||
)
|
||||
self.db.add(metadata)
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(metadata)
|
||||
else:
|
||||
self.db.flush()
|
||||
return metadata
|
||||
|
||||
def update_metadata(
|
||||
self,
|
||||
metadata: ToolMetadata,
|
||||
*,
|
||||
display_name: str,
|
||||
domain: str,
|
||||
description: str,
|
||||
parameters_json: list[dict],
|
||||
status: ToolLifecycleStatus,
|
||||
author_staff_account_id: int,
|
||||
author_display_name: str,
|
||||
commit: bool = True,
|
||||
) -> ToolMetadata:
|
||||
metadata.display_name = display_name
|
||||
metadata.domain = domain
|
||||
metadata.description = description
|
||||
metadata.parameters_json = parameters_json
|
||||
metadata.status = status
|
||||
metadata.author_staff_account_id = author_staff_account_id
|
||||
metadata.author_display_name = author_display_name
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(metadata)
|
||||
else:
|
||||
self.db.flush()
|
||||
return metadata
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
metadata: ToolMetadata,
|
||||
*,
|
||||
status: ToolLifecycleStatus,
|
||||
commit: bool = True,
|
||||
) -> ToolMetadata:
|
||||
metadata.status = status
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(metadata)
|
||||
else:
|
||||
self.db.flush()
|
||||
return metadata
|
||||
|
||||
def upsert_version_metadata(
|
||||
self,
|
||||
*,
|
||||
draft_id: int,
|
||||
tool_version_id: int,
|
||||
tool_name: str,
|
||||
display_name: str,
|
||||
domain: str,
|
||||
description: str,
|
||||
parameters_json: list[dict],
|
||||
version_number: int,
|
||||
status: ToolLifecycleStatus,
|
||||
author_staff_account_id: int,
|
||||
author_display_name: str,
|
||||
commit: bool = True,
|
||||
) -> ToolMetadata:
|
||||
existing = self.get_by_tool_version_id(tool_version_id)
|
||||
if existing is None:
|
||||
return self.create(
|
||||
draft_id=draft_id,
|
||||
tool_version_id=tool_version_id,
|
||||
tool_name=tool_name,
|
||||
display_name=display_name,
|
||||
domain=domain,
|
||||
description=description,
|
||||
parameters_json=parameters_json,
|
||||
version_number=version_number,
|
||||
status=status,
|
||||
author_staff_account_id=author_staff_account_id,
|
||||
author_display_name=author_display_name,
|
||||
commit=commit,
|
||||
)
|
||||
return self.update_metadata(
|
||||
existing,
|
||||
display_name=display_name,
|
||||
domain=domain,
|
||||
description=description,
|
||||
parameters_json=parameters_json,
|
||||
status=status,
|
||||
author_staff_account_id=author_staff_account_id,
|
||||
author_display_name=author_display_name,
|
||||
commit=commit,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_metadata_id(tool_name: str, version_number: int) -> str:
|
||||
normalized_tool_name = str(tool_name or "").strip().lower()
|
||||
return f"tool_metadata::{normalized_tool_name}::v{int(version_number)}"
|
||||
|
||||
@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from admin_app.db.models import ToolVersion
|
||||
from admin_app.repositories.base_repository import BaseRepository
|
||||
from shared.contracts import ToolLifecycleStatus
|
||||
|
||||
|
||||
class ToolVersionRepository(BaseRepository):
|
||||
def list_versions(
|
||||
self,
|
||||
*,
|
||||
tool_name: str | None = None,
|
||||
draft_id: int | None = None,
|
||||
statuses: tuple[ToolLifecycleStatus, ...] | None = None,
|
||||
) -> list[ToolVersion]:
|
||||
statement = select(ToolVersion).order_by(
|
||||
ToolVersion.version_number.desc(),
|
||||
ToolVersion.updated_at.desc(),
|
||||
ToolVersion.created_at.desc(),
|
||||
)
|
||||
if tool_name:
|
||||
statement = statement.where(ToolVersion.tool_name == str(tool_name).strip().lower())
|
||||
if draft_id is not None:
|
||||
statement = statement.where(ToolVersion.draft_id == draft_id)
|
||||
if statuses:
|
||||
statement = statement.where(ToolVersion.status.in_(statuses))
|
||||
return list(self.db.execute(statement).scalars().all())
|
||||
|
||||
def get_next_version_number(self, tool_name: str) -> int:
|
||||
statement = select(func.max(ToolVersion.version_number)).where(
|
||||
ToolVersion.tool_name == str(tool_name or "").strip().lower()
|
||||
)
|
||||
max_version = self.db.execute(statement).scalar_one_or_none()
|
||||
return int(max_version or 0) + 1
|
||||
|
||||
def get_by_version_id(self, version_id: str) -> ToolVersion | None:
|
||||
statement = select(ToolVersion).where(
|
||||
ToolVersion.version_id == str(version_id or "").strip().lower()
|
||||
)
|
||||
return self.db.execute(statement).scalar_one_or_none()
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
draft_id: int,
|
||||
tool_name: str,
|
||||
version_number: int,
|
||||
summary: str,
|
||||
description: str,
|
||||
business_goal: str,
|
||||
parameters_json: list[dict],
|
||||
required_parameter_count: int,
|
||||
owner_staff_account_id: int,
|
||||
owner_display_name: str,
|
||||
generation_model: str | None = None,
|
||||
status: ToolLifecycleStatus = ToolLifecycleStatus.DRAFT,
|
||||
requires_director_approval: bool = True,
|
||||
commit: bool = True,
|
||||
) -> ToolVersion:
|
||||
version = ToolVersion(
|
||||
version_id=self.build_version_id(tool_name, version_number),
|
||||
draft_id=draft_id,
|
||||
tool_name=tool_name,
|
||||
version_number=version_number,
|
||||
status=status,
|
||||
summary=summary,
|
||||
description=description,
|
||||
business_goal=business_goal,
|
||||
parameters_json=parameters_json,
|
||||
required_parameter_count=required_parameter_count,
|
||||
generation_model=generation_model,
|
||||
requires_director_approval=requires_director_approval,
|
||||
owner_staff_account_id=owner_staff_account_id,
|
||||
owner_display_name=owner_display_name,
|
||||
)
|
||||
self.db.add(version)
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(version)
|
||||
else:
|
||||
self.db.flush()
|
||||
return version
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
version: ToolVersion,
|
||||
*,
|
||||
status: ToolLifecycleStatus,
|
||||
commit: bool = True,
|
||||
) -> ToolVersion:
|
||||
version.status = status
|
||||
if commit:
|
||||
self.db.commit()
|
||||
self.db.refresh(version)
|
||||
else:
|
||||
self.db.flush()
|
||||
return version
|
||||
|
||||
@staticmethod
|
||||
def build_version_id(tool_name: str, version_number: int) -> str:
|
||||
normalized_tool_name = str(tool_name or "").strip().lower()
|
||||
return f"tool_version::{normalized_tool_name}::v{int(version_number)}"
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
from admin_app.services.audit_service import (
|
||||
AdminAuditEventType,
|
||||
AdminAuditOutcome,
|
||||
AuditService,
|
||||
)
|
||||
from admin_app.services.auth_service import AuthService
|
||||
from admin_app.services.collaborator_management_service import CollaboratorManagementService
|
||||
from admin_app.services.report_service import ReportService
|
||||
from admin_app.services.system_service import SystemService
|
||||
from admin_app.services.tool_generation_service import ToolGenerationService
|
||||
from admin_app.services.tool_generation_worker_service import ToolGenerationWorkerService
|
||||
from admin_app.services.tool_management_service import ToolManagementService
|
||||
|
||||
__all__ = [
|
||||
"AdminAuditEventType",
|
||||
"AdminAuditOutcome",
|
||||
"AuditService",
|
||||
"AuthService",
|
||||
"CollaboratorManagementService",
|
||||
"ReportService",
|
||||
"SystemService",
|
||||
"ToolGenerationService",
|
||||
"ToolGenerationWorkerService",
|
||||
"ToolManagementService",
|
||||
]
|
||||
@ -0,0 +1,205 @@
|
||||
from enum import Enum
|
||||
|
||||
from admin_app.db.models import AuditLog
|
||||
from admin_app.repositories import AuditLogRepository
|
||||
|
||||
|
||||
class AdminAuditEventType(str, Enum):
|
||||
STAFF_LOGIN_SUCCEEDED = "staff.login.succeeded"
|
||||
STAFF_LOGIN_FAILED = "staff.login.failed"
|
||||
STAFF_LOGOUT_SUCCEEDED = "staff.logout.succeeded"
|
||||
STAFF_ACCOUNT_CREATED = "staff.account.created"
|
||||
STAFF_ACCOUNT_STATUS_UPDATED = "staff.account.status.updated"
|
||||
TOOL_APPROVAL_RECORDED = "tool.approval.recorded"
|
||||
TOOL_PUBLICATION_RECORDED = "tool.publication.recorded"
|
||||
|
||||
|
||||
class AdminAuditOutcome(str, Enum):
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class AuditService:
|
||||
def __init__(self, repository: AuditLogRepository):
|
||||
self.repository = repository
|
||||
|
||||
def record_event(
|
||||
self,
|
||||
*,
|
||||
actor_staff_account_id: int | None,
|
||||
event_type: AdminAuditEventType,
|
||||
resource_type: str,
|
||||
resource_id: str | None,
|
||||
outcome: AdminAuditOutcome,
|
||||
message: str | None,
|
||||
payload_json: dict | None,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> AuditLog:
|
||||
return self.repository.create(
|
||||
actor_staff_account_id=actor_staff_account_id,
|
||||
event_type=event_type.value,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
outcome=outcome.value,
|
||||
message=message,
|
||||
payload_json=payload_json,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
def record_login_succeeded(
|
||||
self,
|
||||
*,
|
||||
actor_staff_account_id: int,
|
||||
session_id: int,
|
||||
email: str,
|
||||
role: str,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> AuditLog:
|
||||
return self.record_event(
|
||||
actor_staff_account_id=actor_staff_account_id,
|
||||
event_type=AdminAuditEventType.STAFF_LOGIN_SUCCEEDED,
|
||||
resource_type="staff_account",
|
||||
resource_id=str(actor_staff_account_id),
|
||||
outcome=AdminAuditOutcome.SUCCESS,
|
||||
message="Login administrativo concluido.",
|
||||
payload_json={"session_id": session_id, "email": email, "role": role},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
def record_login_failed(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> AuditLog:
|
||||
return self.record_event(
|
||||
actor_staff_account_id=None,
|
||||
event_type=AdminAuditEventType.STAFF_LOGIN_FAILED,
|
||||
resource_type="staff_account",
|
||||
resource_id=email,
|
||||
outcome=AdminAuditOutcome.FAILED,
|
||||
message="Tentativa de login administrativo falhou.",
|
||||
payload_json={"email": email},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
def record_logout_succeeded(
|
||||
self,
|
||||
*,
|
||||
actor_staff_account_id: int,
|
||||
session_id: int,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> AuditLog:
|
||||
return self.record_event(
|
||||
actor_staff_account_id=actor_staff_account_id,
|
||||
event_type=AdminAuditEventType.STAFF_LOGOUT_SUCCEEDED,
|
||||
resource_type="staff_session",
|
||||
resource_id=str(session_id),
|
||||
outcome=AdminAuditOutcome.SUCCESS,
|
||||
message="Sessao administrativa encerrada.",
|
||||
payload_json={"session_id": session_id},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
def record_staff_account_created(
|
||||
self,
|
||||
*,
|
||||
actor_staff_account_id: int,
|
||||
created_staff_account_id: int,
|
||||
email: str,
|
||||
role: str,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> AuditLog:
|
||||
return self.record_event(
|
||||
actor_staff_account_id=actor_staff_account_id,
|
||||
event_type=AdminAuditEventType.STAFF_ACCOUNT_CREATED,
|
||||
resource_type="staff_account",
|
||||
resource_id=str(created_staff_account_id),
|
||||
outcome=AdminAuditOutcome.SUCCESS,
|
||||
message="Conta administrativa de colaborador criada.",
|
||||
payload_json={
|
||||
"created_staff_account_id": created_staff_account_id,
|
||||
"email": email,
|
||||
"role": role,
|
||||
},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
def record_staff_account_status_updated(
|
||||
self,
|
||||
*,
|
||||
actor_staff_account_id: int,
|
||||
target_staff_account_id: int,
|
||||
is_active: bool,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> AuditLog:
|
||||
return self.record_event(
|
||||
actor_staff_account_id=actor_staff_account_id,
|
||||
event_type=AdminAuditEventType.STAFF_ACCOUNT_STATUS_UPDATED,
|
||||
resource_type="staff_account",
|
||||
resource_id=str(target_staff_account_id),
|
||||
outcome=AdminAuditOutcome.SUCCESS,
|
||||
message="Status de colaborador administrativo atualizado.",
|
||||
payload_json={
|
||||
"target_staff_account_id": target_staff_account_id,
|
||||
"is_active": is_active,
|
||||
},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
def record_tool_approval(
|
||||
self,
|
||||
*,
|
||||
actor_staff_account_id: int,
|
||||
tool_name: str,
|
||||
tool_version: int,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> AuditLog:
|
||||
return self.record_event(
|
||||
actor_staff_account_id=actor_staff_account_id,
|
||||
event_type=AdminAuditEventType.TOOL_APPROVAL_RECORDED,
|
||||
resource_type="tool_publication",
|
||||
resource_id=tool_name,
|
||||
outcome=AdminAuditOutcome.SUCCESS,
|
||||
message="Aprovacao de tool registrada.",
|
||||
payload_json={"tool_name": tool_name, "tool_version": tool_version},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
def record_tool_publication(
|
||||
self,
|
||||
*,
|
||||
actor_staff_account_id: int,
|
||||
tool_name: str,
|
||||
tool_version: int,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> AuditLog:
|
||||
return self.record_event(
|
||||
actor_staff_account_id=actor_staff_account_id,
|
||||
event_type=AdminAuditEventType.TOOL_PUBLICATION_RECORDED,
|
||||
resource_type="tool_publication",
|
||||
resource_id=tool_name,
|
||||
outcome=AdminAuditOutcome.SUCCESS,
|
||||
message="Publicacao de tool registrada.",
|
||||
payload_json={"tool_name": tool_name, "tool_version": tool_version},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
def list_recent(self, limit: int = 50) -> list[AuditLog]:
|
||||
return self.repository.list_recent(limit=limit)
|
||||
@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from admin_app.core import AdminSecurityService
|
||||
from admin_app.db.models import StaffAccount
|
||||
from admin_app.repositories import StaffAccountRepository
|
||||
from admin_app.services.audit_service import AuditService
|
||||
from shared.contracts import StaffRole
|
||||
|
||||
|
||||
class CollaboratorManagementService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
account_repository: StaffAccountRepository,
|
||||
security_service: AdminSecurityService,
|
||||
audit_service: AuditService,
|
||||
):
|
||||
self.account_repository = account_repository
|
||||
self.security_service = security_service
|
||||
self.audit_service = audit_service
|
||||
|
||||
def list_collaborators(self) -> dict:
|
||||
collaborators = self.account_repository.list_by_role(StaffRole.COLABORADOR)
|
||||
active_count = sum(1 for account in collaborators if account.is_active)
|
||||
return {
|
||||
"accounts": [self._serialize_staff_account(account) for account in collaborators],
|
||||
"total": len(collaborators),
|
||||
"active_count": active_count,
|
||||
"inactive_count": len(collaborators) - active_count,
|
||||
}
|
||||
|
||||
def create_collaborator(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
display_name: str,
|
||||
password: str,
|
||||
is_active: bool,
|
||||
actor_staff_account_id: int,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> dict:
|
||||
normalized_email = self._normalize_email(email)
|
||||
normalized_display_name = display_name.strip()
|
||||
if len(normalized_display_name) < 3:
|
||||
raise ValueError("display_name precisa ter pelo menos 3 caracteres.")
|
||||
if self.account_repository.get_by_email(normalized_email) is not None:
|
||||
raise ValueError("Ja existe uma conta administrativa com este email.")
|
||||
|
||||
password_hash = self.security_service.hash_password(password)
|
||||
collaborator = self.account_repository.create(
|
||||
email=normalized_email,
|
||||
display_name=normalized_display_name,
|
||||
password_hash=password_hash,
|
||||
role=StaffRole.COLABORADOR,
|
||||
is_active=is_active,
|
||||
)
|
||||
self.audit_service.record_staff_account_created(
|
||||
actor_staff_account_id=actor_staff_account_id,
|
||||
created_staff_account_id=collaborator.id,
|
||||
email=collaborator.email,
|
||||
role=collaborator.role.value,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
return self._serialize_staff_account(collaborator)
|
||||
|
||||
def update_collaborator_status(
|
||||
self,
|
||||
*,
|
||||
collaborator_id: int,
|
||||
is_active: bool,
|
||||
actor_staff_account_id: int,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> dict:
|
||||
collaborator = self.account_repository.get_by_id(collaborator_id)
|
||||
if collaborator is None or collaborator.role != StaffRole.COLABORADOR:
|
||||
raise LookupError("Colaborador administrativo nao encontrado.")
|
||||
|
||||
collaborator.is_active = is_active
|
||||
persisted = self.account_repository.save(collaborator)
|
||||
self.audit_service.record_staff_account_status_updated(
|
||||
actor_staff_account_id=actor_staff_account_id,
|
||||
target_staff_account_id=persisted.id,
|
||||
is_active=persisted.is_active,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
return self._serialize_staff_account(persisted)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_email(email: str) -> str:
|
||||
return email.strip().lower()
|
||||
|
||||
@staticmethod
|
||||
def _serialize_staff_account(account: StaffAccount) -> dict:
|
||||
return {
|
||||
"id": account.id,
|
||||
"email": account.email,
|
||||
"display_name": account.display_name,
|
||||
"role": account.role,
|
||||
"is_active": account.is_active,
|
||||
"last_login_at": account.last_login_at,
|
||||
"created_at": account.created_at,
|
||||
"updated_at": account.updated_at,
|
||||
}
|
||||
@ -0,0 +1,963 @@
|
||||
from admin_app.core.settings import AdminSettings
|
||||
from shared.contracts import (
|
||||
PRODUCT_OPERATIONAL_DATASETS,
|
||||
OperationalDatasetContract,
|
||||
OperationalReadGranularity,
|
||||
get_operational_dataset,
|
||||
)
|
||||
|
||||
_MATERIALIZATION_STATUS = "contract_defined_pending_snapshot_view"
|
||||
_REFRESH_BEHAVIOR = "manual_refresh_triggers_sync_boundary"
|
||||
_REPORT_SOURCE = "shared_contract_catalog"
|
||||
_SALES_DATASET_KEY = "sales_orders"
|
||||
|
||||
_SALES_REPORT_METRICS = {
|
||||
"total_orders": {"key": "total_orders", "label": "Pedidos totais", "aggregation": "count", "description": "Quantidade total de pedidos consolidados no periodo."},
|
||||
"gross_order_value": {"key": "gross_order_value", "label": "Valor bruto negociado", "aggregation": "sum", "description": "Soma do valor negociado dos pedidos incluidos no recorte."},
|
||||
"active_orders": {"key": "active_orders", "label": "Pedidos ativos", "aggregation": "count_where_status_active", "description": "Quantidade de pedidos ainda em fluxo operacional ativo."},
|
||||
"cancelled_orders": {"key": "cancelled_orders", "label": "Pedidos cancelados", "aggregation": "count_where_status_cancelled", "description": "Quantidade de pedidos cancelados no recorte selecionado."},
|
||||
"cancellation_rate": {"key": "cancellation_rate", "label": "Taxa de cancelamento", "aggregation": "ratio", "description": "Relacao entre pedidos cancelados e total de pedidos consolidados."},
|
||||
"average_ticket": {"key": "average_ticket", "label": "Ticket medio", "aggregation": "avg", "description": "Media do valor negociado por pedido dentro do recorte."},
|
||||
}
|
||||
|
||||
_SALES_DIMENSIONS = {
|
||||
"created_at": {"field_name": "created_at", "label": "Periodo de criacao", "description": "Agrupamento temporal da criacao do pedido.", "default_group_by": True},
|
||||
"updated_at": {"field_name": "updated_at", "label": "Periodo de atualizacao", "description": "Agrupamento temporal da ultima atualizacao do pedido.", "default_group_by": True},
|
||||
"data_cancelamento": {"field_name": "data_cancelamento", "label": "Periodo de cancelamento", "description": "Agrupamento temporal do cancelamento registrado.", "default_group_by": True},
|
||||
"status": {"field_name": "status", "label": "Status do pedido", "description": "Separa os pedidos por etapa operacional."},
|
||||
"modelo_veiculo": {"field_name": "modelo_veiculo", "label": "Modelo do veiculo", "description": "Recorte por modelo comercial negociado."},
|
||||
"motivo_cancelamento": {"field_name": "motivo_cancelamento", "label": "Motivo do cancelamento", "description": "Separa cancelamentos pelo motivo operacional registrado."},
|
||||
}
|
||||
|
||||
_SALES_FILTERS = {
|
||||
"created_at": {"field_name": "created_at", "label": "Periodo", "filter_type": "date_range", "description": "Intervalo de criacao do pedido consolidado.", "required": True},
|
||||
"updated_at": {"field_name": "updated_at", "label": "Periodo", "filter_type": "date_range", "description": "Intervalo da ultima atualizacao do pedido.", "required": True},
|
||||
"data_cancelamento": {"field_name": "data_cancelamento", "label": "Periodo", "filter_type": "date_range", "description": "Intervalo em que o cancelamento foi registrado.", "required": True},
|
||||
"status": {"field_name": "status", "label": "Status", "filter_type": "enum", "description": "Restringe o consolidado para um ou mais status operacionais."},
|
||||
"modelo_veiculo": {"field_name": "modelo_veiculo", "label": "Modelo do veiculo", "filter_type": "enum", "description": "Filtra pedidos por modelo comercial reservado."},
|
||||
"motivo_cancelamento": {"field_name": "motivo_cancelamento", "label": "Motivo do cancelamento", "filter_type": "enum", "description": "Restringe o consolidado para um ou mais motivos operacionais."},
|
||||
}
|
||||
|
||||
_SALES_REPORTS = (
|
||||
{"report_key": "orders_volume", "label": "Volume de pedidos", "description": "Acompanha o volume bruto de pedidos por periodo e status operacional.", "default_time_field": "created_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_orders", "gross_order_value"), "dimension_fields": ("created_at", "status", "modelo_veiculo"), "filter_fields": ("created_at", "status", "modelo_veiculo")},
|
||||
{"report_key": "active_vs_cancelled", "label": "Pedidos ativos e cancelados", "description": "Compara pedidos em andamento com pedidos cancelados para leitura operacional da conversao.", "default_time_field": "updated_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("active_orders", "cancelled_orders", "cancellation_rate"), "dimension_fields": ("updated_at", "status", "modelo_veiculo"), "filter_fields": ("updated_at", "status", "modelo_veiculo")},
|
||||
{"report_key": "average_ticket", "label": "Ticket medio", "description": "Consolida a evolucao do valor medio negociado por periodo e por modelo.", "default_time_field": "created_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("average_ticket", "gross_order_value", "total_orders"), "dimension_fields": ("created_at", "modelo_veiculo", "status"), "filter_fields": ("created_at", "status", "modelo_veiculo")},
|
||||
{"report_key": "cancellations_by_period", "label": "Cancelamentos por periodo", "description": "Organiza o volume de cancelamentos e seus motivos ao longo do tempo.", "default_time_field": "data_cancelamento", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("cancelled_orders", "cancellation_rate"), "dimension_fields": ("data_cancelamento", "motivo_cancelamento", "modelo_veiculo"), "filter_fields": ("data_cancelamento", "motivo_cancelamento", "modelo_veiculo")},
|
||||
)
|
||||
|
||||
_REVENUE_DATASET_KEY = "rental_payments"
|
||||
|
||||
_REVENUE_REPORT_METRICS = {
|
||||
"total_payments": {"key": "total_payments", "label": "Pagamentos totais", "aggregation": "count", "description": "Quantidade total de pagamentos liquidados no periodo."},
|
||||
"collected_amount": {"key": "collected_amount", "label": "Valor arrecadado", "aggregation": "sum", "description": "Soma do valor liquidado dos pagamentos incluidos no recorte."},
|
||||
"average_payment_amount": {"key": "average_payment_amount", "label": "Valor medio por pagamento", "aggregation": "avg", "description": "Media do valor liquidado por pagamento no recorte selecionado."},
|
||||
"distinct_contracts": {"key": "distinct_contracts", "label": "Contratos conciliados", "aggregation": "count_distinct", "description": "Quantidade de contratos distintos com pagamento consolidado no periodo."},
|
||||
}
|
||||
|
||||
_REVENUE_DIMENSIONS = {
|
||||
"data_pagamento": {"field_name": "data_pagamento", "label": "Periodo do pagamento", "description": "Agrupamento temporal do pagamento liquidado.", "default_group_by": True},
|
||||
"created_at": {"field_name": "created_at", "label": "Periodo de registro", "description": "Agrupamento temporal do registro do pagamento no read model administrativo.", "default_group_by": True},
|
||||
"contrato_numero": {"field_name": "contrato_numero", "label": "Contrato", "description": "Recorte por contrato associado ao pagamento."},
|
||||
"placa": {"field_name": "placa", "label": "Placa", "description": "Recorte por veiculo vinculado ao contrato pago."},
|
||||
"protocolo": {"field_name": "protocolo", "label": "Protocolo", "description": "Rastreio por protocolo publico do pagamento."},
|
||||
}
|
||||
|
||||
_REVENUE_FILTERS = {
|
||||
"data_pagamento": {"field_name": "data_pagamento", "label": "Periodo do pagamento", "filter_type": "date_range", "description": "Intervalo em que o pagamento foi liquidado.", "required": True},
|
||||
"created_at": {"field_name": "created_at", "label": "Periodo de registro", "filter_type": "date_range", "description": "Intervalo em que o pagamento foi registrado no dataset administrativo.", "required": True},
|
||||
"contrato_numero": {"field_name": "contrato_numero", "label": "Contrato", "filter_type": "exact_match", "description": "Filtra pagamentos por contrato associado."},
|
||||
"placa": {"field_name": "placa", "label": "Placa", "filter_type": "exact_match", "description": "Filtra pagamentos pela placa vinculada ao contrato."},
|
||||
"protocolo": {"field_name": "protocolo", "label": "Protocolo", "filter_type": "exact_match", "description": "Filtra o consolidado por protocolo publico do pagamento."},
|
||||
}
|
||||
|
||||
_REVENUE_REPORTS = (
|
||||
{"report_key": "payments_volume", "label": "Volume de pagamentos", "description": "Acompanha a quantidade de pagamentos liquidados por periodo, contrato e veiculo.", "default_time_field": "data_pagamento", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_payments", "distinct_contracts"), "dimension_fields": ("data_pagamento", "contrato_numero", "placa"), "filter_fields": ("data_pagamento", "contrato_numero", "placa")},
|
||||
{"report_key": "collected_amount", "label": "Arrecadacao por periodo", "description": "Consolida o valor arrecadado por periodo com apoio de contrato e placa para leitura operacional.", "default_time_field": "data_pagamento", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("collected_amount", "average_payment_amount", "total_payments"), "dimension_fields": ("data_pagamento", "contrato_numero", "placa"), "filter_fields": ("data_pagamento", "contrato_numero", "placa")},
|
||||
{"report_key": "contract_reconciliation", "label": "Pagamentos por contrato", "description": "Organiza pagamentos conciliados por contrato com rastreio por placa e protocolo publico.", "default_time_field": "data_pagamento", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("collected_amount", "total_payments"), "dimension_fields": ("contrato_numero", "placa", "protocolo"), "filter_fields": ("data_pagamento", "contrato_numero", "placa", "protocolo")},
|
||||
)
|
||||
|
||||
_RENTAL_FLEET_DATASET_KEY = "rental_fleet"
|
||||
_RENTAL_CONTRACTS_DATASET_KEY = "rental_contracts"
|
||||
|
||||
_RENTAL_REPORT_METRICS = {
|
||||
"total_fleet_vehicles": {"key": "total_fleet_vehicles", "label": "Veiculos da frota", "aggregation": "count", "description": "Quantidade total de veiculos consolidados na frota administrativa."},
|
||||
"available_fleet_vehicles": {"key": "available_fleet_vehicles", "label": "Veiculos disponiveis", "aggregation": "count_where_status_available", "description": "Quantidade de veiculos em status operacional disponivel para locacao."},
|
||||
"average_daily_rate": {"key": "average_daily_rate", "label": "Diaria media", "aggregation": "avg", "description": "Media do valor de diaria vigente dos veiculos incluidos no recorte."},
|
||||
"total_contracts": {"key": "total_contracts", "label": "Contratos totais", "aggregation": "count", "description": "Quantidade total de contratos consolidados no periodo selecionado."},
|
||||
"active_contracts": {"key": "active_contracts", "label": "Contratos ativos", "aggregation": "count_where_status_active", "description": "Quantidade de contratos ainda em curso no recorte operacional."},
|
||||
"closed_contracts": {"key": "closed_contracts", "label": "Contratos encerrados", "aggregation": "count_where_status_closed", "description": "Quantidade de contratos concluidos ou encerrados no recorte selecionado."},
|
||||
"overdue_contracts": {"key": "overdue_contracts", "label": "Devolucoes em atraso", "aggregation": "count_overdue", "description": "Quantidade de contratos com fim previsto vencido e sem devolucao consolidada."},
|
||||
"occupied_vehicles": {"key": "occupied_vehicles", "label": "Veiculos ocupados", "aggregation": "count_distinct_active_vehicles", "description": "Quantidade de veiculos distintos associados a contratos ativos no periodo."},
|
||||
"projected_revenue": {"key": "projected_revenue", "label": "Receita prevista", "aggregation": "sum", "description": "Soma do valor previsto dos contratos incluidos no recorte."},
|
||||
"final_revenue": {"key": "final_revenue", "label": "Receita final", "aggregation": "sum", "description": "Soma do valor final consolidado dos contratos no recorte selecionado."},
|
||||
"revenue_delta": {"key": "revenue_delta", "label": "Desvio entre previsto e final", "aggregation": "difference", "description": "Diferenca consolidada entre receita prevista e receita final dos contratos."},
|
||||
}
|
||||
|
||||
_RENTAL_DIMENSIONS = {
|
||||
"created_at": {"field_name": "created_at", "label": "Periodo de cadastro", "description": "Agrupamento temporal do cadastro no read model administrativo.", "default_group_by": True},
|
||||
"categoria": {"field_name": "categoria", "label": "Categoria", "description": "Recorte por categoria comercial da locacao."},
|
||||
"status": {"field_name": "status", "label": "Status", "description": "Separa frota ou contratos por status operacional."},
|
||||
"modelo": {"field_name": "modelo", "label": "Modelo", "description": "Recorte por modelo do veiculo de locacao."},
|
||||
"placa": {"field_name": "placa", "label": "Placa", "description": "Rastreio por placa do veiculo locado."},
|
||||
"data_inicio": {"field_name": "data_inicio", "label": "Inicio da locacao", "description": "Agrupamento temporal da abertura do contrato.", "default_group_by": True},
|
||||
"data_fim_prevista": {"field_name": "data_fim_prevista", "label": "Fim previsto", "description": "Agrupamento temporal do fim previsto da locacao.", "default_group_by": True},
|
||||
"data_devolucao": {"field_name": "data_devolucao", "label": "Data de devolucao", "description": "Agrupamento temporal da devolucao consolidada do contrato.", "default_group_by": True},
|
||||
"updated_at": {"field_name": "updated_at", "label": "Ultima atualizacao", "description": "Agrupamento temporal da ultima atualizacao do contrato.", "default_group_by": True},
|
||||
"modelo_veiculo": {"field_name": "modelo_veiculo", "label": "Modelo do veiculo", "description": "Recorte por modelo do veiculo vinculado ao contrato."},
|
||||
"contrato_numero": {"field_name": "contrato_numero", "label": "Contrato", "description": "Rastreio por numero publico do contrato."},
|
||||
}
|
||||
|
||||
_RENTAL_FILTERS = {
|
||||
"created_at": {"field_name": "created_at", "label": "Periodo de cadastro", "filter_type": "date_range", "description": "Intervalo de cadastro no dataset administrativo.", "required": True},
|
||||
"categoria": {"field_name": "categoria", "label": "Categoria", "filter_type": "enum", "description": "Filtra frota ou contratos por categoria comercial."},
|
||||
"status": {"field_name": "status", "label": "Status", "filter_type": "enum", "description": "Restringe o consolidado para um ou mais status operacionais."},
|
||||
"modelo": {"field_name": "modelo", "label": "Modelo", "filter_type": "enum", "description": "Filtra o consolidado por modelo da frota."},
|
||||
"placa": {"field_name": "placa", "label": "Placa", "filter_type": "exact_match", "description": "Filtra o consolidado pela placa do veiculo."},
|
||||
"data_inicio": {"field_name": "data_inicio", "label": "Inicio da locacao", "filter_type": "date_range", "description": "Intervalo de abertura dos contratos de locacao.", "required": True},
|
||||
"data_fim_prevista": {"field_name": "data_fim_prevista", "label": "Fim previsto", "filter_type": "date_range", "description": "Intervalo do fim previsto dos contratos de locacao.", "required": True},
|
||||
"updated_at": {"field_name": "updated_at", "label": "Ultima atualizacao", "filter_type": "date_range", "description": "Intervalo da ultima atualizacao operacional do contrato.", "required": True},
|
||||
"modelo_veiculo": {"field_name": "modelo_veiculo", "label": "Modelo do veiculo", "filter_type": "enum", "description": "Filtra contratos pelo modelo do veiculo locado."},
|
||||
"contrato_numero": {"field_name": "contrato_numero", "label": "Contrato", "filter_type": "exact_match", "description": "Filtra o consolidado por numero publico do contrato."},
|
||||
}
|
||||
|
||||
_RENTAL_REPORTS = (
|
||||
{"report_key": "fleet_availability", "label": "Disponibilidade da frota", "description": "Resume disponibilidade, status e diaria vigente da frota de locacao.", "dataset_key": _RENTAL_FLEET_DATASET_KEY, "default_time_field": "created_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_fleet_vehicles", "available_fleet_vehicles", "average_daily_rate"), "dimension_fields": ("created_at", "categoria", "status", "modelo"), "filter_fields": ("created_at", "categoria", "status", "modelo", "placa")},
|
||||
{"report_key": "contracts_lifecycle", "label": "Contratos ativos e encerrados", "description": "Organiza o ciclo operacional dos contratos de locacao entre abertos, ativos e encerrados.", "dataset_key": _RENTAL_CONTRACTS_DATASET_KEY, "default_time_field": "data_inicio", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_contracts", "active_contracts", "closed_contracts"), "dimension_fields": ("data_inicio", "categoria", "status", "modelo_veiculo"), "filter_fields": ("data_inicio", "categoria", "status", "placa", "contrato_numero")},
|
||||
{"report_key": "overdue_returns", "label": "Devolucoes em atraso", "description": "Acompanha contratos com fim previsto vencido e sem devolucao consolidada.", "dataset_key": _RENTAL_CONTRACTS_DATASET_KEY, "default_time_field": "data_fim_prevista", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("overdue_contracts", "active_contracts"), "dimension_fields": ("data_fim_prevista", "categoria", "status", "placa"), "filter_fields": ("data_fim_prevista", "categoria", "status", "placa", "contrato_numero")},
|
||||
{"report_key": "fleet_occupancy", "label": "Ocupacao da frota", "description": "Consolida o uso da frota por contratos ativos ao longo do tempo e por categoria.", "dataset_key": _RENTAL_CONTRACTS_DATASET_KEY, "default_time_field": "data_inicio", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("occupied_vehicles", "active_contracts", "projected_revenue"), "dimension_fields": ("data_inicio", "categoria", "modelo_veiculo", "status"), "filter_fields": ("data_inicio", "categoria", "status", "modelo_veiculo", "placa")},
|
||||
{"report_key": "projected_vs_final_revenue", "label": "Receita prevista versus final", "description": "Compara o valor previsto na abertura do contrato com o valor final consolidado da locacao.", "dataset_key": _RENTAL_CONTRACTS_DATASET_KEY, "default_time_field": "updated_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("projected_revenue", "final_revenue", "revenue_delta"), "dimension_fields": ("updated_at", "categoria", "status", "modelo_veiculo"), "filter_fields": ("updated_at", "categoria", "status", "placa", "contrato_numero")},
|
||||
)
|
||||
|
||||
_BOT_FLOW_DATASET_KEY = "conversation_turns"
|
||||
|
||||
_BOT_FLOW_REPORT_METRICS = {
|
||||
"total_turns": {"key": "total_turns", "label": "Turnos totais", "aggregation": "count", "description": "Quantidade total de turnos processados no recorte operacional."},
|
||||
"completed_turns": {"key": "completed_turns", "label": "Turnos concluidos", "aggregation": "count_where_status_completed", "description": "Quantidade de turnos concluidos pelo fluxo operacional do bot."},
|
||||
"errored_turns": {"key": "errored_turns", "label": "Turnos com falha", "aggregation": "count_where_status_error", "description": "Quantidade de turnos com falha operacional no processamento."},
|
||||
"tool_routed_turns": {"key": "tool_routed_turns", "label": "Turnos com tool", "aggregation": "count_where_tool_called", "description": "Quantidade de turnos que acionaram pelo menos uma tool no fluxo."},
|
||||
"fallback_turns": {"key": "fallback_turns", "label": "Turnos em fallback", "aggregation": "count_where_action_fallback", "description": "Quantidade de turnos encaminhados para fallback funcional do bot."},
|
||||
"handoff_turns": {"key": "handoff_turns", "label": "Turnos em handoff", "aggregation": "count_where_action_handoff", "description": "Quantidade de turnos que escalaram para handoff humano."},
|
||||
}
|
||||
|
||||
_BOT_FLOW_DIMENSIONS = {
|
||||
"started_at": {"field_name": "started_at", "label": "Inicio do turno", "description": "Agrupamento temporal do inicio do processamento do turno.", "default_group_by": True},
|
||||
"completed_at": {"field_name": "completed_at", "label": "Fim do turno", "description": "Agrupamento temporal da finalizacao do turno processado.", "default_group_by": True},
|
||||
"channel": {"field_name": "channel", "label": "Canal", "description": "Recorte por canal operacional do atendimento."},
|
||||
"turn_status": {"field_name": "turn_status", "label": "Status do turno", "description": "Separa o fluxo pelos estados operacionais do turno."},
|
||||
"action": {"field_name": "action", "label": "Acao do fluxo", "description": "Recorte pela acao tomada pelo orquestrador durante o turno."},
|
||||
"tool_name": {"field_name": "tool_name", "label": "Tool acionada", "description": "Rastreio da tool utilizada durante o turno do bot."},
|
||||
"domain": {"field_name": "domain", "label": "Dominio operacional", "description": "Recorte pelo dominio operacional associado ao turno."},
|
||||
"intent": {"field_name": "intent", "label": "Intencao", "description": "Recorte pela intencao classificada para o turno."},
|
||||
}
|
||||
|
||||
_BOT_FLOW_FILTERS = {
|
||||
"started_at": {"field_name": "started_at", "label": "Inicio do turno", "filter_type": "date_range", "description": "Intervalo de inicio do processamento do turno.", "required": True},
|
||||
"completed_at": {"field_name": "completed_at", "label": "Fim do turno", "filter_type": "date_range", "description": "Intervalo de finalizacao do turno processado."},
|
||||
"channel": {"field_name": "channel", "label": "Canal", "filter_type": "enum", "description": "Filtra o fluxo por canal operacional."},
|
||||
"turn_status": {"field_name": "turn_status", "label": "Status do turno", "filter_type": "enum", "description": "Restringe o consolidado para um ou mais status do turno."},
|
||||
"action": {"field_name": "action", "label": "Acao do fluxo", "filter_type": "enum", "description": "Restringe o consolidado para uma ou mais acoes do fluxo do bot."},
|
||||
"tool_name": {"field_name": "tool_name", "label": "Tool acionada", "filter_type": "enum", "description": "Filtra os turnos pela tool utilizada no atendimento."},
|
||||
"domain": {"field_name": "domain", "label": "Dominio operacional", "filter_type": "enum", "description": "Filtra o fluxo pelo dominio operacional associado ao turno."},
|
||||
"intent": {"field_name": "intent", "label": "Intencao", "filter_type": "enum", "description": "Filtra o consolidado pela intencao classificada para o turno."},
|
||||
}
|
||||
|
||||
_BOT_FLOW_REPORTS = (
|
||||
{"report_key": "turn_status_overview", "label": "Status dos turnos", "description": "Acompanha o andamento operacional dos turnos por status, canal e dominio.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_turns", "completed_turns", "errored_turns"), "dimension_fields": ("started_at", "turn_status", "channel", "domain"), "filter_fields": ("started_at", "turn_status", "channel", "domain")},
|
||||
{"report_key": "action_routing_flow", "label": "Roteamento do fluxo", "description": "Organiza as acoes do orquestrador entre resposta, fallback, handoff e outros caminhos operacionais.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_turns", "fallback_turns", "handoff_turns"), "dimension_fields": ("started_at", "action", "channel", "domain"), "filter_fields": ("started_at", "action", "channel", "domain", "intent")},
|
||||
{"report_key": "tool_activation_flow", "label": "Uso operacional de tools", "description": "Mostra quais turnos acionaram tools e como isso se distribui no fluxo do bot.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("tool_routed_turns", "completed_turns", "errored_turns"), "dimension_fields": ("started_at", "tool_name", "action", "domain"), "filter_fields": ("started_at", "tool_name", "action", "domain", "intent")},
|
||||
{"report_key": "fallback_and_handoff", "label": "Fallback e handoff", "description": "Destaca turnos que saem do fluxo padrao para fallback funcional ou handoff humano.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("fallback_turns", "handoff_turns", "errored_turns"), "dimension_fields": ("started_at", "action", "channel", "intent"), "filter_fields": ("started_at", "action", "channel", "intent", "domain")},
|
||||
{"report_key": "operational_failures", "label": "Falhas operacionais do fluxo", "description": "Ajuda a triar turnos com falha por status, acao e canal operacional.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("errored_turns", "total_turns", "tool_routed_turns"), "dimension_fields": ("started_at", "turn_status", "action", "channel"), "filter_fields": ("started_at", "turn_status", "action", "channel", "tool_name")},
|
||||
)
|
||||
|
||||
_CONVERSATION_TELEMETRY_DATASET_KEY = "conversation_turns"
|
||||
|
||||
_CONVERSATION_TELEMETRY_REPORT_METRICS = {
|
||||
"total_turns": {"key": "total_turns", "label": "Turnos totais", "aggregation": "count", "description": "Quantidade total de turnos observados no recorte de telemetria."},
|
||||
"distinct_conversations": {"key": "distinct_conversations", "label": "Conversas distintas", "aggregation": "count_distinct", "description": "Quantidade de conversas distintas observadas no recorte selecionado."},
|
||||
"average_latency_ms": {"key": "average_latency_ms", "label": "Latencia media", "aggregation": "avg", "description": "Media do tempo de processamento do turno em milissegundos."},
|
||||
"p95_latency_ms": {"key": "p95_latency_ms", "label": "Latencia p95", "aggregation": "percentile_p95", "description": "Percentil 95 do tempo de processamento dos turnos observados."},
|
||||
"tool_routed_turns": {"key": "tool_routed_turns", "label": "Turnos com tool", "aggregation": "count_where_tool_called", "description": "Quantidade de turnos que acionaram pelo menos uma tool no atendimento."},
|
||||
"errored_turns": {"key": "errored_turns", "label": "Turnos com falha", "aggregation": "count_where_status_error", "description": "Quantidade de turnos com falha no recorte de telemetria."},
|
||||
}
|
||||
|
||||
_CONVERSATION_TELEMETRY_DIMENSIONS = {
|
||||
"started_at": {"field_name": "started_at", "label": "Inicio do turno", "description": "Agrupamento temporal do inicio do processamento do turno.", "default_group_by": True},
|
||||
"completed_at": {"field_name": "completed_at", "label": "Fim do turno", "description": "Agrupamento temporal da finalizacao do turno.", "default_group_by": True},
|
||||
"channel": {"field_name": "channel", "label": "Canal", "description": "Recorte por canal operacional do atendimento."},
|
||||
"domain": {"field_name": "domain", "label": "Dominio operacional", "description": "Recorte pelo dominio operacional associado ao turno."},
|
||||
"intent": {"field_name": "intent", "label": "Intencao", "description": "Recorte pela intencao classificada para o turno."},
|
||||
"tool_name": {"field_name": "tool_name", "label": "Tool acionada", "description": "Rastreio da tool utilizada durante o turno."},
|
||||
"turn_status": {"field_name": "turn_status", "label": "Status do turno", "description": "Separa a telemetria pelos estados observados do turno."},
|
||||
"action": {"field_name": "action", "label": "Acao do turno", "description": "Recorte pela acao operacional tomada pelo orquestrador."},
|
||||
}
|
||||
|
||||
_CONVERSATION_TELEMETRY_FILTERS = {
|
||||
"started_at": {"field_name": "started_at", "label": "Inicio do turno", "filter_type": "date_range", "description": "Intervalo de inicio do processamento do turno.", "required": True},
|
||||
"completed_at": {"field_name": "completed_at", "label": "Fim do turno", "filter_type": "date_range", "description": "Intervalo de finalizacao do turno processado."},
|
||||
"channel": {"field_name": "channel", "label": "Canal", "filter_type": "enum", "description": "Filtra a telemetria por canal operacional."},
|
||||
"domain": {"field_name": "domain", "label": "Dominio operacional", "filter_type": "enum", "description": "Filtra a telemetria pelo dominio associado ao turno."},
|
||||
"intent": {"field_name": "intent", "label": "Intencao", "filter_type": "enum", "description": "Filtra o recorte pela intencao classificada."},
|
||||
"tool_name": {"field_name": "tool_name", "label": "Tool acionada", "filter_type": "enum", "description": "Filtra os turnos pela tool utilizada durante o atendimento."},
|
||||
"turn_status": {"field_name": "turn_status", "label": "Status do turno", "filter_type": "enum", "description": "Restringe o consolidado para um ou mais status observados."},
|
||||
"action": {"field_name": "action", "label": "Acao do turno", "filter_type": "enum", "description": "Restringe o consolidado para uma ou mais acoes do orquestrador."},
|
||||
}
|
||||
|
||||
_CONVERSATION_TELEMETRY_REPORTS = (
|
||||
{"report_key": "conversation_volume", "label": "Volume de atendimento", "description": "Consolida o volume de turnos e conversas por periodo, canal e dominio.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_turns", "distinct_conversations"), "dimension_fields": ("started_at", "channel", "domain", "intent"), "filter_fields": ("started_at", "channel", "domain", "intent")},
|
||||
{"report_key": "latency_profile", "label": "Perfil de latencia", "description": "Organiza sinais de latencia media e p95 por canal, dominio e intencao.", "default_time_field": "completed_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("average_latency_ms", "p95_latency_ms", "total_turns"), "dimension_fields": ("completed_at", "channel", "domain", "intent"), "filter_fields": ("started_at", "completed_at", "channel", "domain", "intent")},
|
||||
{"report_key": "domain_distribution", "label": "Distribuicao por dominio", "description": "Mostra como o atendimento se distribui entre dominios, intencoes e canais.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("total_turns", "distinct_conversations"), "dimension_fields": ("started_at", "domain", "intent", "channel"), "filter_fields": ("started_at", "domain", "intent", "channel")},
|
||||
{"report_key": "tool_usage_telemetry", "label": "Uso de tools", "description": "Expoe quais tools aparecem com mais frequencia no atendimento e em quais contextos.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("tool_routed_turns", "total_turns", "errored_turns"), "dimension_fields": ("started_at", "tool_name", "domain", "channel"), "filter_fields": ("started_at", "tool_name", "domain", "channel", "intent")},
|
||||
{"report_key": "turn_health_status", "label": "Saude por status", "description": "Acompanha estados de saude do atendimento por status observado e acao tomada.", "default_time_field": "started_at", "default_granularity": OperationalReadGranularity.AGGREGATE, "metric_keys": ("errored_turns", "total_turns", "average_latency_ms"), "dimension_fields": ("started_at", "turn_status", "action", "channel"), "filter_fields": ("started_at", "turn_status", "action", "channel", "domain")},
|
||||
)
|
||||
|
||||
_REPORT_FAMILIES = (
|
||||
{
|
||||
"key": "sales",
|
||||
"label": "Vendas",
|
||||
"description": "Pedidos, conversao comercial e cancelamentos usados pela operacao interna.",
|
||||
"dataset_keys": ["sales_orders"],
|
||||
},
|
||||
{
|
||||
"key": "arrecadacao",
|
||||
"label": "Arrecadacao",
|
||||
"description": "Recebimentos de locacao e conciliacao operacional do faturamento.",
|
||||
"dataset_keys": ["rental_payments"],
|
||||
},
|
||||
{
|
||||
"key": "operacao",
|
||||
"label": "Operacao",
|
||||
"description": "Estoque, revisoes, frota e contratos que suportam o acompanhamento do dia a dia.",
|
||||
"dataset_keys": [
|
||||
"vehicle_inventory",
|
||||
"review_schedules",
|
||||
"rental_fleet",
|
||||
"rental_contracts",
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "telemetria_atendimento",
|
||||
"label": "Telemetria de atendimento",
|
||||
"description": "Turnos conversacionais, uso de tools e sinais de eficiencia do bot.",
|
||||
"dataset_keys": ["conversation_turns"],
|
||||
},
|
||||
{
|
||||
"key": "integration_deliveries",
|
||||
"label": "Entregas de integracao",
|
||||
"description": "Rastreio operacional das entregas para provedores e falhas de despacho.",
|
||||
"dataset_keys": ["integration_deliveries"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ReportService:
|
||||
def __init__(self, settings: AdminSettings):
|
||||
self.settings = settings
|
||||
|
||||
def build_overview_payload(self) -> dict:
|
||||
datasets = PRODUCT_OPERATIONAL_DATASETS
|
||||
near_real_time_count = sum(1 for dataset in datasets if dataset.freshness_target.value == "near_real_time")
|
||||
intra_hour_count = sum(1 for dataset in datasets if dataset.freshness_target.value == "intra_hour")
|
||||
return {
|
||||
"mode": "shared_contract_bootstrap",
|
||||
"metrics": [
|
||||
{
|
||||
"key": "datasets",
|
||||
"label": "Datasets liberados",
|
||||
"value": str(len(datasets)),
|
||||
"description": "Datasets operacionais explicitamente liberados para relatorios administrativos.",
|
||||
},
|
||||
{
|
||||
"key": "domains",
|
||||
"label": "Dominios operacionais",
|
||||
"value": str(len({dataset.domain for dataset in datasets})),
|
||||
"description": "Dominios cobertos pelo catalogo inicial de leitura administrativa.",
|
||||
},
|
||||
{
|
||||
"key": "near_real_time_targets",
|
||||
"label": "Metas near real time",
|
||||
"value": str(near_real_time_count),
|
||||
"description": "Datasets cuja UX espera consolidacao mais frequente sem leitura live do produto.",
|
||||
},
|
||||
{
|
||||
"key": "intra_hour_targets",
|
||||
"label": "Metas intra-hour",
|
||||
"value": str(intra_hour_count),
|
||||
"description": "Datasets de apoio operacional e telemetria servidos por consolidacao eventual intra-horaria.",
|
||||
},
|
||||
],
|
||||
"materialization": self._build_materialization_payload(),
|
||||
"report_families": list(_REPORT_FAMILIES),
|
||||
"next_steps": [
|
||||
"Criar snapshots sanitizados no admin para vendas, arrecadacao e operacao.",
|
||||
"Servir views dedicadas por caso de uso em vez de espelhar o schema operacional do produto.",
|
||||
"Exibir carimbo de atualizacao e watermark quando a camada de sincronizacao entrar em producao.",
|
||||
],
|
||||
}
|
||||
|
||||
def list_datasets_payload(self) -> dict:
|
||||
return {
|
||||
"source": _REPORT_SOURCE,
|
||||
"materialization": self._build_materialization_payload(),
|
||||
"datasets": [
|
||||
self._serialize_dataset_summary(dataset)
|
||||
for dataset in sorted(
|
||||
PRODUCT_OPERATIONAL_DATASETS,
|
||||
key=lambda item: (item.domain.value, item.dataset_key),
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
def get_dataset_payload(self, dataset_key: str) -> dict | None:
|
||||
dataset = get_operational_dataset(dataset_key)
|
||||
if dataset is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"source": _REPORT_SOURCE,
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"dataset": self._serialize_dataset_detail(dataset),
|
||||
}
|
||||
|
||||
def build_sales_overview_payload(self) -> dict:
|
||||
dataset = self._get_sales_dataset()
|
||||
return {
|
||||
"domain": dataset.domain,
|
||||
"mode": "sales_contract_bootstrap",
|
||||
"source_dataset_keys": [dataset.dataset_key],
|
||||
"metrics": [
|
||||
{
|
||||
"key": "source_datasets",
|
||||
"label": "Datasets fonte",
|
||||
"value": "1",
|
||||
"description": "A estrutura inicial de vendas nasce apoiada em um dataset sanitizado de pedidos.",
|
||||
},
|
||||
{
|
||||
"key": "initial_reports",
|
||||
"label": "Relatorios iniciais",
|
||||
"value": str(len(_SALES_REPORTS)),
|
||||
"description": "Casos de uso de vendas previstos para a primeira superficie administrativa do dominio.",
|
||||
},
|
||||
{
|
||||
"key": "allowed_fields",
|
||||
"label": "Campos liberados",
|
||||
"value": str(len(dataset.allowed_fields)),
|
||||
"description": "Campos operacionais expostos para agregacao e filtros de vendas.",
|
||||
},
|
||||
{
|
||||
"key": "blocked_fields",
|
||||
"label": "Campos bloqueados",
|
||||
"value": str(len(dataset.blocked_fields)),
|
||||
"description": "Campos sensiveis que permanecem fora do read model administrativo.",
|
||||
},
|
||||
{
|
||||
"key": "freshness_target",
|
||||
"label": "Meta de frescor",
|
||||
"value": dataset.freshness_target.value,
|
||||
"description": "Objetivo inicial de consolidacao para a UX dos relatorios de vendas.",
|
||||
},
|
||||
],
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"reports": [self._serialize_sales_report_summary(report) for report in _SALES_REPORTS],
|
||||
"next_steps": [
|
||||
"Materializar snapshot sanitizado de sales_orders no banco administrativo.",
|
||||
"Criar dedicated views separadas para volume, ticket medio e cancelamentos.",
|
||||
"Exibir watermark e timestamp da ultima consolidacao quando o ETL incremental entrar em producao.",
|
||||
],
|
||||
}
|
||||
|
||||
def list_sales_reports_payload(self) -> dict:
|
||||
dataset = self._get_sales_dataset()
|
||||
return {
|
||||
"domain": dataset.domain,
|
||||
"source": _REPORT_SOURCE,
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"reports": [self._serialize_sales_report_summary(report) for report in _SALES_REPORTS],
|
||||
}
|
||||
|
||||
def get_sales_report_payload(self, report_key: str) -> dict | None:
|
||||
normalized_report_key = self._normalize_key(report_key)
|
||||
dataset = self._get_sales_dataset()
|
||||
for report in _SALES_REPORTS:
|
||||
if report["report_key"] == normalized_report_key:
|
||||
return {
|
||||
"domain": dataset.domain,
|
||||
"source": _REPORT_SOURCE,
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"report": self._serialize_sales_report_detail(report, dataset),
|
||||
}
|
||||
return None
|
||||
|
||||
def build_revenue_overview_payload(self) -> dict:
|
||||
dataset = self._get_revenue_dataset()
|
||||
return {
|
||||
"area": "arrecadacao",
|
||||
"source_domain": dataset.domain,
|
||||
"mode": "revenue_contract_bootstrap",
|
||||
"source_dataset_keys": [dataset.dataset_key],
|
||||
"metrics": [
|
||||
{
|
||||
"key": "source_datasets",
|
||||
"label": "Datasets fonte",
|
||||
"value": "1",
|
||||
"description": "A estrutura inicial de arrecadacao nasce apoiada em um dataset sanitizado de pagamentos.",
|
||||
},
|
||||
{
|
||||
"key": "initial_reports",
|
||||
"label": "Relatorios iniciais",
|
||||
"value": str(len(_REVENUE_REPORTS)),
|
||||
"description": "Casos de uso iniciais de arrecadacao previstos para a primeira superficie administrativa.",
|
||||
},
|
||||
{
|
||||
"key": "allowed_fields",
|
||||
"label": "Campos liberados",
|
||||
"value": str(len(dataset.allowed_fields)),
|
||||
"description": "Campos operacionais expostos para agregacao e conciliacao de pagamentos.",
|
||||
},
|
||||
{
|
||||
"key": "blocked_fields",
|
||||
"label": "Campos bloqueados",
|
||||
"value": str(len(dataset.blocked_fields)),
|
||||
"description": "Campos sensiveis que permanecem fora do read model administrativo.",
|
||||
},
|
||||
{
|
||||
"key": "freshness_target",
|
||||
"label": "Meta de frescor",
|
||||
"value": dataset.freshness_target.value,
|
||||
"description": "Objetivo inicial de consolidacao para a UX dos relatorios de arrecadacao.",
|
||||
},
|
||||
],
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"reports": [self._serialize_revenue_report_summary(report) for report in _REVENUE_REPORTS],
|
||||
"next_steps": [
|
||||
"Materializar snapshot sanitizado de rental_payments no banco administrativo.",
|
||||
"Criar dedicated views separadas para arrecadacao por periodo e conciliacao por contrato.",
|
||||
"Cruzar contratos e pagamentos em uma etapa futura para abrir inadimplencia operacional sem leitura live do produto.",
|
||||
],
|
||||
}
|
||||
|
||||
def list_revenue_reports_payload(self) -> dict:
|
||||
dataset = self._get_revenue_dataset()
|
||||
return {
|
||||
"area": "arrecadacao",
|
||||
"source_domain": dataset.domain,
|
||||
"source": _REPORT_SOURCE,
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"reports": [self._serialize_revenue_report_summary(report) for report in _REVENUE_REPORTS],
|
||||
}
|
||||
|
||||
def get_revenue_report_payload(self, report_key: str) -> dict | None:
|
||||
normalized_report_key = self._normalize_key(report_key)
|
||||
dataset = self._get_revenue_dataset()
|
||||
for report in _REVENUE_REPORTS:
|
||||
if report["report_key"] == normalized_report_key:
|
||||
return {
|
||||
"area": "arrecadacao",
|
||||
"source_domain": dataset.domain,
|
||||
"source": _REPORT_SOURCE,
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"report": self._serialize_revenue_report_detail(report, dataset),
|
||||
}
|
||||
return None
|
||||
|
||||
def build_rental_overview_payload(self) -> dict:
|
||||
fleet_dataset = self._get_rental_fleet_dataset()
|
||||
contracts_dataset = self._get_rental_contracts_dataset()
|
||||
return {
|
||||
"area": "locacao",
|
||||
"source_domain": contracts_dataset.domain,
|
||||
"mode": "rental_contract_bootstrap",
|
||||
"source_dataset_keys": [fleet_dataset.dataset_key, contracts_dataset.dataset_key],
|
||||
"metrics": [
|
||||
{
|
||||
"key": "source_datasets",
|
||||
"label": "Datasets fonte",
|
||||
"value": "2",
|
||||
"description": "A estrutura inicial de locacao nasce sobre snapshots sanitizados de frota e contratos.",
|
||||
},
|
||||
{
|
||||
"key": "initial_reports",
|
||||
"label": "Relatorios iniciais",
|
||||
"value": str(len(_RENTAL_REPORTS)),
|
||||
"description": "Casos de uso operacionais de locacao previstos para a primeira superficie administrativa.",
|
||||
},
|
||||
{
|
||||
"key": "fleet_allowed_fields",
|
||||
"label": "Campos liberados da frota",
|
||||
"value": str(len(fleet_dataset.allowed_fields)),
|
||||
"description": "Campos expostos para disponibilidade, categoria e diaria vigente da frota.",
|
||||
},
|
||||
{
|
||||
"key": "contracts_allowed_fields",
|
||||
"label": "Campos liberados dos contratos",
|
||||
"value": str(len(contracts_dataset.allowed_fields)),
|
||||
"description": "Campos expostos para ciclo do contrato, ocupacao e devolucao operacional.",
|
||||
},
|
||||
{
|
||||
"key": "freshness_target",
|
||||
"label": "Meta de frescor",
|
||||
"value": contracts_dataset.freshness_target.value,
|
||||
"description": "Objetivo inicial de consolidacao para a UX dos relatorios de locacao.",
|
||||
},
|
||||
],
|
||||
"materialization": self._build_materialization_payload(contracts_dataset),
|
||||
"reports": [self._serialize_rental_report_summary(report) for report in _RENTAL_REPORTS],
|
||||
"next_steps": [
|
||||
"Materializar snapshots sanitizados de rental_fleet e rental_contracts no banco administrativo.",
|
||||
"Criar dedicated views separadas para disponibilidade da frota, contratos em curso e devolucoes em atraso.",
|
||||
"Combinar frota e contratos em uma camada futura de ocupacao sem consultar tabelas live do produto.",
|
||||
],
|
||||
}
|
||||
|
||||
def list_rental_reports_payload(self) -> dict:
|
||||
contracts_dataset = self._get_rental_contracts_dataset()
|
||||
return {
|
||||
"area": "locacao",
|
||||
"source_domain": contracts_dataset.domain,
|
||||
"source": _REPORT_SOURCE,
|
||||
"materialization": self._build_materialization_payload(contracts_dataset),
|
||||
"reports": [self._serialize_rental_report_summary(report) for report in _RENTAL_REPORTS],
|
||||
}
|
||||
|
||||
def get_rental_report_payload(self, report_key: str) -> dict | None:
|
||||
normalized_report_key = self._normalize_key(report_key)
|
||||
for report in _RENTAL_REPORTS:
|
||||
if report["report_key"] == normalized_report_key:
|
||||
dataset = self._get_rental_dataset(report["dataset_key"])
|
||||
return {
|
||||
"area": "locacao",
|
||||
"source_domain": dataset.domain,
|
||||
"source": _REPORT_SOURCE,
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"report": self._serialize_rental_report_detail(report, dataset),
|
||||
}
|
||||
return None
|
||||
|
||||
def build_bot_flow_overview_payload(self) -> dict:
|
||||
dataset = self._get_bot_flow_dataset()
|
||||
return {
|
||||
"area": "fluxo_bot",
|
||||
"source_domain": dataset.domain,
|
||||
"mode": "bot_flow_contract_bootstrap",
|
||||
"source_dataset_keys": [dataset.dataset_key],
|
||||
"metrics": [
|
||||
{
|
||||
"key": "source_datasets",
|
||||
"label": "Datasets fonte",
|
||||
"value": "1",
|
||||
"description": "A estrutura inicial do fluxo do bot nasce apoiada em um dataset sanitizado de turnos operacionais.",
|
||||
},
|
||||
{
|
||||
"key": "initial_reports",
|
||||
"label": "Relatorios iniciais",
|
||||
"value": str(len(_BOT_FLOW_REPORTS)),
|
||||
"description": "Casos de uso de operacao do fluxo do bot previstos para a primeira superficie administrativa.",
|
||||
},
|
||||
{
|
||||
"key": "allowed_fields",
|
||||
"label": "Campos liberados",
|
||||
"value": str(len(dataset.allowed_fields)),
|
||||
"description": "Campos operacionais expostos para triagem de status, acao e uso de tools.",
|
||||
},
|
||||
{
|
||||
"key": "blocked_fields",
|
||||
"label": "Campos bloqueados",
|
||||
"value": str(len(dataset.blocked_fields)),
|
||||
"description": "Campos sensiveis e mensagens livres que permanecem fora da operacao administrativa.",
|
||||
},
|
||||
{
|
||||
"key": "freshness_target",
|
||||
"label": "Meta de frescor",
|
||||
"value": dataset.freshness_target.value,
|
||||
"description": "Objetivo inicial de consolidacao para a UX dos relatorios operacionais do fluxo do bot.",
|
||||
},
|
||||
],
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"reports": [self._serialize_bot_flow_report_summary(report) for report in _BOT_FLOW_REPORTS],
|
||||
"next_steps": [
|
||||
"Materializar snapshot sanitizado de conversation_turns no banco administrativo.",
|
||||
"Criar dedicated views separadas para status do turno, roteamento operacional e falhas do fluxo.",
|
||||
"Reservar latencia e eficiencia detalhada para a etapa seguinte de telemetria conversacional.",
|
||||
],
|
||||
}
|
||||
|
||||
def list_bot_flow_reports_payload(self) -> dict:
|
||||
dataset = self._get_bot_flow_dataset()
|
||||
return {
|
||||
"area": "fluxo_bot",
|
||||
"source_domain": dataset.domain,
|
||||
"source": _REPORT_SOURCE,
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"reports": [self._serialize_bot_flow_report_summary(report) for report in _BOT_FLOW_REPORTS],
|
||||
}
|
||||
|
||||
def get_bot_flow_report_payload(self, report_key: str) -> dict | None:
|
||||
normalized_report_key = self._normalize_key(report_key)
|
||||
dataset = self._get_bot_flow_dataset()
|
||||
for report in _BOT_FLOW_REPORTS:
|
||||
if report["report_key"] == normalized_report_key:
|
||||
return {
|
||||
"area": "fluxo_bot",
|
||||
"source_domain": dataset.domain,
|
||||
"source": _REPORT_SOURCE,
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"report": self._serialize_bot_flow_report_detail(report, dataset),
|
||||
}
|
||||
return None
|
||||
|
||||
def build_conversation_telemetry_overview_payload(self) -> dict:
|
||||
dataset = self._get_conversation_telemetry_dataset()
|
||||
return {
|
||||
"area": "telemetria_conversacional",
|
||||
"source_domain": dataset.domain,
|
||||
"mode": "conversation_telemetry_contract_bootstrap",
|
||||
"source_dataset_keys": [dataset.dataset_key],
|
||||
"metrics": [
|
||||
{
|
||||
"key": "source_datasets",
|
||||
"label": "Datasets fonte",
|
||||
"value": "1",
|
||||
"description": "A estrutura inicial de telemetria conversacional nasce apoiada em um dataset sanitizado de turnos.",
|
||||
},
|
||||
{
|
||||
"key": "initial_reports",
|
||||
"label": "Relatorios iniciais",
|
||||
"value": str(len(_CONVERSATION_TELEMETRY_REPORTS)),
|
||||
"description": "Casos de uso iniciais de observabilidade conversacional previstos para a primeira superficie administrativa.",
|
||||
},
|
||||
{
|
||||
"key": "allowed_fields",
|
||||
"label": "Campos liberados",
|
||||
"value": str(len(dataset.allowed_fields)),
|
||||
"description": "Campos expostos para volume, latencia, distribuicao por dominio e uso de tools.",
|
||||
},
|
||||
{
|
||||
"key": "blocked_fields",
|
||||
"label": "Campos bloqueados",
|
||||
"value": str(len(dataset.blocked_fields)),
|
||||
"description": "Campos sensiveis e texto livre que permanecem fora da telemetria administrativa.",
|
||||
},
|
||||
{
|
||||
"key": "freshness_target",
|
||||
"label": "Meta de frescor",
|
||||
"value": dataset.freshness_target.value,
|
||||
"description": "Objetivo inicial de consolidacao para a UX dos relatorios de telemetria conversacional.",
|
||||
},
|
||||
],
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"reports": [self._serialize_conversation_telemetry_report_summary(report) for report in _CONVERSATION_TELEMETRY_REPORTS],
|
||||
"next_steps": [
|
||||
"Materializar snapshot sanitizado de conversation_turns no banco administrativo.",
|
||||
"Criar dedicated views separadas para volume, latencia e distribuicao por dominio do atendimento.",
|
||||
"Preparar buckets e watermark de consolidacao para comparativos historicos da telemetria.",
|
||||
],
|
||||
}
|
||||
|
||||
def list_conversation_telemetry_reports_payload(self) -> dict:
|
||||
dataset = self._get_conversation_telemetry_dataset()
|
||||
return {
|
||||
"area": "telemetria_conversacional",
|
||||
"source_domain": dataset.domain,
|
||||
"source": _REPORT_SOURCE,
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"reports": [self._serialize_conversation_telemetry_report_summary(report) for report in _CONVERSATION_TELEMETRY_REPORTS],
|
||||
}
|
||||
|
||||
def get_conversation_telemetry_report_payload(self, report_key: str) -> dict | None:
|
||||
normalized_report_key = self._normalize_key(report_key)
|
||||
dataset = self._get_conversation_telemetry_dataset()
|
||||
for report in _CONVERSATION_TELEMETRY_REPORTS:
|
||||
if report["report_key"] == normalized_report_key:
|
||||
return {
|
||||
"area": "telemetria_conversacional",
|
||||
"source_domain": dataset.domain,
|
||||
"source": _REPORT_SOURCE,
|
||||
"materialization": self._build_materialization_payload(dataset),
|
||||
"report": self._serialize_conversation_telemetry_report_detail(report, dataset),
|
||||
}
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _build_materialization_payload(dataset: OperationalDatasetContract | None = None) -> dict:
|
||||
reference_dataset = dataset or PRODUCT_OPERATIONAL_DATASETS[0]
|
||||
return {
|
||||
"report_read_model": reference_dataset.report_read_model,
|
||||
"consistency_model": reference_dataset.consistency_model,
|
||||
"sync_strategy": reference_dataset.sync_strategy,
|
||||
"storage_shape": reference_dataset.storage_shape,
|
||||
"query_surface": reference_dataset.query_surface,
|
||||
"uses_product_replica": reference_dataset.uses_product_replica,
|
||||
"direct_product_query_allowed": reference_dataset.direct_product_query_allowed,
|
||||
"refresh_behavior": _REFRESH_BEHAVIOR,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_dataset_summary(dataset) -> dict:
|
||||
return {
|
||||
"dataset_key": dataset.dataset_key,
|
||||
"domain": dataset.domain,
|
||||
"description": dataset.description,
|
||||
"source_table": dataset.source_table,
|
||||
"freshness_target": dataset.freshness_target,
|
||||
"allowed_granularities": list(dataset.allowed_granularities),
|
||||
"allowed_field_count": len(dataset.allowed_fields),
|
||||
"blocked_field_count": len(dataset.blocked_fields),
|
||||
"write_allowed": dataset.write_allowed,
|
||||
"materialization_status": _MATERIALIZATION_STATUS,
|
||||
"last_consolidated_at": None,
|
||||
"source_watermark": None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_dataset_detail(cls, dataset) -> dict:
|
||||
return {
|
||||
"dataset_key": dataset.dataset_key,
|
||||
"domain": dataset.domain,
|
||||
"description": dataset.description,
|
||||
"source_table": dataset.source_table,
|
||||
"read_permission": dataset.read_permission,
|
||||
"report_read_model": dataset.report_read_model,
|
||||
"consistency_model": dataset.consistency_model,
|
||||
"sync_strategy": dataset.sync_strategy,
|
||||
"storage_shape": dataset.storage_shape,
|
||||
"query_surface": dataset.query_surface,
|
||||
"uses_product_replica": dataset.uses_product_replica,
|
||||
"direct_product_query_allowed": dataset.direct_product_query_allowed,
|
||||
"freshness_target": dataset.freshness_target,
|
||||
"allowed_granularities": list(dataset.allowed_granularities),
|
||||
"write_allowed": dataset.write_allowed,
|
||||
"materialization_status": _MATERIALIZATION_STATUS,
|
||||
"last_consolidated_at": None,
|
||||
"source_watermark": None,
|
||||
"allowed_fields": [cls._serialize_field(field) for field in dataset.allowed_fields],
|
||||
"blocked_fields": [cls._serialize_field(field) for field in dataset.blocked_fields],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_sales_report_summary(cls, report_definition: dict) -> dict:
|
||||
return {
|
||||
"report_key": report_definition["report_key"],
|
||||
"label": report_definition["label"],
|
||||
"description": report_definition["description"],
|
||||
"dataset_key": _SALES_DATASET_KEY,
|
||||
"default_time_field": report_definition["default_time_field"],
|
||||
"default_granularity": report_definition["default_granularity"],
|
||||
"supported_metric_keys": list(report_definition["metric_keys"]),
|
||||
"supported_dimension_fields": list(report_definition["dimension_fields"]),
|
||||
"materialization_status": _MATERIALIZATION_STATUS,
|
||||
"last_consolidated_at": None,
|
||||
"source_watermark": None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_sales_report_detail(
|
||||
cls,
|
||||
report_definition: dict,
|
||||
dataset: OperationalDatasetContract,
|
||||
) -> dict:
|
||||
return {
|
||||
"report_key": report_definition["report_key"],
|
||||
"label": report_definition["label"],
|
||||
"description": report_definition["description"],
|
||||
"dataset_key": _SALES_DATASET_KEY,
|
||||
"default_time_field": report_definition["default_time_field"],
|
||||
"default_granularity": report_definition["default_granularity"],
|
||||
"metrics": [dict(_SALES_REPORT_METRICS[key]) for key in report_definition["metric_keys"]],
|
||||
"dimensions": [dict(_SALES_DIMENSIONS[field_name]) for field_name in report_definition["dimension_fields"]],
|
||||
"filters": [dict(_SALES_FILTERS[field_name]) for field_name in report_definition["filter_fields"]],
|
||||
"dataset": cls._serialize_dataset_detail(dataset),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_revenue_report_summary(cls, report_definition: dict) -> dict:
|
||||
return {
|
||||
"report_key": report_definition["report_key"],
|
||||
"label": report_definition["label"],
|
||||
"description": report_definition["description"],
|
||||
"dataset_key": _REVENUE_DATASET_KEY,
|
||||
"default_time_field": report_definition["default_time_field"],
|
||||
"default_granularity": report_definition["default_granularity"],
|
||||
"supported_metric_keys": list(report_definition["metric_keys"]),
|
||||
"supported_dimension_fields": list(report_definition["dimension_fields"]),
|
||||
"materialization_status": _MATERIALIZATION_STATUS,
|
||||
"last_consolidated_at": None,
|
||||
"source_watermark": None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_revenue_report_detail(
|
||||
cls,
|
||||
report_definition: dict,
|
||||
dataset: OperationalDatasetContract,
|
||||
) -> dict:
|
||||
return {
|
||||
"report_key": report_definition["report_key"],
|
||||
"label": report_definition["label"],
|
||||
"description": report_definition["description"],
|
||||
"dataset_key": _REVENUE_DATASET_KEY,
|
||||
"default_time_field": report_definition["default_time_field"],
|
||||
"default_granularity": report_definition["default_granularity"],
|
||||
"metrics": [dict(_REVENUE_REPORT_METRICS[key]) for key in report_definition["metric_keys"]],
|
||||
"dimensions": [dict(_REVENUE_DIMENSIONS[field_name]) for field_name in report_definition["dimension_fields"]],
|
||||
"filters": [dict(_REVENUE_FILTERS[field_name]) for field_name in report_definition["filter_fields"]],
|
||||
"dataset": cls._serialize_dataset_detail(dataset),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_rental_report_summary(cls, report_definition: dict) -> dict:
|
||||
return {
|
||||
"report_key": report_definition["report_key"],
|
||||
"label": report_definition["label"],
|
||||
"description": report_definition["description"],
|
||||
"dataset_key": report_definition["dataset_key"],
|
||||
"default_time_field": report_definition["default_time_field"],
|
||||
"default_granularity": report_definition["default_granularity"],
|
||||
"supported_metric_keys": list(report_definition["metric_keys"]),
|
||||
"supported_dimension_fields": list(report_definition["dimension_fields"]),
|
||||
"materialization_status": _MATERIALIZATION_STATUS,
|
||||
"last_consolidated_at": None,
|
||||
"source_watermark": None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_rental_report_detail(
|
||||
cls,
|
||||
report_definition: dict,
|
||||
dataset: OperationalDatasetContract,
|
||||
) -> dict:
|
||||
return {
|
||||
"report_key": report_definition["report_key"],
|
||||
"label": report_definition["label"],
|
||||
"description": report_definition["description"],
|
||||
"dataset_key": report_definition["dataset_key"],
|
||||
"default_time_field": report_definition["default_time_field"],
|
||||
"default_granularity": report_definition["default_granularity"],
|
||||
"metrics": [dict(_RENTAL_REPORT_METRICS[key]) for key in report_definition["metric_keys"]],
|
||||
"dimensions": [dict(_RENTAL_DIMENSIONS[field_name]) for field_name in report_definition["dimension_fields"]],
|
||||
"filters": [dict(_RENTAL_FILTERS[field_name]) for field_name in report_definition["filter_fields"]],
|
||||
"dataset": cls._serialize_dataset_detail(dataset),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_bot_flow_report_summary(cls, report_definition: dict) -> dict:
|
||||
return {
|
||||
"report_key": report_definition["report_key"],
|
||||
"label": report_definition["label"],
|
||||
"description": report_definition["description"],
|
||||
"dataset_key": _BOT_FLOW_DATASET_KEY,
|
||||
"default_time_field": report_definition["default_time_field"],
|
||||
"default_granularity": report_definition["default_granularity"],
|
||||
"supported_metric_keys": list(report_definition["metric_keys"]),
|
||||
"supported_dimension_fields": list(report_definition["dimension_fields"]),
|
||||
"materialization_status": _MATERIALIZATION_STATUS,
|
||||
"last_consolidated_at": None,
|
||||
"source_watermark": None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_bot_flow_report_detail(
|
||||
cls,
|
||||
report_definition: dict,
|
||||
dataset: OperationalDatasetContract,
|
||||
) -> dict:
|
||||
return {
|
||||
"report_key": report_definition["report_key"],
|
||||
"label": report_definition["label"],
|
||||
"description": report_definition["description"],
|
||||
"dataset_key": _BOT_FLOW_DATASET_KEY,
|
||||
"default_time_field": report_definition["default_time_field"],
|
||||
"default_granularity": report_definition["default_granularity"],
|
||||
"metrics": [dict(_BOT_FLOW_REPORT_METRICS[key]) for key in report_definition["metric_keys"]],
|
||||
"dimensions": [dict(_BOT_FLOW_DIMENSIONS[field_name]) for field_name in report_definition["dimension_fields"]],
|
||||
"filters": [dict(_BOT_FLOW_FILTERS[field_name]) for field_name in report_definition["filter_fields"]],
|
||||
"dataset": cls._serialize_dataset_detail(dataset),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_conversation_telemetry_report_summary(cls, report_definition: dict) -> dict:
|
||||
return {
|
||||
"report_key": report_definition["report_key"],
|
||||
"label": report_definition["label"],
|
||||
"description": report_definition["description"],
|
||||
"dataset_key": _CONVERSATION_TELEMETRY_DATASET_KEY,
|
||||
"default_time_field": report_definition["default_time_field"],
|
||||
"default_granularity": report_definition["default_granularity"],
|
||||
"supported_metric_keys": list(report_definition["metric_keys"]),
|
||||
"supported_dimension_fields": list(report_definition["dimension_fields"]),
|
||||
"materialization_status": _MATERIALIZATION_STATUS,
|
||||
"last_consolidated_at": None,
|
||||
"source_watermark": None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_conversation_telemetry_report_detail(
|
||||
cls,
|
||||
report_definition: dict,
|
||||
dataset: OperationalDatasetContract,
|
||||
) -> dict:
|
||||
return {
|
||||
"report_key": report_definition["report_key"],
|
||||
"label": report_definition["label"],
|
||||
"description": report_definition["description"],
|
||||
"dataset_key": _CONVERSATION_TELEMETRY_DATASET_KEY,
|
||||
"default_time_field": report_definition["default_time_field"],
|
||||
"default_granularity": report_definition["default_granularity"],
|
||||
"metrics": [dict(_CONVERSATION_TELEMETRY_REPORT_METRICS[key]) for key in report_definition["metric_keys"]],
|
||||
"dimensions": [dict(_CONVERSATION_TELEMETRY_DIMENSIONS[field_name]) for field_name in report_definition["dimension_fields"]],
|
||||
"filters": [dict(_CONVERSATION_TELEMETRY_FILTERS[field_name]) for field_name in report_definition["filter_fields"]],
|
||||
"dataset": cls._serialize_dataset_detail(dataset),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_field(field) -> dict:
|
||||
return {
|
||||
"name": field.name,
|
||||
"description": field.description,
|
||||
"sensitivity": field.sensitivity,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_key(value: str) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
@staticmethod
|
||||
def _get_sales_dataset() -> OperationalDatasetContract:
|
||||
dataset = get_operational_dataset(_SALES_DATASET_KEY)
|
||||
if dataset is None:
|
||||
raise RuntimeError("sales_orders contract is required to build sales reports")
|
||||
return dataset
|
||||
|
||||
@staticmethod
|
||||
def _get_revenue_dataset() -> OperationalDatasetContract:
|
||||
dataset = get_operational_dataset(_REVENUE_DATASET_KEY)
|
||||
if dataset is None:
|
||||
raise RuntimeError("rental_payments contract is required to build revenue reports")
|
||||
return dataset
|
||||
|
||||
@staticmethod
|
||||
def _get_rental_dataset(dataset_key: str) -> OperationalDatasetContract:
|
||||
dataset = get_operational_dataset(dataset_key)
|
||||
if dataset is None:
|
||||
raise RuntimeError(f"{dataset_key} contract is required to build rental reports")
|
||||
return dataset
|
||||
|
||||
@classmethod
|
||||
def _get_rental_fleet_dataset(cls) -> OperationalDatasetContract:
|
||||
return cls._get_rental_dataset(_RENTAL_FLEET_DATASET_KEY)
|
||||
|
||||
@classmethod
|
||||
def _get_rental_contracts_dataset(cls) -> OperationalDatasetContract:
|
||||
return cls._get_rental_dataset(_RENTAL_CONTRACTS_DATASET_KEY)
|
||||
|
||||
@staticmethod
|
||||
def _get_bot_flow_dataset() -> OperationalDatasetContract:
|
||||
dataset = get_operational_dataset(_BOT_FLOW_DATASET_KEY)
|
||||
if dataset is None:
|
||||
raise RuntimeError("conversation_turns contract is required to build bot flow reports")
|
||||
return dataset
|
||||
|
||||
@staticmethod
|
||||
def _get_conversation_telemetry_dataset() -> OperationalDatasetContract:
|
||||
dataset = get_operational_dataset(_CONVERSATION_TELEMETRY_DATASET_KEY)
|
||||
if dataset is None:
|
||||
raise RuntimeError("conversation_turns contract is required to build conversation telemetry reports")
|
||||
return dataset
|
||||
@ -0,0 +1,262 @@
|
||||
from admin_app.core import AdminCredentialStrategy, AdminSecurityService
|
||||
from admin_app.db.write_governance import (
|
||||
build_admin_write_governance_payload,
|
||||
build_admin_write_governance_source_payload,
|
||||
)
|
||||
from admin_app.core.settings import AdminSettings
|
||||
from shared.contracts import (
|
||||
BOT_GOVERNED_SETTINGS,
|
||||
FunctionalConfigurationPropagation,
|
||||
MODEL_RUNTIME_PROFILES,
|
||||
MODEL_RUNTIME_SEPARATION_RULES,
|
||||
SYSTEM_FUNCTIONAL_CONFIGURATIONS,
|
||||
get_functional_configuration,
|
||||
)
|
||||
|
||||
|
||||
class SystemService:
|
||||
def __init__(
|
||||
self,
|
||||
settings: AdminSettings,
|
||||
security_service: AdminSecurityService | None = None,
|
||||
):
|
||||
self.settings = settings
|
||||
self.security_service = security_service or AdminSecurityService(settings)
|
||||
|
||||
def build_root_payload(self) -> dict:
|
||||
return {
|
||||
"service": "orquestrador-admin",
|
||||
"status": "ok",
|
||||
"message": "Servico administrativo inicializado.",
|
||||
"environment": self.settings.admin_environment,
|
||||
}
|
||||
|
||||
def build_health_payload(self) -> dict:
|
||||
return {
|
||||
"service": "orquestrador-admin",
|
||||
"status": "ok",
|
||||
"version": self.settings.admin_version,
|
||||
}
|
||||
|
||||
def build_system_info_payload(self) -> dict:
|
||||
return {
|
||||
"service": "orquestrador-admin",
|
||||
"app_name": self.settings.admin_app_name,
|
||||
"environment": self.settings.admin_environment,
|
||||
"version": self.settings.admin_version,
|
||||
"api_prefix": self.settings.admin_api_prefix,
|
||||
"debug": self.settings.admin_debug,
|
||||
}
|
||||
|
||||
def build_runtime_configuration_payload(self) -> dict:
|
||||
return {
|
||||
"application": {
|
||||
"app_name": self.settings.admin_app_name,
|
||||
"environment": self.settings.admin_environment,
|
||||
"version": self.settings.admin_version,
|
||||
"api_prefix": self.settings.admin_api_prefix,
|
||||
"debug": self.settings.admin_debug,
|
||||
},
|
||||
"database": {
|
||||
"host": self.settings.admin_db_host,
|
||||
"port": self.settings.admin_db_port,
|
||||
"name": self.settings.admin_db_name,
|
||||
"cloud_sql_configured": bool(self.settings.admin_db_cloud_sql_connection_name),
|
||||
},
|
||||
}
|
||||
|
||||
def build_security_configuration_payload(self) -> AdminCredentialStrategy:
|
||||
return self.security_service.build_credential_strategy()
|
||||
|
||||
def build_model_runtime_separation_payload(self) -> dict:
|
||||
atendimento_configuration = get_functional_configuration("atendimento_runtime_profile")
|
||||
tool_generation_configuration = get_functional_configuration("tool_generation_runtime_profile")
|
||||
if atendimento_configuration is None or tool_generation_configuration is None:
|
||||
raise RuntimeError("Shared functional configuration contracts are not available.")
|
||||
|
||||
return {
|
||||
"runtime_profiles": [
|
||||
self._serialize_model_runtime_profile(runtime_profile)
|
||||
for runtime_profile in MODEL_RUNTIME_PROFILES
|
||||
],
|
||||
"separation_rules": list(MODEL_RUNTIME_SEPARATION_RULES),
|
||||
"atendimento_runtime_configuration": self._serialize_functional_configuration(
|
||||
atendimento_configuration
|
||||
),
|
||||
"tool_generation_runtime_configuration": self._serialize_functional_configuration(
|
||||
tool_generation_configuration
|
||||
),
|
||||
"bot_governed_parent_config_keys": sorted(
|
||||
{setting.parent_config_key for setting in BOT_GOVERNED_SETTINGS}
|
||||
),
|
||||
}
|
||||
|
||||
def build_functional_configuration_catalog_payload(self) -> dict:
|
||||
return {
|
||||
"mode": "shared_contract_bootstrap",
|
||||
"configurations": [
|
||||
self._serialize_functional_configuration(configuration)
|
||||
for configuration in SYSTEM_FUNCTIONAL_CONFIGURATIONS
|
||||
],
|
||||
"bot_governed_parent_config_keys": sorted(
|
||||
{setting.parent_config_key for setting in BOT_GOVERNED_SETTINGS}
|
||||
),
|
||||
"next_steps": [
|
||||
"Persistir estado funcional governado no admin antes da publicacao para o produto.",
|
||||
"Adicionar versionamento, auditoria e aprovacao humana para configuracoes alteraveis.",
|
||||
"Conectar o estado desejado do admin ao estado efetivo publicado no product.",
|
||||
],
|
||||
}
|
||||
|
||||
def get_functional_configuration_payload(self, config_key: str) -> dict | None:
|
||||
configuration = get_functional_configuration(config_key)
|
||||
if configuration is None:
|
||||
return None
|
||||
|
||||
linked_bot_settings = [
|
||||
self._serialize_bot_governed_setting(setting)
|
||||
for setting in BOT_GOVERNED_SETTINGS
|
||||
if setting.parent_config_key == configuration.config_key
|
||||
]
|
||||
related_runtime_profile = next(
|
||||
(
|
||||
self._serialize_model_runtime_profile(runtime_profile)
|
||||
for runtime_profile in MODEL_RUNTIME_PROFILES
|
||||
if runtime_profile.config_key == configuration.config_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"configuration": self._serialize_functional_configuration(configuration),
|
||||
"linked_bot_settings": linked_bot_settings,
|
||||
"related_runtime_profile": related_runtime_profile,
|
||||
"managed_by_bot_governance": bool(linked_bot_settings),
|
||||
}
|
||||
|
||||
def build_bot_governed_configuration_payload(self) -> dict:
|
||||
ordered_settings = sorted(
|
||||
BOT_GOVERNED_SETTINGS,
|
||||
key=lambda setting: (setting.parent_config_key, setting.area.value, setting.setting_key),
|
||||
)
|
||||
return {
|
||||
"parent_config_keys": sorted(
|
||||
{setting.parent_config_key for setting in BOT_GOVERNED_SETTINGS}
|
||||
),
|
||||
"settings": [
|
||||
self._serialize_bot_governed_setting(setting)
|
||||
for setting in ordered_settings
|
||||
],
|
||||
}
|
||||
|
||||
def build_write_governance_payload(self) -> dict:
|
||||
payload = build_admin_write_governance_payload()
|
||||
payload["governed_configuration_keys"] = sorted(
|
||||
configuration.config_key
|
||||
for configuration in SYSTEM_FUNCTIONAL_CONFIGURATIONS
|
||||
if configuration.propagation == FunctionalConfigurationPropagation.VERSIONED_PUBLICATION
|
||||
)
|
||||
return payload
|
||||
|
||||
def build_configuration_sources_payload(self) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"key": "application",
|
||||
"source": "env",
|
||||
"mutable": False,
|
||||
"description": "Metadados principais do admin runtime vindos de AdminSettings.",
|
||||
},
|
||||
{
|
||||
"key": "database",
|
||||
"source": "env",
|
||||
"mutable": False,
|
||||
"description": "Conexao administrativa derivada das variaveis de ambiente do servico.",
|
||||
},
|
||||
{
|
||||
"key": "security",
|
||||
"source": "env",
|
||||
"mutable": False,
|
||||
"description": "Politicas de senha, token e bootstrap lidas do runtime administrativo.",
|
||||
},
|
||||
{
|
||||
"key": "panel_session",
|
||||
"source": "runtime",
|
||||
"mutable": False,
|
||||
"description": "Cookies e sessao web do painel derivam da configuracao ativa do admin.",
|
||||
},
|
||||
{
|
||||
"key": "functional_configuration_contracts",
|
||||
"source": "shared_contract",
|
||||
"mutable": False,
|
||||
"description": "Catalogo compartilhado das configuracoes funcionais governadas entre admin e product.",
|
||||
},
|
||||
{
|
||||
"key": "bot_governed_configuration_contracts",
|
||||
"source": "shared_contract",
|
||||
"mutable": False,
|
||||
"description": "Regras compartilhadas dos campos do bot que ficam sob governanca administrativa.",
|
||||
},
|
||||
{
|
||||
"key": "model_runtime_separation",
|
||||
"source": "shared_contract",
|
||||
"mutable": False,
|
||||
"description": "Contratos compartilhados que separam o runtime do atendimento do runtime de geracao de tools.",
|
||||
},
|
||||
build_admin_write_governance_source_payload(),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _serialize_model_runtime_profile(runtime_profile) -> dict:
|
||||
return {
|
||||
"runtime_target": runtime_profile.runtime_target,
|
||||
"config_key": runtime_profile.config_key,
|
||||
"catalog_runtime_target": runtime_profile.catalog_runtime_target,
|
||||
"purpose": runtime_profile.purpose,
|
||||
"consumed_by_service": runtime_profile.consumed_by_service,
|
||||
"description": runtime_profile.description,
|
||||
"read_permission": runtime_profile.read_permission,
|
||||
"write_permission": runtime_profile.write_permission,
|
||||
"published_independently": runtime_profile.published_independently,
|
||||
"rollback_independently": runtime_profile.rollback_independently,
|
||||
"cross_target_propagation_allowed": runtime_profile.cross_target_propagation_allowed,
|
||||
"affects_customer_response": runtime_profile.affects_customer_response,
|
||||
"can_generate_code": runtime_profile.can_generate_code,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_functional_configuration(configuration) -> dict:
|
||||
return {
|
||||
"config_key": configuration.config_key,
|
||||
"domain": configuration.domain,
|
||||
"description": configuration.description,
|
||||
"source": configuration.source,
|
||||
"read_permission": configuration.read_permission,
|
||||
"write_permission": configuration.write_permission,
|
||||
"mutability": configuration.mutability,
|
||||
"propagation": configuration.propagation,
|
||||
"affects_product_runtime": configuration.affects_product_runtime,
|
||||
"direct_product_write_allowed": configuration.direct_product_write_allowed,
|
||||
"fields": [
|
||||
{
|
||||
"name": field.name,
|
||||
"description": field.description,
|
||||
"writable": field.writable,
|
||||
"secret": field.secret,
|
||||
}
|
||||
for field in configuration.fields
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_bot_governed_setting(setting) -> dict:
|
||||
return {
|
||||
"setting_key": setting.setting_key,
|
||||
"parent_config_key": setting.parent_config_key,
|
||||
"field_name": setting.field_name,
|
||||
"area": setting.area,
|
||||
"description": setting.description,
|
||||
"read_permission": setting.read_permission,
|
||||
"write_permission": setting.write_permission,
|
||||
"mutability": setting.mutability,
|
||||
"versioned_publication_required": setting.versioned_publication_required,
|
||||
"direct_product_write_allowed": setting.direct_product_write_allowed,
|
||||
}
|
||||
@ -0,0 +1,483 @@
|
||||
"""Serviço isolado de geração de tools via LLM para o runtime administrativo.
|
||||
|
||||
Este módulo é a única camada do admin_app que conversa com o Vertex AI para fins
|
||||
de geração de código. Ele é completamente separado do LLMService do product
|
||||
(app.services.ai.llm_service) e usa configurações próprias do AdminSettings.
|
||||
|
||||
Separação arquitetural garantida por:
|
||||
- shared.contracts.model_runtime_separation.ModelRuntimeTarget.TOOL_GENERATION
|
||||
- config keys: admin_tool_generation_model / admin_tool_generation_fallback_model
|
||||
- Nenhuma importação de app.* é permitida neste módulo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
import vertexai
|
||||
from google.api_core.exceptions import GoogleAPIError, NotFound
|
||||
from vertexai.generative_models import GenerationConfig, GenerativeModel
|
||||
|
||||
from admin_app.core.settings import AdminSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---- Constantes de geração ---------------------------------------------------
|
||||
|
||||
_PYTHON_BLOCK_RE = re.compile(
|
||||
r"```python\s*\n(.*?)```",
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Padrões que o código gerado não pode conter.
|
||||
# Aplicados antes das validações automáticas existentes no ToolManagementService.
|
||||
_DANGEROUS_PATTERNS: tuple[tuple[str, str], ...] = (
|
||||
(r"\bexec\s*\(", "uso de exec() proibido em tools geradas"),
|
||||
(r"\beval\s*\(", "uso de eval() proibido em tools geradas"),
|
||||
(r"\b__import__\s*\(", "uso de __import__() proibido em tools geradas"),
|
||||
(r"os\.system\s*\(", "chamada a os.system() proibida em tools geradas"),
|
||||
(r"os\.popen\s*\(", "chamada a os.popen() proibida em tools geradas"),
|
||||
(r"\bsubprocess\b", "uso de subprocess proibido em tools geradas"),
|
||||
(r"from\s+app\.", "importação de app.* proibida em tools geradas"),
|
||||
(r"from\s+admin_app\.", "importação de admin_app.* proibida em tools geradas"),
|
||||
(r"import\s+app\b", "importação direta de app proibida em tools geradas"),
|
||||
(r"import\s+admin_app\b", "importação direta de admin_app proibida em tools geradas"),
|
||||
(r"\bopen\s*\(", "acesso a sistema de arquivos via open() proibido em tools geradas"),
|
||||
(r"__builtins__", "acesso a __builtins__ proibido em tools geradas"),
|
||||
)
|
||||
|
||||
# Mapeamento de tipo de parâmetro para anotação Python legível
|
||||
_TYPE_ANNOTATION_MAP: dict[str, str] = {
|
||||
"string": "str",
|
||||
"integer": "int",
|
||||
"number": "float",
|
||||
"boolean": "bool",
|
||||
"object": "dict",
|
||||
"array": "list",
|
||||
}
|
||||
|
||||
# Cache de modelos Vertex AI instanciados (por nome de modelo)
|
||||
_MODEL_CACHE: dict[str, GenerativeModel] = {}
|
||||
|
||||
# Flag de controle de inicialização do SDK (evita reinit por instância)
|
||||
_VERTEX_INITIALIZED: bool = False
|
||||
|
||||
|
||||
class ToolGenerationService:
|
||||
"""Gera implementações de tools via Vertex AI no contexto administrativo.
|
||||
|
||||
Responsabilidades:
|
||||
- Construir prompt estruturado a partir dos metadados da tool
|
||||
- Chamar o modelo LLM de geração (separado do modelo de atendimento)
|
||||
- Extrair o bloco de código Python da resposta
|
||||
- Aplicar linting de segurança antes de devolver o código
|
||||
- Retornar resultado estruturado para o ToolManagementService
|
||||
|
||||
Não faz:
|
||||
- Não persiste artefatos (responsabilidade do ToolManagementService)
|
||||
- Não valida contrato nem assinatura (responsabilidade do ToolManagementService)
|
||||
- Não executa o código gerado
|
||||
"""
|
||||
|
||||
def __init__(self, settings: AdminSettings) -> None:
|
||||
self.settings = settings
|
||||
self._ensure_vertex_initialized()
|
||||
|
||||
def _ensure_vertex_initialized(self) -> None:
|
||||
global _VERTEX_INITIALIZED
|
||||
if _VERTEX_INITIALIZED:
|
||||
return
|
||||
# Reutiliza as credenciais do projeto Google já configuradas nas settings
|
||||
# do admin (que leem do .env, idêntico ao product). O isolamento é nos
|
||||
# parâmetros de modelo e temperatura — não na conta GCP.
|
||||
try:
|
||||
import os
|
||||
project_id = os.environ.get("GOOGLE_PROJECT_ID", "")
|
||||
location = os.environ.get("GOOGLE_LOCATION", "us-central1")
|
||||
vertexai.init(project=project_id, location=location)
|
||||
_VERTEX_INITIALIZED = True
|
||||
logger.info(
|
||||
"tool_generation_service_event=vertex_initialized project=%s location=%s",
|
||||
project_id,
|
||||
location,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"tool_generation_service_event=vertex_init_warning error=%s",
|
||||
exc,
|
||||
)
|
||||
|
||||
def _get_model(self, model_name: str) -> GenerativeModel:
|
||||
model = _MODEL_CACHE.get(model_name)
|
||||
if model is None:
|
||||
model = GenerativeModel(model_name)
|
||||
_MODEL_CACHE[model_name] = model
|
||||
return model
|
||||
|
||||
def _build_model_sequence(self, preferred_model: str | None) -> list[str]:
|
||||
"""Constrói a sequência de modelos a tentar, respeitando o preferred e o fallback."""
|
||||
sequence: list[str] = []
|
||||
candidates = [
|
||||
preferred_model,
|
||||
self.settings.admin_tool_generation_model,
|
||||
self.settings.admin_tool_generation_fallback_model,
|
||||
]
|
||||
for candidate in candidates:
|
||||
normalized = str(candidate or "").strip()
|
||||
if normalized and normalized not in sequence:
|
||||
sequence.append(normalized)
|
||||
return sequence
|
||||
|
||||
def _build_generation_prompt(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
display_name: str,
|
||||
domain: str,
|
||||
description: str,
|
||||
business_goal: str,
|
||||
parameters: list[dict],
|
||||
previous_source_code: str | None = None,
|
||||
change_request_notes: str | None = None,
|
||||
generation_iteration: int = 1,
|
||||
) -> str:
|
||||
"""Monta o prompt estruturado de geração enviado ao modelo.
|
||||
|
||||
O prompt descreve o contrato esperado, os restrições de importação,
|
||||
os parâmetros e o objetivo operacional da tool.
|
||||
"""
|
||||
signature_parts: list[str] = []
|
||||
parameter_lines: list[str] = []
|
||||
|
||||
for param in parameters:
|
||||
name = str(param.get("name") or "").strip().lower()
|
||||
if not name:
|
||||
continue
|
||||
param_type = str(param.get("parameter_type") or "string").strip().lower()
|
||||
description_param = str(param.get("description") or "").strip()
|
||||
required = bool(param.get("required", True))
|
||||
annotation = _TYPE_ANNOTATION_MAP.get(param_type, "str")
|
||||
|
||||
if required:
|
||||
signature_parts.append(f"{name}: {annotation}")
|
||||
else:
|
||||
signature_parts.append(f"{name}: {annotation} | None = None")
|
||||
|
||||
required_label = "obrigatório" if required else "opcional"
|
||||
parameter_lines.append(
|
||||
f" - {name} ({annotation}, {required_label}): {description_param}"
|
||||
)
|
||||
|
||||
signature = ", ".join(signature_parts)
|
||||
if signature:
|
||||
full_signature = f"async def run(*, {signature}) -> dict:"
|
||||
else:
|
||||
full_signature = "async def run() -> dict:"
|
||||
|
||||
parameters_block = (
|
||||
"\n".join(parameter_lines)
|
||||
if parameter_lines
|
||||
else " (nenhum parâmetro — a tool não recebe entrada contextual)"
|
||||
)
|
||||
|
||||
domain_context_map = {
|
||||
"vendas": (
|
||||
"O bot atua em um sistema de atendimento para concessionária automotiva. "
|
||||
"A tool opera no domínio de vendas: estoque de veículos, negociações, pedidos e cancelamentos."
|
||||
),
|
||||
"revisao": (
|
||||
"O bot atua em um sistema de atendimento de oficina automotiva. "
|
||||
"A tool opera no domínio de revisão: agendamentos, remarcações, listagem de serviços."
|
||||
),
|
||||
"locacao": (
|
||||
"O bot atua em um sistema de atendimento de locadora de veículos. "
|
||||
"A tool opera no domínio de locação: frota, contratos, pagamentos e devoluções."
|
||||
),
|
||||
"orquestracao": (
|
||||
"O bot atua em um sistema de orquestração conversacional. "
|
||||
"A tool opera no domínio de orquestração: controla fluxo, contexto e estado da conversa."
|
||||
),
|
||||
}
|
||||
domain_context = domain_context_map.get(
|
||||
str(domain or "").strip().lower(),
|
||||
"O bot atua em um sistema de atendimento automatizado.",
|
||||
)
|
||||
|
||||
normalized_previous_source = str(previous_source_code or "").strip()
|
||||
normalized_change_request_notes = str(change_request_notes or "").strip()
|
||||
prompt_mode = "geracao_inicial"
|
||||
refinement_block = ""
|
||||
if normalized_previous_source and normalized_change_request_notes:
|
||||
prompt_mode = "refatoracao_guiada_por_feedback"
|
||||
refinement_block = (
|
||||
"MODO DE EXECUCAO:\n"
|
||||
"- Esta nao e uma geracao do zero. Refatore a implementacao existente.\n"
|
||||
"- Preserve o contrato governado, o objetivo de negocio e os parametros da tool.\n"
|
||||
"- Corrija explicitamente os pontos apontados pela revisao humana.\n\n"
|
||||
"FEEDBACK HUMANO:\n"
|
||||
f"{normalized_change_request_notes}\n\n"
|
||||
"CODIGO ANTERIOR A SER REFATORADO:\n"
|
||||
f"```python\n{normalized_previous_source}\n```\n\n"
|
||||
)
|
||||
elif normalized_previous_source:
|
||||
prompt_mode = "regeneracao_com_contexto_previo"
|
||||
refinement_block = (
|
||||
"MODO DE EXECUCAO:\n"
|
||||
"- Existe um codigo anterior para esta mesma versao.\n"
|
||||
"- Use-o como referencia para manter continuidade e consistencia na implementacao.\n\n"
|
||||
"CODIGO ANTERIOR DE REFERENCIA:\n"
|
||||
f"```python\n{normalized_previous_source}\n```\n\n"
|
||||
)
|
||||
|
||||
return (
|
||||
"CONTEXTO DA EXECUCAO:\n"
|
||||
f"- Iteracao de geracao: {int(generation_iteration)}\n"
|
||||
f"- Modo do prompt: {prompt_mode}\n\n"
|
||||
f"{refinement_block}"
|
||||
"Você é um especialista em Python que gera implementações realistas de tools "
|
||||
"para um bot de atendimento.\n\n"
|
||||
f"CONTEXTO DO DOMÍNIO:\n{domain_context}\n\n"
|
||||
"CONTRATO OBRIGATÓRIO:\n"
|
||||
"- A função deve ser assíncrona: async def run(...)\n"
|
||||
"- Todos os parâmetros devem ser keyword-only (após *)\n"
|
||||
"- O tipo de retorno deve ser dict (JSON-serializável)\n"
|
||||
"- O módulo pode importar apenas stdlib (datetime, json, re, math, uuid, etc.)\n"
|
||||
"- Proibido importar: app.*, admin_app.*, subprocess, os.system, os.popen\n"
|
||||
"- Proibido usar: exec(), eval(), __import__(), open()\n\n"
|
||||
"TOOL A IMPLEMENTAR:\n"
|
||||
f"- Nome técnico: {tool_name}\n"
|
||||
f"- Nome de exibição: {display_name}\n"
|
||||
f"- Domínio: {domain}\n"
|
||||
f"- Descrição funcional: {description}\n"
|
||||
f"- Objetivo de negócio: {business_goal}\n\n"
|
||||
f"PARÂMETROS DA TOOL:\n{parameters_block}\n\n"
|
||||
f"ASSINATURA ESPERADA:\n{full_signature}\n\n"
|
||||
"INSTRUÇÕES DE GERAÇÃO:\n"
|
||||
"- Gere uma implementação realista que simule o comportamento esperado da tool.\n"
|
||||
"- O retorno deve incluir os campos relevantes ao domínio (não apenas echo dos argumentos).\n"
|
||||
"- Use dados fictícios mas verossímeis para simular a resposta operacional.\n"
|
||||
"- Nenhuma explicação ou comentário fora do código. Retorne apenas o bloco Python.\n"
|
||||
"- O módulo deve começar com um docstring descritivo.\n"
|
||||
"- Envolva o código em ```python ... ```.\n"
|
||||
)
|
||||
|
||||
def _extract_python_block(self, raw_response: str) -> str | None:
|
||||
"""Extrai o primeiro bloco ```python ... ``` da resposta do modelo."""
|
||||
normalized = str(raw_response or "").strip()
|
||||
match = _PYTHON_BLOCK_RE.search(normalized)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
# Fallback: se não há marcador de código mas o conteúdo parece Python
|
||||
if normalized.startswith("async def run") or normalized.startswith('"""'):
|
||||
return normalized
|
||||
return None
|
||||
|
||||
def _apply_safety_linting(self, source_code: str) -> list[str]:
|
||||
"""Verifica padrões perigosos no código gerado antes da validação formal.
|
||||
|
||||
Retorna lista de issues. Lista vazia = linting passou.
|
||||
"""
|
||||
issues: list[str] = []
|
||||
for pattern, description in _DANGEROUS_PATTERNS:
|
||||
if re.search(pattern, source_code, re.MULTILINE):
|
||||
issues.append(f"linting: {description}.")
|
||||
return issues
|
||||
|
||||
async def generate_tool_source(
|
||||
self,
|
||||
*,
|
||||
tool_name: str,
|
||||
display_name: str,
|
||||
domain: str,
|
||||
description: str,
|
||||
business_goal: str,
|
||||
parameters: list[dict],
|
||||
preferred_model: str | None = None,
|
||||
previous_source_code: str | None = None,
|
||||
change_request_notes: str | None = None,
|
||||
generation_iteration: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
"""Gera o código Python da tool a partir dos metadados do draft.
|
||||
|
||||
Retorna um dicionário com:
|
||||
- passed (bool): True se o código foi gerado e passou no linting
|
||||
- generated_source_code (str | None): código Python gerado
|
||||
- generation_model_used (str | None): modelo que gerou o código
|
||||
- prompt_rendered (str): prompt enviado ao modelo (para auditoria)
|
||||
- issues (list[str]): problemas encontrados (geração ou linting)
|
||||
- elapsed_ms (float): tempo total de geração em milissegundos
|
||||
"""
|
||||
prompt = self._build_generation_prompt(
|
||||
tool_name=tool_name,
|
||||
display_name=display_name,
|
||||
domain=domain,
|
||||
description=description,
|
||||
business_goal=business_goal,
|
||||
parameters=parameters,
|
||||
previous_source_code=previous_source_code,
|
||||
change_request_notes=change_request_notes,
|
||||
generation_iteration=generation_iteration,
|
||||
)
|
||||
|
||||
model_sequence = self._build_model_sequence(preferred_model)
|
||||
generation_config = GenerationConfig(
|
||||
temperature=self.settings.admin_tool_generation_temperature,
|
||||
max_output_tokens=self.settings.admin_tool_generation_max_output_tokens,
|
||||
)
|
||||
|
||||
raw_response: str | None = None
|
||||
generation_model_used: str | None = None
|
||||
last_error: Exception | None = None
|
||||
started_at = perf_counter()
|
||||
|
||||
import asyncio
|
||||
|
||||
for model_name in model_sequence:
|
||||
try:
|
||||
model = self._get_model(model_name)
|
||||
response = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
model.generate_content,
|
||||
prompt,
|
||||
generation_config=generation_config,
|
||||
),
|
||||
timeout=float(self.settings.admin_tool_generation_timeout_seconds),
|
||||
)
|
||||
candidate = (
|
||||
response.candidates[0]
|
||||
if getattr(response, "candidates", None)
|
||||
else None
|
||||
)
|
||||
content = getattr(candidate, "content", None)
|
||||
parts = list(getattr(content, "parts", None) or [])
|
||||
text_parts = [
|
||||
getattr(part, "text", None)
|
||||
for part in parts
|
||||
if isinstance(getattr(part, "text", None), str)
|
||||
]
|
||||
raw_response = "\n".join(
|
||||
t for t in text_parts if t and t.strip()
|
||||
).strip() or None
|
||||
|
||||
if raw_response is None:
|
||||
# Fallback para o atributo .text raiz
|
||||
try:
|
||||
raw_response = str(response.text or "").strip() or None
|
||||
except (AttributeError, ValueError):
|
||||
raw_response = None
|
||||
|
||||
generation_model_used = model_name
|
||||
break
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
last_error = TimeoutError(
|
||||
f"modelo '{model_name}' excedeu o timeout de "
|
||||
f"{self.settings.admin_tool_generation_timeout_seconds}s para geração de tools."
|
||||
)
|
||||
logger.warning(
|
||||
"tool_generation_service_event=timeout model=%s timeout_seconds=%s",
|
||||
model_name,
|
||||
self.settings.admin_tool_generation_timeout_seconds,
|
||||
)
|
||||
continue
|
||||
|
||||
except NotFound as exc:
|
||||
last_error = exc
|
||||
_MODEL_CACHE.pop(model_name, None)
|
||||
logger.warning(
|
||||
"tool_generation_service_event=model_not_found model=%s error=%s",
|
||||
model_name,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
except GoogleAPIError as exc:
|
||||
last_error = exc
|
||||
logger.warning(
|
||||
"tool_generation_service_event=api_error model=%s error=%s",
|
||||
model_name,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
logger.warning(
|
||||
"tool_generation_service_event=unexpected_error model=%s error=%s class=%s",
|
||||
model_name,
|
||||
exc,
|
||||
exc.__class__.__name__,
|
||||
)
|
||||
continue
|
||||
|
||||
elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
|
||||
|
||||
if raw_response is None or generation_model_used is None:
|
||||
error_detail = str(last_error) if last_error else "nenhum modelo disponivel respondeu"
|
||||
logger.error(
|
||||
"tool_generation_service_event=generation_failed tool_name=%s elapsed_ms=%s error=%s",
|
||||
tool_name,
|
||||
elapsed_ms,
|
||||
error_detail,
|
||||
)
|
||||
return {
|
||||
"passed": False,
|
||||
"generated_source_code": None,
|
||||
"generation_model_used": None,
|
||||
"prompt_rendered": prompt,
|
||||
"issues": [f"falha na geração via LLM: {error_detail}"],
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
|
||||
generated_source_code = self._extract_python_block(raw_response)
|
||||
if generated_source_code is None:
|
||||
logger.warning(
|
||||
"tool_generation_service_event=no_code_block tool_name=%s model=%s elapsed_ms=%s",
|
||||
tool_name,
|
||||
generation_model_used,
|
||||
elapsed_ms,
|
||||
)
|
||||
return {
|
||||
"passed": False,
|
||||
"generated_source_code": None,
|
||||
"generation_model_used": generation_model_used,
|
||||
"prompt_rendered": prompt,
|
||||
"issues": ["o modelo não retornou um bloco de código Python identificável."],
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
|
||||
linting_issues = self._apply_safety_linting(generated_source_code)
|
||||
if linting_issues:
|
||||
logger.warning(
|
||||
"tool_generation_service_event=linting_failed tool_name=%s model=%s issues=%s elapsed_ms=%s",
|
||||
tool_name,
|
||||
generation_model_used,
|
||||
linting_issues,
|
||||
elapsed_ms,
|
||||
)
|
||||
return {
|
||||
"passed": False,
|
||||
"generated_source_code": generated_source_code,
|
||||
"generation_model_used": generation_model_used,
|
||||
"prompt_rendered": prompt,
|
||||
"issues": linting_issues,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"tool_generation_service_event=generation_succeeded tool_name=%s model=%s elapsed_ms=%s",
|
||||
tool_name,
|
||||
generation_model_used,
|
||||
elapsed_ms,
|
||||
)
|
||||
return {
|
||||
"passed": True,
|
||||
"generated_source_code": generated_source_code,
|
||||
"generation_model_used": generation_model_used,
|
||||
"prompt_rendered": prompt,
|
||||
"issues": [],
|
||||
"elapsed_ms": elapsed_ms,
|
||||
}
|
||||
@ -0,0 +1,266 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from time import perf_counter
|
||||
from typing import Any
|
||||
|
||||
from admin_app.core.settings import AdminSettings
|
||||
from admin_app.db.database import AdminSessionLocal
|
||||
from admin_app.repositories import (
|
||||
ToolArtifactRepository,
|
||||
ToolDraftRepository,
|
||||
ToolMetadataRepository,
|
||||
ToolVersionRepository,
|
||||
)
|
||||
from admin_app.services.tool_generation_service import ToolGenerationService
|
||||
|
||||
|
||||
class ToolGenerationWorkerService:
|
||||
"""Executa a pipeline de geracao em um worker dedicado do runtime admin.
|
||||
|
||||
O worker abre a propria sessao administrativa e cria uma instancia isolada do
|
||||
ToolManagementService dentro da thread dedicada. Assim, a geracao e as
|
||||
validacoes nao compartilham a sessao SQLAlchemy da request web nem o pool de
|
||||
threads padrao usado pelas rotas sync do FastAPI.
|
||||
"""
|
||||
|
||||
_THREAD_NAME_PREFIX = "admin-tool-generation-worker"
|
||||
_DEFAULT_POLL_AFTER_MS = 1200
|
||||
|
||||
def __init__(self, settings: AdminSettings) -> None:
|
||||
self.settings = settings
|
||||
self.max_workers = max(1, int(settings.admin_tool_generation_worker_max_workers))
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=self.max_workers,
|
||||
thread_name_prefix=self._THREAD_NAME_PREFIX,
|
||||
)
|
||||
self._lock = threading.Lock()
|
||||
self._pending_jobs = 0
|
||||
self._jobs: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def shutdown(self, *, wait: bool = False) -> None:
|
||||
self._executor.shutdown(wait=wait, cancel_futures=True)
|
||||
|
||||
def execute_generation_pipeline(
|
||||
self,
|
||||
*,
|
||||
version_id: str,
|
||||
runner_staff_account_id: int,
|
||||
runner_name: str,
|
||||
runner_role,
|
||||
) -> dict[str, Any]:
|
||||
submitted_at = datetime.now(UTC).isoformat()
|
||||
with self._lock:
|
||||
self._pending_jobs += 1
|
||||
queued_jobs_before_submit = max(self._pending_jobs - 1, 0)
|
||||
|
||||
started_at = perf_counter()
|
||||
future = self._executor.submit(
|
||||
self._run_generation_pipeline_job,
|
||||
version_id,
|
||||
runner_staff_account_id,
|
||||
runner_name,
|
||||
runner_role,
|
||||
)
|
||||
try:
|
||||
payload = future.result()
|
||||
finally:
|
||||
with self._lock:
|
||||
self._pending_jobs = max(self._pending_jobs - 1, 0)
|
||||
pending_jobs_after_completion = self._pending_jobs
|
||||
|
||||
execution = {
|
||||
"mode": "dedicated_generation_worker",
|
||||
"target": "admin_tool_generation_worker",
|
||||
"dispatch_state": "completed",
|
||||
"worker_max_workers": self.max_workers,
|
||||
"worker_pending_jobs": pending_jobs_after_completion,
|
||||
"queued_jobs_before_submit": queued_jobs_before_submit,
|
||||
"submitted_at": submitted_at,
|
||||
"started_at": submitted_at,
|
||||
"completed_at": datetime.now(UTC).isoformat(),
|
||||
"elapsed_ms": round((perf_counter() - started_at) * 1000, 2),
|
||||
"worker_thread_name": str(payload.pop("_worker_thread_name", "")) or None,
|
||||
"poll_after_ms": None,
|
||||
"last_error": None,
|
||||
}
|
||||
enriched_payload = dict(payload)
|
||||
enriched_payload["execution"] = execution
|
||||
return enriched_payload
|
||||
|
||||
def dispatch_generation_pipeline(
|
||||
self,
|
||||
*,
|
||||
version_id: str,
|
||||
runner_staff_account_id: int,
|
||||
runner_name: str,
|
||||
runner_role,
|
||||
) -> dict[str, Any]:
|
||||
normalized_version_id = str(version_id or "").strip().lower()
|
||||
if not normalized_version_id:
|
||||
raise ValueError("Versao administrativa invalida para o worker de geracao.")
|
||||
|
||||
with self._lock:
|
||||
existing_job = self._jobs.get(normalized_version_id)
|
||||
if existing_job is not None and existing_job.get("dispatch_state") in {"queued", "running"}:
|
||||
return self._build_dispatch_snapshot_locked(existing_job)
|
||||
|
||||
self._pending_jobs += 1
|
||||
queued_jobs_before_submit = max(self._pending_jobs - 1, 0)
|
||||
job = {
|
||||
"version_id": normalized_version_id,
|
||||
"dispatch_state": "queued",
|
||||
"queued_jobs_before_submit": queued_jobs_before_submit,
|
||||
"submitted_at": datetime.now(UTC).isoformat(),
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"elapsed_ms": None,
|
||||
"worker_thread_name": None,
|
||||
"last_error": None,
|
||||
"result_payload": None,
|
||||
}
|
||||
self._jobs[normalized_version_id] = job
|
||||
self._executor.submit(
|
||||
self._run_generation_pipeline_job_async,
|
||||
normalized_version_id,
|
||||
runner_staff_account_id,
|
||||
runner_name,
|
||||
runner_role,
|
||||
)
|
||||
return self._build_dispatch_snapshot_locked(job)
|
||||
|
||||
def get_generation_pipeline_dispatch(self, version_id: str) -> dict[str, Any] | None:
|
||||
normalized_version_id = str(version_id or "").strip().lower()
|
||||
if not normalized_version_id:
|
||||
return None
|
||||
with self._lock:
|
||||
job = self._jobs.get(normalized_version_id)
|
||||
if job is None:
|
||||
return None
|
||||
return self._build_dispatch_snapshot_locked(job)
|
||||
|
||||
def _run_generation_pipeline_job_async(
|
||||
self,
|
||||
version_id: str,
|
||||
runner_staff_account_id: int,
|
||||
runner_name: str,
|
||||
runner_role,
|
||||
) -> None:
|
||||
self._mark_job_running(version_id)
|
||||
try:
|
||||
payload = self._run_generation_pipeline_job(
|
||||
version_id,
|
||||
runner_staff_account_id,
|
||||
runner_name,
|
||||
runner_role,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._mark_job_failed(version_id, exc)
|
||||
return
|
||||
self._mark_job_completed(version_id, payload)
|
||||
|
||||
def _mark_job_running(self, version_id: str) -> None:
|
||||
with self._lock:
|
||||
job = self._jobs.get(version_id)
|
||||
if job is None:
|
||||
return
|
||||
job["dispatch_state"] = "running"
|
||||
job["started_at"] = datetime.now(UTC).isoformat()
|
||||
job["worker_thread_name"] = threading.current_thread().name
|
||||
|
||||
def _mark_job_completed(self, version_id: str, payload: dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
job = self._jobs.get(version_id)
|
||||
if job is None:
|
||||
return
|
||||
completed_at = datetime.now(UTC).isoformat()
|
||||
started_reference = self._parse_job_timestamp(job.get("started_at")) or self._parse_job_timestamp(job.get("submitted_at"))
|
||||
elapsed_ms = None
|
||||
if started_reference is not None:
|
||||
elapsed_ms = round((datetime.now(UTC) - started_reference).total_seconds() * 1000, 2)
|
||||
job["dispatch_state"] = "completed"
|
||||
job["completed_at"] = completed_at
|
||||
job["elapsed_ms"] = elapsed_ms
|
||||
job["result_payload"] = dict(payload)
|
||||
job["last_error"] = None
|
||||
self._pending_jobs = max(self._pending_jobs - 1, 0)
|
||||
|
||||
def _mark_job_failed(self, version_id: str, exc: Exception) -> None:
|
||||
with self._lock:
|
||||
job = self._jobs.get(version_id)
|
||||
if job is None:
|
||||
return
|
||||
completed_at = datetime.now(UTC).isoformat()
|
||||
started_reference = self._parse_job_timestamp(job.get("started_at")) or self._parse_job_timestamp(job.get("submitted_at"))
|
||||
elapsed_ms = None
|
||||
if started_reference is not None:
|
||||
elapsed_ms = round((datetime.now(UTC) - started_reference).total_seconds() * 1000, 2)
|
||||
job["dispatch_state"] = "failed"
|
||||
job["completed_at"] = completed_at
|
||||
job["elapsed_ms"] = elapsed_ms
|
||||
job["last_error"] = f"{type(exc).__name__}: {exc}"
|
||||
self._pending_jobs = max(self._pending_jobs - 1, 0)
|
||||
|
||||
def _build_dispatch_snapshot_locked(self, job: dict[str, Any]) -> dict[str, Any]:
|
||||
dispatch_state = str(job.get("dispatch_state") or "queued")
|
||||
snapshot = {
|
||||
"mode": "dedicated_generation_worker_async",
|
||||
"target": "admin_tool_generation_worker",
|
||||
"dispatch_state": dispatch_state,
|
||||
"worker_max_workers": self.max_workers,
|
||||
"worker_pending_jobs": self._pending_jobs,
|
||||
"queued_jobs_before_submit": job.get("queued_jobs_before_submit", 0),
|
||||
"submitted_at": job.get("submitted_at"),
|
||||
"started_at": job.get("started_at"),
|
||||
"completed_at": job.get("completed_at"),
|
||||
"elapsed_ms": job.get("elapsed_ms"),
|
||||
"worker_thread_name": job.get("worker_thread_name"),
|
||||
"poll_after_ms": self._DEFAULT_POLL_AFTER_MS if dispatch_state in {"queued", "running"} else None,
|
||||
"last_error": job.get("last_error"),
|
||||
}
|
||||
result_payload = job.get("result_payload")
|
||||
if isinstance(result_payload, dict):
|
||||
snapshot["result_payload"] = dict(result_payload)
|
||||
return snapshot
|
||||
|
||||
@staticmethod
|
||||
def _parse_job_timestamp(value: Any) -> datetime | None:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _run_generation_pipeline_job(
|
||||
self,
|
||||
version_id: str,
|
||||
runner_staff_account_id: int,
|
||||
runner_name: str,
|
||||
runner_role,
|
||||
) -> dict[str, Any]:
|
||||
from admin_app.services.tool_management_service import ToolManagementService
|
||||
|
||||
db = AdminSessionLocal()
|
||||
try:
|
||||
service = ToolManagementService(
|
||||
settings=self.settings,
|
||||
draft_repository=ToolDraftRepository(db),
|
||||
version_repository=ToolVersionRepository(db),
|
||||
metadata_repository=ToolMetadataRepository(db),
|
||||
artifact_repository=ToolArtifactRepository(db),
|
||||
tool_generation_service=ToolGenerationService(self.settings),
|
||||
)
|
||||
payload = service.run_generation_pipeline(
|
||||
version_id,
|
||||
runner_staff_account_id=runner_staff_account_id,
|
||||
runner_name=runner_name,
|
||||
runner_role=runner_role,
|
||||
)
|
||||
payload = dict(payload)
|
||||
payload["_worker_thread_name"] = threading.current_thread().name
|
||||
return payload
|
||||
finally:
|
||||
db.close()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,478 @@
|
||||
:root {
|
||||
--admin-bg: #f6f1e8;
|
||||
--admin-surface: rgba(255, 255, 255, 0.84);
|
||||
--admin-surface-strong: rgba(255, 255, 255, 0.92);
|
||||
--admin-ink: #20242f;
|
||||
--admin-muted: #677084;
|
||||
--admin-accent: #144d47;
|
||||
--admin-accent-soft: rgba(20, 77, 71, 0.08);
|
||||
--admin-line: rgba(32, 36, 47, 0.08);
|
||||
--admin-shadow: 0 24px 60px rgba(56, 44, 23, 0.11);
|
||||
}
|
||||
|
||||
body.admin-view-body {
|
||||
min-height: 100vh;
|
||||
color: var(--admin-ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(20, 77, 71, 0.18), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(193, 106, 51, 0.16), transparent 24%),
|
||||
linear-gradient(180deg, #fbf7f1 0%, var(--admin-bg) 100%);
|
||||
}
|
||||
|
||||
.admin-shell-card,
|
||||
.admin-hero-card,
|
||||
.admin-surface-card,
|
||||
.admin-metric-card,
|
||||
.admin-module-card,
|
||||
.admin-login-card,
|
||||
.admin-login-info-card {
|
||||
background: var(--admin-surface);
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: var(--admin-shadow);
|
||||
}
|
||||
|
||||
.admin-shell-card,
|
||||
.admin-hero-card,
|
||||
.admin-surface-card,
|
||||
.admin-metric-card,
|
||||
.admin-login-card,
|
||||
.admin-login-info-card {
|
||||
border-radius: 1.75rem;
|
||||
}
|
||||
|
||||
.admin-module-card,
|
||||
.admin-roadmap-item,
|
||||
.admin-runtime-block,
|
||||
.admin-login-kpi,
|
||||
.admin-login-note,
|
||||
.admin-login-policy,
|
||||
.admin-login-session-card {
|
||||
border-radius: 1.35rem;
|
||||
}
|
||||
|
||||
.admin-sidebar-sticky {
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-runtime-block,
|
||||
.admin-module-card,
|
||||
.admin-roadmap-item,
|
||||
.admin-surface-link,
|
||||
.admin-login-kpi,
|
||||
.admin-login-note,
|
||||
.admin-login-policy,
|
||||
.admin-login-session-card {
|
||||
background: var(--admin-surface-strong);
|
||||
border: 1px solid var(--admin-line);
|
||||
}
|
||||
|
||||
.admin-hero-card,
|
||||
.admin-login-info-card,
|
||||
.admin-login-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(20, 77, 71, 0.08);
|
||||
}
|
||||
|
||||
.admin-hero-card::after,
|
||||
.admin-login-info-card::after,
|
||||
.admin-login-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto -6rem -8rem auto;
|
||||
width: 16rem;
|
||||
height: 16rem;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(20, 77, 71, 0.17), transparent 72%);
|
||||
}
|
||||
|
||||
.admin-nav-link {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.admin-nav-link:hover {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-color: rgba(20, 77, 71, 0.1);
|
||||
}
|
||||
|
||||
.admin-nav-link.active {
|
||||
background: linear-gradient(135deg, #163f3a 0%, #215a53 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-nav-link.active .text-dark,
|
||||
.admin-nav-link.active .text-secondary,
|
||||
.admin-nav-link.active .badge {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.admin-nav-link.active .badge {
|
||||
background: rgba(255, 255, 255, 0.14) !important;
|
||||
border-color: rgba(255, 255, 255, 0.16) !important;
|
||||
}
|
||||
|
||||
.admin-metric-card {
|
||||
border: 1px solid rgba(20, 77, 71, 0.06);
|
||||
}
|
||||
|
||||
.admin-module-card {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.admin-surface-link {
|
||||
padding: 1rem 1.1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-surface-link:hover {
|
||||
background: rgba(20, 77, 71, 0.05);
|
||||
}
|
||||
|
||||
.admin-roadmap-item {
|
||||
position: relative;
|
||||
padding-left: 1.3rem !important;
|
||||
}
|
||||
|
||||
.admin-roadmap-item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
bottom: 1rem;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(20, 77, 71, 0.72), rgba(20, 77, 71, 0.18));
|
||||
}
|
||||
|
||||
.admin-quick-actions .btn {
|
||||
min-height: 3.25rem;
|
||||
}
|
||||
|
||||
.admin-login-form .form-control {
|
||||
border: 1px solid rgba(20, 77, 71, 0.12);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.admin-login-form .form-control:focus {
|
||||
border-color: rgba(20, 77, 71, 0.32);
|
||||
box-shadow: 0 0 0 0.25rem rgba(20, 77, 71, 0.12);
|
||||
}
|
||||
|
||||
.admin-login-policy,
|
||||
.admin-login-kpi,
|
||||
.admin-login-note,
|
||||
.admin-login-session-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#admin-login-feedback {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
[data-panel-ready="true"] .admin-hero-card,
|
||||
[data-panel-ready="true"] .admin-shell-card,
|
||||
[data-panel-ready="true"] .admin-surface-card,
|
||||
[data-panel-ready="true"] .admin-metric-card,
|
||||
[data-panel-ready="true"] .admin-login-card,
|
||||
[data-panel-ready="true"] .admin-login-info-card,
|
||||
[data-panel-ready="true"] .admin-login-session-card {
|
||||
animation: admin-fade-up 520ms ease both;
|
||||
}
|
||||
|
||||
[data-panel-ready="true"] .admin-metric-card:nth-child(2),
|
||||
[data-panel-ready="true"] .admin-surface-card:nth-of-type(2),
|
||||
[data-panel-ready="true"] .admin-login-info-card {
|
||||
animation-delay: 120ms;
|
||||
}
|
||||
|
||||
@keyframes admin-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.admin-sidebar-sticky {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.admin-shell-card,
|
||||
.admin-hero-card,
|
||||
.admin-surface-card,
|
||||
.admin-metric-card,
|
||||
.admin-login-card,
|
||||
.admin-login-info-card {
|
||||
border-radius: 1.4rem;
|
||||
}
|
||||
|
||||
.admin-module-card,
|
||||
.admin-roadmap-item,
|
||||
.admin-runtime-block,
|
||||
.admin-login-kpi,
|
||||
.admin-login-note,
|
||||
.admin-login-policy,
|
||||
.admin-login-session-card {
|
||||
border-radius: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-tool-review-note,
|
||||
.admin-tool-workflow-card,
|
||||
.admin-tool-review-card,
|
||||
.admin-tool-publication-card,
|
||||
.admin-tool-inline-note,
|
||||
.admin-tool-empty-state {
|
||||
background: var(--admin-surface-strong);
|
||||
border: 1px solid var(--admin-line);
|
||||
border-radius: 1.35rem;
|
||||
}
|
||||
|
||||
.admin-tool-workflow-card,
|
||||
.admin-tool-review-card,
|
||||
.admin-tool-publication-card,
|
||||
.admin-tool-empty-state,
|
||||
.admin-tool-inline-note {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.admin-tool-review-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-tool-inline-note {
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.admin-tool-review-page .admin-hero-card::after {
|
||||
background: radial-gradient(circle, rgba(20, 77, 71, 0.2), transparent 72%);
|
||||
}
|
||||
|
||||
.admin-tool-form-pane,
|
||||
.admin-tool-preview-card,
|
||||
.admin-tool-parameter-row {
|
||||
background: var(--admin-surface-strong);
|
||||
border: 1px solid var(--admin-line);
|
||||
border-radius: 1.35rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.admin-tool-form-control {
|
||||
border: 1px solid rgba(20, 77, 71, 0.12);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.admin-tool-form-control:focus {
|
||||
border-color: rgba(20, 77, 71, 0.32);
|
||||
box-shadow: 0 0 0 0.25rem rgba(20, 77, 71, 0.12);
|
||||
}
|
||||
|
||||
.admin-tool-intake-chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.admin-tool-preview-meta {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-tool-preview-stack {
|
||||
border-top: 1px solid var(--admin-line);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.admin-tool-intake-page .admin-hero-card::after {
|
||||
background: radial-gradient(circle, rgba(193, 106, 51, 0.18), transparent 72%);
|
||||
}
|
||||
|
||||
.admin-collaborator-page .admin-hero-card::after {
|
||||
background: radial-gradient(circle, rgba(32, 36, 47, 0.16), transparent 72%);
|
||||
}
|
||||
|
||||
.admin-collaborator-card,
|
||||
.admin-collaborator-kpi {
|
||||
background: var(--admin-surface-strong);
|
||||
border: 1px solid var(--admin-line);
|
||||
border-radius: 1.35rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.admin-collaborator-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-collaborator-meta {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.admin-system-page .admin-hero-card::after {
|
||||
background: radial-gradient(circle, rgba(20, 77, 71, 0.22), transparent 72%);
|
||||
}
|
||||
|
||||
.admin-system-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.admin-system-stack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-system-item {
|
||||
background: var(--admin-surface-strong);
|
||||
border: 1px solid var(--admin-line);
|
||||
border-radius: 1.35rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.admin-system-meta {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.admin-system-chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.admin-commercial-reports-page .admin-hero-card::after {
|
||||
background: radial-gradient(circle, rgba(193, 106, 51, 0.2), transparent 72%);
|
||||
}
|
||||
|
||||
.admin-commercial-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.admin-commercial-stack,
|
||||
.admin-commercial-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-commercial-item {
|
||||
background: var(--admin-surface-strong);
|
||||
border: 1px solid var(--admin-line);
|
||||
border-radius: 1.35rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.admin-commercial-meta {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.admin-commercial-chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.admin-rental-reports-page .admin-hero-card::after {
|
||||
background: radial-gradient(circle, rgba(38, 88, 132, 0.2), transparent 72%);
|
||||
}
|
||||
|
||||
.admin-rental-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.admin-rental-stack,
|
||||
.admin-rental-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-rental-item {
|
||||
background: var(--admin-surface-strong);
|
||||
border: 1px solid var(--admin-line);
|
||||
border-radius: 1.35rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.admin-rental-meta {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.admin-rental-chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-bot-monitoring-page .admin-hero-card::after {
|
||||
background: radial-gradient(circle, rgba(22, 63, 58, 0.22), transparent 72%);
|
||||
}
|
||||
|
||||
.admin-bot-monitoring-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.admin-bot-monitoring-stack,
|
||||
.admin-bot-monitoring-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-bot-monitoring-item {
|
||||
background: var(--admin-surface-strong);
|
||||
border: 1px solid var(--admin-line);
|
||||
border-radius: 1.35rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.admin-bot-monitoring-meta {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.admin-bot-monitoring-chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-system-item,
|
||||
.admin-commercial-item,
|
||||
.admin-rental-item,
|
||||
.admin-bot-monitoring-item {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-system-item h4,
|
||||
.admin-commercial-item h4,
|
||||
.admin-rental-item h4,
|
||||
.admin-bot-monitoring-item h4 {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-system-chip-group .badge,
|
||||
.admin-commercial-chip-group .badge,
|
||||
.admin-rental-chip-group .badge,
|
||||
.admin-bot-monitoring-chip-group .badge {
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
}
|
||||
@ -0,0 +1,201 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AdminPanelNavigationItem(BaseModel):
|
||||
label: str
|
||||
href: str
|
||||
description: str
|
||||
badge: str | None = None
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
class AdminPanelQuickAction(BaseModel):
|
||||
label: str
|
||||
href: str
|
||||
button_class: str = "btn-outline-dark"
|
||||
|
||||
|
||||
class AdminPanelMetric(BaseModel):
|
||||
label: str
|
||||
value: str
|
||||
description: str
|
||||
|
||||
|
||||
class AdminPanelModuleCard(BaseModel):
|
||||
eyebrow: str
|
||||
title: str
|
||||
description: str
|
||||
status_label: str
|
||||
status_variant: str = "secondary"
|
||||
highlights: tuple[str, ...] = ()
|
||||
cta_label: str | None = None
|
||||
href: str | None = None
|
||||
is_available: bool = False
|
||||
|
||||
|
||||
class AdminPanelSurfaceLink(BaseModel):
|
||||
method: str
|
||||
label: str
|
||||
href: str
|
||||
description: str
|
||||
|
||||
|
||||
class AdminPanelRoadmapItem(BaseModel):
|
||||
step: str
|
||||
title: str
|
||||
description: str
|
||||
status_label: str
|
||||
|
||||
|
||||
class AdminPanelHomeView(BaseModel):
|
||||
service: str
|
||||
app_name: str
|
||||
panel_title: str
|
||||
panel_subtitle: str
|
||||
environment: str
|
||||
version: str
|
||||
api_prefix: str
|
||||
release_label: str
|
||||
navigation: tuple[AdminPanelNavigationItem, ...]
|
||||
quick_actions: tuple[AdminPanelQuickAction, ...]
|
||||
metrics: tuple[AdminPanelMetric, ...]
|
||||
modules: tuple[AdminPanelModuleCard, ...]
|
||||
surface_links: tuple[AdminPanelSurfaceLink, ...]
|
||||
roadmap: tuple[AdminPanelRoadmapItem, ...]
|
||||
|
||||
|
||||
class AdminLoginPageView(BaseModel):
|
||||
app_name: str
|
||||
title: str
|
||||
subtitle: str
|
||||
environment: str
|
||||
version: str
|
||||
dashboard_href: str
|
||||
auth_endpoint: str
|
||||
email_placeholder: str
|
||||
password_placeholder: str
|
||||
access_token_ttl_label: str
|
||||
refresh_token_ttl_label: str
|
||||
password_policy_label: str
|
||||
security_highlights: tuple[str, ...]
|
||||
integration_notes: tuple[str, ...]
|
||||
|
||||
|
||||
class AdminToolReviewWorkflowStep(BaseModel):
|
||||
eyebrow: str
|
||||
title: str
|
||||
description: str
|
||||
status_label: str
|
||||
status_variant: str = "secondary"
|
||||
|
||||
|
||||
class AdminToolReviewPageView(BaseModel):
|
||||
app_name: str
|
||||
title: str
|
||||
subtitle: str
|
||||
environment: str
|
||||
version: str
|
||||
dashboard_href: str
|
||||
overview_endpoint: str
|
||||
contracts_endpoint: str
|
||||
review_queue_endpoint: str
|
||||
publications_endpoint: str
|
||||
workflow: tuple[AdminToolReviewWorkflowStep, ...]
|
||||
review_notes: tuple[str, ...]
|
||||
approval_notes: tuple[str, ...]
|
||||
activation_notes: tuple[str, ...]
|
||||
|
||||
|
||||
class AdminToolIntakeDomainOption(BaseModel):
|
||||
value: str
|
||||
label: str
|
||||
description: str
|
||||
|
||||
|
||||
class AdminToolIntakeParameterTypeOption(BaseModel):
|
||||
value: str
|
||||
label: str
|
||||
description: str
|
||||
|
||||
|
||||
class AdminToolIntakePageView(BaseModel):
|
||||
app_name: str
|
||||
title: str
|
||||
subtitle: str
|
||||
environment: str
|
||||
version: str
|
||||
dashboard_href: str
|
||||
review_href: str
|
||||
intake_endpoint: str
|
||||
domain_options: tuple[AdminToolIntakeDomainOption, ...]
|
||||
parameter_type_options: tuple[AdminToolIntakeParameterTypeOption, ...]
|
||||
naming_rules: tuple[str, ...]
|
||||
submission_notes: tuple[str, ...]
|
||||
approval_notes: tuple[str, ...]
|
||||
|
||||
|
||||
class AdminCollaboratorManagementPageView(BaseModel):
|
||||
app_name: str
|
||||
title: str
|
||||
subtitle: str
|
||||
environment: str
|
||||
version: str
|
||||
dashboard_href: str
|
||||
collection_endpoint: str
|
||||
password_policy_label: str
|
||||
onboarding_notes: tuple[str, ...]
|
||||
governance_notes: tuple[str, ...]
|
||||
|
||||
|
||||
class AdminSystemConfigurationPageView(BaseModel):
|
||||
app_name: str
|
||||
title: str
|
||||
subtitle: str
|
||||
environment: str
|
||||
version: str
|
||||
dashboard_href: str
|
||||
overview_endpoint: str
|
||||
runtime_endpoint: str
|
||||
security_endpoint: str
|
||||
model_runtimes_endpoint: str
|
||||
functional_endpoint: str
|
||||
functional_detail_base: str
|
||||
bot_governance_endpoint: str
|
||||
access_notes: tuple[str, ...]
|
||||
governance_notes: tuple[str, ...]
|
||||
|
||||
|
||||
class AdminSalesRevenueReportsPageView(BaseModel):
|
||||
app_name: str
|
||||
title: str
|
||||
subtitle: str
|
||||
environment: str
|
||||
version: str
|
||||
dashboard_href: str
|
||||
sales_overview_endpoint: str
|
||||
revenue_overview_endpoint: str
|
||||
access_notes: tuple[str, ...]
|
||||
reading_notes: tuple[str, ...]
|
||||
|
||||
class AdminRentalReportsPageView(BaseModel):
|
||||
app_name: str
|
||||
title: str
|
||||
subtitle: str
|
||||
environment: str
|
||||
version: str
|
||||
dashboard_href: str
|
||||
overview_endpoint: str
|
||||
access_notes: tuple[str, ...]
|
||||
reading_notes: tuple[str, ...]
|
||||
|
||||
class AdminBotMonitoringPageView(BaseModel):
|
||||
app_name: str
|
||||
title: str
|
||||
subtitle: str
|
||||
environment: str
|
||||
version: str
|
||||
dashboard_href: str
|
||||
bot_flow_overview_endpoint: str
|
||||
telemetry_overview_endpoint: str
|
||||
access_notes: tuple[str, ...]
|
||||
reading_notes: tuple[str, ...]
|
||||
@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=AI Orquestrador Admin Runtime
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=vitor
|
||||
Group=vitor
|
||||
WorkingDirectory=/opt/orquestrador
|
||||
EnvironmentFile=/opt/orquestrador/.env.admin
|
||||
Environment=PATH=/opt/orquestrador/venv/bin
|
||||
ExecStart=/opt/orquestrador/venv/bin/python -m uvicorn admin_app.main:app --host 127.0.0.1 --port 8081
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=AI Orquestrador Product Runtime
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=vitor
|
||||
Group=vitor
|
||||
WorkingDirectory=/opt/orquestrador
|
||||
EnvironmentFile=/opt/orquestrador/.env.product
|
||||
Environment=PATH=/opt/orquestrador/venv/bin
|
||||
ExecStart=/opt/orquestrador/venv/bin/python -m app.integrations.telegram_satellite_service
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -0,0 +1,138 @@
|
||||
# ADR 0001 - Separar usuario de atendimento de conta administrativa interna
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Contexto
|
||||
Hoje o sistema possui um conceito principal de usuario em `app/db/mock_models.py` (`User`).
|
||||
Esse registro representa a identidade operacional do atendimento e nasce a partir de canais externos, como Telegram.
|
||||
Ele serve para vincular conversas, pedidos, locacoes, revisoes e contexto transacional do usuario final.
|
||||
|
||||
Para a frente de auto-incremento de tools, precisaremos de uma area interna com login, permissao, auditoria e publicacao controlada.
|
||||
Misturar essa conta interna com o `User` atual criaria problemas de seguranca, modelagem e isolamento de dominio.
|
||||
|
||||
## Decisao
|
||||
Vamos separar explicitamente dois dominios de identidade:
|
||||
|
||||
1. `AtendimentoUser`
|
||||
- Continua sendo o `User` atual do banco operacional/mock.
|
||||
- Representa clientes e pessoas atendidas por canais externos.
|
||||
- Continua vinculado a conversa, pedido, revisao, locacao e historico operacional.
|
||||
|
||||
2. `StaffAccount`
|
||||
- Sera uma nova entidade para acesso administrativo interno.
|
||||
- Representa funcionarios e administradores da empresa.
|
||||
- Sera usada para login no painel interno, configuracao do sistema, criacao/aprovacao de tools e auditoria.
|
||||
|
||||
## Fronteira entre os dois tipos de conta
|
||||
|
||||
### AtendimentoUser
|
||||
- Banco: operacional/mock (`MockBase`)
|
||||
- Origem: canal externo (`channel`, `external_id`)
|
||||
- Autenticacao: indireta, via canal de atendimento
|
||||
- Responsabilidade: atendimento ao cliente e contexto de negocio
|
||||
- Nao deve receber credenciais de painel interno
|
||||
|
||||
### StaffAccount
|
||||
- Banco: administrativo/tools (`Base`)
|
||||
- Origem: cadastro interno controlado
|
||||
- Autenticacao: login web proprio
|
||||
- Responsabilidade: administracao, configuracao e governanca de tools
|
||||
- Nao deve ser usado para identificar cliente do atendimento
|
||||
|
||||
## Racional para usar o banco administrativo/tools para StaffAccount
|
||||
O projeto ja possui um banco administrativo ligado a `Base`, hoje usado para `tools`.
|
||||
Como a nova frente trata de governanca do sistema e nao de jornada do cliente final, o lugar mais coerente para `StaffAccount` e para os metadados de geracao/publicacao e esse mesmo dominio administrativo.
|
||||
|
||||
Isso reduz acoplamento com o banco operacional e evita misturar seguranca interna com dados de atendimento.
|
||||
|
||||
## Entidades alvo derivadas desta decisao
|
||||
As proximas fases devem introduzir, no banco administrativo, entidades como:
|
||||
|
||||
- `StaffAccount`
|
||||
- `StaffSession` ou estrategia equivalente de token
|
||||
- `ToolDraft`
|
||||
- `ToolGenerationJob`
|
||||
- `ToolValidationRun`
|
||||
- `ToolPublication`
|
||||
- `AuditLog`
|
||||
|
||||
O banco operacional continua com entidades como:
|
||||
|
||||
- `User`
|
||||
- `Order`
|
||||
- `ReviewSchedule`
|
||||
- `RentalContract`
|
||||
- `ConversationTurn`
|
||||
|
||||
## Regras arquiteturais obrigatorias
|
||||
|
||||
1. Nenhuma rota administrativa deve reutilizar `User` do atendimento como identidade autenticada.
|
||||
2. Nenhuma regra de atendimento deve depender de `StaffAccount` para funcionar.
|
||||
3. O pipeline de geracao/publicacao de tools deve operar fora do caminho critico do atendimento.
|
||||
4. Toda ativacao de tool gerada deve ser auditavel e vinculada a um `StaffAccount`.
|
||||
5. O atendimento continua decidindo execucao com base no modelo; o painel administrativo apenas governa cadastro, validacao e publicacao.
|
||||
|
||||
## Papel inicial de permissao
|
||||
A primeira versao deve prever ao menos estes papeis:
|
||||
|
||||
- `diretor`: gerencia contas internas, aprova e publica tools, altera configuracoes sensiveis e cadastra novos colaboradores
|
||||
- `colaborador`: consulta o fluxo operacional do bot, cria drafts de tools e acompanha o andamento ate a aprovacao
|
||||
|
||||
## Estrutura tecnica sugerida
|
||||
|
||||
### Banco administrativo (`Base`)
|
||||
- `app/db/models/staff_account.py`
|
||||
- `app/db/models/tool_draft.py`
|
||||
- `app/db/models/tool_generation_job.py`
|
||||
- `app/db/models/audit_log.py`
|
||||
|
||||
### Repositorios
|
||||
- `app/repositories/staff_account_repository.py`
|
||||
- `app/repositories/tool_draft_repository.py`
|
||||
- `app/repositories/tool_generation_job_repository.py`
|
||||
|
||||
### Servicos
|
||||
- `app/services/admin/auth_service.py`
|
||||
- `app/services/admin/tool_draft_service.py`
|
||||
- `app/services/admin/tool_generation_service.py`
|
||||
- `app/services/admin/audit_service.py`
|
||||
|
||||
### API interna
|
||||
- `app/api/routes/admin_auth.py`
|
||||
- `app/api/routes/admin_tools.py`
|
||||
- `app/api/routes/admin_audit.py`
|
||||
|
||||
## Fluxo alvo de alto nivel
|
||||
1. `StaffAccount` faz login no painel interno.
|
||||
2. Um `colaborador` cria um `ToolDraft` com nome, descricao e parametros.
|
||||
3. Um job isolado gera a implementacao e executa validacoes.
|
||||
4. O resultado fica disponivel para revisao humana.
|
||||
5. Um `diretor` revisa, aprova e publica a tool.
|
||||
6. A tool publicada passa a integrar o registry ativo sem afetar o dominio de identidade do atendimento.
|
||||
|
||||
## Impacto nas proximas etapas
|
||||
A partir desta decisao, as proximas implementacoes devem seguir esta ordem:
|
||||
|
||||
1. Criar `StaffAccount` e autenticacao administrativa.
|
||||
2. Criar autorizacao por papel.
|
||||
3. Criar entidades de draft/versionamento/validacao.
|
||||
4. Criar pipeline isolado de geracao.
|
||||
5. Criar painel e rotas administrativas.
|
||||
|
||||
## Consequencias
|
||||
### Positivas
|
||||
- Isola seguranca interna do atendimento ao cliente.
|
||||
- Facilita auditoria e governanca.
|
||||
- Evita acoplamento indevido entre canal externo e painel interno.
|
||||
- Deixa clara a separacao entre operacao e administracao do sistema.
|
||||
|
||||
### Custos
|
||||
- Introduz novo conjunto de entidades e rotas.
|
||||
- Exige autenticacao e autorizacao dedicadas.
|
||||
- Aumenta a complexidade de bootstrap e persistencia do dominio administrativo.
|
||||
|
||||
## Fora do escopo desta ADR
|
||||
- Escolha definitiva do modelo para geracao de codigo.
|
||||
- Implementacao do frontend administrativo.
|
||||
- Definicao detalhada do sandbox de execucao das tools geradas.
|
||||
@ -0,0 +1,249 @@
|
||||
# ADR 0002 - Separar o runtime de produto do serviço administrativo
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Relacao com ADRs anteriores
|
||||
Esta decisao complementa a ADR 0001.
|
||||
A ADR 0001 separa identidade de atendimento e identidade administrativa.
|
||||
A ADR 0002 amplia essa separacao para o nivel de servicos e runtime.
|
||||
|
||||
## Contexto
|
||||
O sistema atual nasceu como um unico runtime orientado ao atendimento.
|
||||
Hoje ele concentra no mesmo projeto e no mesmo ciclo operacional:
|
||||
|
||||
- atendimento conversacional
|
||||
- orquestracao de tools
|
||||
- integracao com Telegram
|
||||
- estado conversacional
|
||||
- regras operacionais de vendas, revisao e locacao
|
||||
- administracao futura do sistema
|
||||
- geracao futura de novas tools
|
||||
- relatorios e configuracoes internas
|
||||
|
||||
A nova frente de evolucao exige um modulo administrativo mais robusto, com:
|
||||
|
||||
- login interno de funcionarios e administradores
|
||||
- configuracao do sistema
|
||||
- relatorios de vendas, arrecadacao e operacao
|
||||
- cadastro, geracao, validacao e publicacao de novas tools
|
||||
- auditoria de alteracoes e aprovacoes
|
||||
|
||||
Se tudo isso continuar no mesmo runtime do atendimento, teremos aumento de risco em quatro eixos:
|
||||
|
||||
1. Performance
|
||||
- jobs pesados de geracao e validacao podem concorrer com o atendimento.
|
||||
|
||||
2. Seguranca
|
||||
- login administrativo, aprovacoes e publicacao de codigo ficariam expostos no mesmo servico do produto.
|
||||
|
||||
3. Operacao
|
||||
- qualquer falha ou deploy administrativo pode impactar diretamente o atendimento.
|
||||
|
||||
4. Evolucao
|
||||
- o painel e a automacao interna possuem cadencia, dependencias e necessidades diferentes do runtime conversacional.
|
||||
|
||||
## Decisao
|
||||
Vamos separar a solucao em dois servicos distintos, inicialmente no mesmo repositorio.
|
||||
|
||||
### 1. Servico de produto
|
||||
Nome conceitual: `orquestrador-product`
|
||||
|
||||
Responsabilidades:
|
||||
- atendimento conversacional
|
||||
- integracao com Telegram e futuros canais de atendimento
|
||||
- orquestracao de tools em tempo de execucao
|
||||
- fluxos operacionais de vendas, revisao e locacao
|
||||
- leitura apenas de tools publicadas e configuracoes ativas
|
||||
|
||||
Esse servico continua sendo o runtime critico do produto.
|
||||
Ele deve permanecer leve, previsivel e protegido de cargas administrativas.
|
||||
|
||||
### 2. Servico administrativo
|
||||
Nome conceitual: `orquestrador-admin`
|
||||
|
||||
Responsabilidades:
|
||||
- autenticacao e autorizacao interna
|
||||
- painel administrativo
|
||||
- configuracoes do sistema
|
||||
- relatorios de vendas, arrecadacao e operacao
|
||||
- cadastro de drafts de tools
|
||||
- geracao de implementacoes
|
||||
- validacao automatica
|
||||
- aprovacao humana
|
||||
- publicacao controlada
|
||||
- auditoria de mudancas
|
||||
|
||||
Esse servico nao participa do hot path do atendimento.
|
||||
Ele governa o sistema, mas nao executa atendimento em tempo real.
|
||||
|
||||
## Decisao sobre repositorio
|
||||
Neste primeiro momento, os dois servicos permanecem no mesmo repositorio.
|
||||
|
||||
Motivos:
|
||||
- menor custo operacional inicial
|
||||
- versionamento conjunto das fronteiras compartilhadas
|
||||
- mais facilidade para evoluir contratos internos
|
||||
- menos atrito no inicio da iniciativa
|
||||
|
||||
No futuro, se a operacao justificar, eles podem ser separados em repositorios diferentes.
|
||||
Essa separacao nao e obrigatoria agora.
|
||||
|
||||
## Fronteira entre os servicos
|
||||
|
||||
### O que pertence ao servico de produto
|
||||
- LLM do atendimento
|
||||
- orquestrador
|
||||
- registry de tools ativas
|
||||
- execucao de tools aprovadas
|
||||
- fluxo de conversa
|
||||
- integracoes com canais externos de atendimento
|
||||
- persistencia operacional do usuario final
|
||||
|
||||
### O que pertence ao servico administrativo
|
||||
- `StaffAccount`
|
||||
- permissao por papel
|
||||
- painel interno
|
||||
- configuracao administrativa
|
||||
- relatorios e dashboards
|
||||
- pipeline de geracao de tools
|
||||
- versionamento de tools
|
||||
- aprovacao/publicacao
|
||||
- trilha de auditoria
|
||||
|
||||
## Principio de integracao entre os servicos
|
||||
A integracao entre `product` e `admin` deve ser preferencialmente assincrona ou orientada a publicacao de estado.
|
||||
O runtime de produto nao deve depender de uma chamada online ao servico administrativo para responder ao cliente.
|
||||
|
||||
Regra obrigatoria:
|
||||
- o atendimento deve continuar funcionando mesmo se o servico administrativo estiver indisponivel.
|
||||
|
||||
## Modelo de acoplamento permitido
|
||||
|
||||
### Permitido
|
||||
- leitura de tools publicadas
|
||||
- leitura de configuracoes marcadas como ativas
|
||||
- leitura de versoes aprovadas
|
||||
- sincronizacao de metadados publicados
|
||||
- consumo de eventos ou snapshots administrativos
|
||||
|
||||
### Nao permitido no hot path do atendimento
|
||||
- gerar tool sob demanda durante o atendimento
|
||||
- validar codigo em tempo real no runtime do produto
|
||||
- depender de login administrativo para executar atendimento
|
||||
- bloquear resposta ao usuario aguardando operacao do servico administrativo
|
||||
|
||||
## Estrategia de dados
|
||||
|
||||
### Banco do servico de produto
|
||||
Responsavel por:
|
||||
- usuarios de atendimento
|
||||
- pedidos
|
||||
- revisoes
|
||||
- locacoes
|
||||
- conversas
|
||||
- estado operacional
|
||||
- referencias de tools ativas necessarias ao runtime
|
||||
|
||||
### Banco do servico administrativo
|
||||
Responsavel por:
|
||||
- contas internas (`StaffAccount`)
|
||||
- sessoes e credenciais administrativas
|
||||
- configuracoes do sistema
|
||||
- relatorios consolidados
|
||||
- drafts de tools
|
||||
- jobs de geracao
|
||||
- execucoes de validacao
|
||||
- publicacoes
|
||||
- auditoria
|
||||
|
||||
## Conexao entre dados dos dois servicos
|
||||
A conexao entre `product` e `admin` para relatorios e auditoria operacional segue a seguinte direcao inicial:
|
||||
|
||||
1. O `product` permanece como fonte operacional primaria.
|
||||
2. Um `etl_incremental` fora do hot path exporta apenas datasets e campos aprovados em contrato compartilhado.
|
||||
3. O `admin` persiste `snapshot_table` sanitizadas e expoe `dedicated_view` para APIs e dashboard.
|
||||
4. Replica operacional pode aparecer depois apenas como fonte de extracao do ETL, nunca como backend direto do painel.
|
||||
|
||||
Decisao inicial recomendada:
|
||||
- manter o produto como fonte operacional
|
||||
- usar o servico administrativo para leitura consolidada, auditoria e governanca
|
||||
- evitar escrita administrativa direta nas tabelas operacionais do atendimento, salvo casos explicitamente versionados e controlados
|
||||
|
||||
## Estrutura tecnica sugerida no monorepo
|
||||
|
||||
### Produto
|
||||
- `app/` permanece como nucleo do runtime de atendimento
|
||||
- entrypoints de atendimento e integracoes continuam aqui
|
||||
|
||||
### Administrativo
|
||||
Criar uma nova arvore dedicada, por exemplo:
|
||||
- `admin_app/`
|
||||
- `api/`
|
||||
- `services/`
|
||||
- `repositories/`
|
||||
- `models/`
|
||||
- `main.py`
|
||||
|
||||
Ou, se quisermos maximizar reaproveitamento de convencao atual:
|
||||
- `app_admin/`
|
||||
|
||||
A escolha do nome pode ser definida na fase de scaffold.
|
||||
O importante nesta ADR e a separacao de runtime e responsabilidade.
|
||||
|
||||
## Deploy esperado
|
||||
No medio prazo, o deploy deve prever dois servicos distintos:
|
||||
|
||||
- `orquestrador-product`
|
||||
- `orquestrador-admin`
|
||||
|
||||
Cada um com:
|
||||
- variaveis de ambiente proprias
|
||||
- processo/servico dedicado
|
||||
- observabilidade propria
|
||||
- escala independente
|
||||
|
||||
## Implicacoes para modelo de IA
|
||||
A geracao de tools e automacao administrativa podem usar um modelo diferente do atendimento.
|
||||
Essa escolha fica facilitada pela separacao de servicos, pois:
|
||||
- evita disputa de recurso e custo com o chat principal
|
||||
- permite tuning de latencia e qualidade por caso de uso
|
||||
- reduz risco de sobrecarregar o atendimento
|
||||
|
||||
## Regras obrigatorias decorrentes desta ADR
|
||||
1. O runtime de produto nao executa pipeline de geracao de tools.
|
||||
2. O servico administrativo nao participa do hot path de resposta ao cliente.
|
||||
3. Toda tool nova nasce no servico administrativo e so chega ao produto depois de publicada.
|
||||
4. Relatorios e configuracoes internas pertencem ao servico administrativo.
|
||||
5. O produto so consome estado publicado e aprovado.
|
||||
6. Deploys do servico administrativo nao devem exigir redeploy simultaneo do produto, salvo mudanca de contrato compartilhado.
|
||||
|
||||
## Sequencia recomendada de implementacao
|
||||
1. Formalizar esta arquitetura em documentacao.
|
||||
2. Criar fundacao do servico administrativo no monorepo.
|
||||
3. Implementar `StaffAccount`, auth e papeis.
|
||||
4. Criar area de configuracao e relatorios basicos.
|
||||
5. Criar entidades de draft/publicacao de tools.
|
||||
6. Implementar pipeline isolado de geracao e validacao.
|
||||
7. Integrar publicacao de tools com o runtime de produto.
|
||||
|
||||
## Consequencias
|
||||
### Positivas
|
||||
- isola o atendimento das cargas administrativas
|
||||
- melhora seguranca
|
||||
- facilita escalabilidade independente
|
||||
- prepara o sistema para governanca e auditoria reais
|
||||
- reduz risco operacional no produto
|
||||
|
||||
### Custos
|
||||
- aumenta a complexidade arquitetural
|
||||
- exige contratos claros entre servicos
|
||||
- traz mais trabalho de deploy, observabilidade e configuracao
|
||||
- exige estrategia de compartilhamento de dados para relatorios
|
||||
|
||||
## Fora do escopo desta ADR
|
||||
- implementar o scaffold real do segundo servico
|
||||
- escolher o modelo definitivo de geracao
|
||||
- definir o formato final de sincronizacao de dados analiticos
|
||||
- definir a UI final do painel administrativo
|
||||
|
||||
@ -0,0 +1,108 @@
|
||||
# Configuracoes Do Bot Governadas Pelo Admin
|
||||
|
||||
## Objetivo
|
||||
|
||||
Definir exatamente quais configuracoes do bot de atendimento entram sob governanca do `orquestrador-admin`.
|
||||
|
||||
Esta etapa detalha, em nivel de campo, a parte do runtime do bot que pode ser consultada por `colaborador` e alterada por `diretor`.
|
||||
|
||||
## Decisao
|
||||
|
||||
O `admin` governa apenas configuracoes funcionais do bot de atendimento.
|
||||
|
||||
Isso inclui:
|
||||
|
||||
- escolha do modelo homologado usado no atendimento
|
||||
- politicas de resposta do bot
|
||||
- politicas de uso de tools
|
||||
- politicas de fallback e handoff humano
|
||||
- politicas operacionais por canal
|
||||
|
||||
Essa fronteira fica formalizada em `shared/contracts/bot_governed_configuration.py`.
|
||||
|
||||
## Configuracoes governadas
|
||||
|
||||
### 1. Selecao de modelo do bot
|
||||
|
||||
Campos governados:
|
||||
|
||||
- `provider`
|
||||
- `model_name`
|
||||
|
||||
Esses campos definem qual modelo homologado responde ao cliente final.
|
||||
|
||||
### 2. Geracao de resposta
|
||||
|
||||
Campos governados:
|
||||
|
||||
- `temperature`
|
||||
- `max_output_tokens`
|
||||
- `prompt_profile_ref`
|
||||
|
||||
Esses campos controlam o perfil funcional da resposta, sem expor o painel a segredos ou internals de infraestrutura.
|
||||
|
||||
### 3. Uso de tools
|
||||
|
||||
Campos governados:
|
||||
|
||||
- `tool_policy_ref`
|
||||
- `max_tool_calls_per_turn`
|
||||
- `confirmation_policy`
|
||||
|
||||
Esses campos definem como o bot pode usar tools e quando precisa de confirmacao antes de acao critica.
|
||||
|
||||
### 4. Fallback e handoff
|
||||
|
||||
Campos governados:
|
||||
|
||||
- `fallback_mode`
|
||||
- `handoff_enabled`
|
||||
- `handoff_intents`
|
||||
|
||||
Esses campos governam quando o fluxo segue fallback controlado e quando encaminha para atendimento humano.
|
||||
|
||||
### 5. Operacao por canal
|
||||
|
||||
Campos governados:
|
||||
|
||||
- `enabled`
|
||||
- `maintenance_mode`
|
||||
- `default_route`
|
||||
- `operation_window_ref`
|
||||
|
||||
Esses campos permitem controlar disponibilidade e comportamento funcional por canal homologado.
|
||||
|
||||
## O que nao entra como configuracao do bot
|
||||
|
||||
As seguintes superficies ficam fora desta governanca:
|
||||
|
||||
- configuracao de modelo para geracao de tools
|
||||
- credenciais de provedor e segredos
|
||||
- conteudo bruto de prompt sensivel
|
||||
- variaveis de ambiente e infraestrutura
|
||||
- implementacao interna das tools
|
||||
- alteracao direta em tabelas operacionais do `product`
|
||||
|
||||
## Regras obrigatorias
|
||||
|
||||
### 1. Leitura por `colaborador`, alteracao por `diretor`
|
||||
|
||||
- `colaborador` consulta via `view_system`
|
||||
- `diretor` consulta e altera via `manage_settings`
|
||||
|
||||
### 2. Sem escrita direta no runtime do produto
|
||||
|
||||
O painel registra estado desejado e governado.
|
||||
O `product` consome apenas configuracao publicada, versionada e auditavel.
|
||||
|
||||
### 3. Separacao do runtime de geracao
|
||||
|
||||
O runtime usado para gerar tools continua em trilha propria.
|
||||
Ele nao deve ser tratado como configuracao do bot de atendimento.
|
||||
|
||||
## Consequencias positivas
|
||||
|
||||
- deixa a tela de configuracao do bot mais clara e segura
|
||||
- evita que a UI misture atendimento com geracao de tools
|
||||
- preserva a governanca de publicacao entre `admin` e `product`
|
||||
- prepara a proxima etapa de rotas administrativas para configuracao funcional do sistema
|
||||
@ -0,0 +1,163 @@
|
||||
# Estrategia De Credenciais Para StaffAccount
|
||||
|
||||
Este documento define a estrategia inicial de credenciais para contas administrativas internas (`StaffAccount`).
|
||||
|
||||
## Objetivo
|
||||
|
||||
Permitir autenticacao web no `orquestrador-admin` sem misturar a identidade do painel com o `User` do atendimento.
|
||||
|
||||
## O que e o StaffAccount
|
||||
|
||||
`StaffAccount` e a conta administrativa interna do sistema.
|
||||
|
||||
Ela representa um funcionario ou administrador da empresa e existe apenas no dominio administrativo.
|
||||
Nao deve ser usada para identificar cliente do atendimento.
|
||||
|
||||
Responsabilidades principais do `StaffAccount`:
|
||||
|
||||
- autenticar acesso ao painel administrativo
|
||||
- carregar papel de autorizacao (`colaborador`, `diretor`)
|
||||
- auditar quem executou uma acao interna
|
||||
- governar drafts, configuracoes, relatorios e publicacao de tools
|
||||
|
||||
## O que e a StaffSession
|
||||
|
||||
`StaffSession` representa uma sessao administrativa ativa derivada de um login do `StaffAccount`.
|
||||
|
||||
Cada sessao possui:
|
||||
|
||||
- `session_id`
|
||||
- `refresh_token_hash`
|
||||
- data de expiracao
|
||||
- marcador de revogacao
|
||||
- metadados simples de IP e user-agent
|
||||
|
||||
Isso permite tratar login, refresh e logout sem depender apenas de um access token solto.
|
||||
|
||||
## Identificador de login
|
||||
|
||||
O identificador principal de login sera:
|
||||
|
||||
- `email`
|
||||
|
||||
Regras:
|
||||
|
||||
- deve ser unico no banco administrativo
|
||||
- deve ser normalizado em lowercase na camada de servico e repositorio
|
||||
- nao sera compartilhado com a identidade do atendimento
|
||||
|
||||
## Segredo primario
|
||||
|
||||
O segredo primario inicial sera:
|
||||
|
||||
- senha gerenciada internamente
|
||||
|
||||
A senha nunca deve ser armazenada em texto puro.
|
||||
|
||||
## Estrategia de hash
|
||||
|
||||
A estrategia inicial definida para `password_hash` e:
|
||||
|
||||
- esquema: `pbkdf2_sha256`
|
||||
- iteracoes padrao: `390000`
|
||||
- salt aleatorio por conta
|
||||
- pepper opcional via ambiente: `ADMIN_AUTH_PASSWORD_PEPPER`
|
||||
|
||||
Formato de persistencia adotado:
|
||||
|
||||
- `pbkdf2_sha256$<iterations>$<salt>$<digest>`
|
||||
|
||||
## Politica inicial de senha
|
||||
|
||||
Politica minima definida:
|
||||
|
||||
- tamanho minimo: `12`
|
||||
- exigir letra maiuscula
|
||||
- exigir letra minuscula
|
||||
- exigir digito
|
||||
- exigir simbolo
|
||||
|
||||
A validacao dessa politica ja esta refletida no runtime administrativo.
|
||||
|
||||
## Tokens e sessao
|
||||
|
||||
Estrategia inicial para a autenticacao web:
|
||||
|
||||
- access token curto: `30` minutos
|
||||
- refresh token: `7` dias
|
||||
- secret de assinatura dedicado: `ADMIN_AUTH_TOKEN_SECRET`
|
||||
- issuer do admin runtime: `ADMIN_AUTH_TOKEN_ISSUER`
|
||||
- tamanho configuravel do refresh token: `ADMIN_AUTH_REFRESH_TOKEN_BYTES`
|
||||
|
||||
Nesta etapa, o runtime administrativo ja faz:
|
||||
|
||||
- emissao de access token assinado por HMAC-SHA256
|
||||
- emissao de refresh token opaco
|
||||
- armazenamento hash do refresh token em `StaffSession`
|
||||
- rotacao do refresh token no endpoint de refresh
|
||||
- revogacao da sessao no logout
|
||||
|
||||
## Bootstrap do primeiro diretor
|
||||
|
||||
A primeira conta de diretor deve nascer por fluxo controlado, nunca por startup automatico.
|
||||
|
||||
Variaveis previstas:
|
||||
|
||||
- `ADMIN_BOOTSTRAP_ENABLED`
|
||||
- `ADMIN_BOOTSTRAP_EMAIL`
|
||||
- `ADMIN_BOOTSTRAP_DISPLAY_NAME`
|
||||
- `ADMIN_BOOTSTRAP_PASSWORD`
|
||||
- `ADMIN_BOOTSTRAP_ROLE`
|
||||
|
||||
Regras:
|
||||
|
||||
- o bootstrap deve ser executado por comando explicito no futuro
|
||||
- nao deve criar conta automaticamente ao subir o servico
|
||||
- o papel padrao do bootstrap e `diretor`
|
||||
|
||||
## Relacao com papeis e permissoes
|
||||
|
||||
A conta `StaffAccount` continua acoplada a hierarquia compartilhada:
|
||||
|
||||
- `colaborador`
|
||||
- `diretor`
|
||||
|
||||
A senha autentica a identidade; o `role` governa autorizacao.
|
||||
|
||||
## Configuracoes adicionadas ao admin runtime
|
||||
|
||||
As configuracoes de credenciais passam a existir em `admin_app/core/settings.py`:
|
||||
|
||||
- `admin_auth_password_hash_scheme`
|
||||
- `admin_auth_password_hash_iterations`
|
||||
- `admin_auth_password_min_length`
|
||||
- `admin_auth_password_require_uppercase`
|
||||
- `admin_auth_password_require_lowercase`
|
||||
- `admin_auth_password_require_digit`
|
||||
- `admin_auth_password_require_symbol`
|
||||
- `admin_auth_password_pepper`
|
||||
- `admin_auth_token_secret`
|
||||
- `admin_auth_token_issuer`
|
||||
- `admin_auth_access_token_ttl_minutes`
|
||||
- `admin_auth_refresh_token_ttl_days`
|
||||
- `admin_auth_refresh_token_bytes`
|
||||
- `admin_bootstrap_enabled`
|
||||
- `admin_bootstrap_email`
|
||||
- `admin_bootstrap_display_name`
|
||||
- `admin_bootstrap_password`
|
||||
- `admin_bootstrap_role`
|
||||
|
||||
## Endpoints iniciais de autenticacao
|
||||
|
||||
Nesta etapa, o `orquestrador-admin` passa a expor:
|
||||
|
||||
- `POST /auth/login`
|
||||
- `POST /auth/refresh`
|
||||
- `POST /auth/logout`
|
||||
- `GET /auth/me`
|
||||
|
||||
## Proximos passos naturais
|
||||
|
||||
- implementar autorizacao por papel nas demais rotas administrativas
|
||||
- implementar fluxo explicito de bootstrap do primeiro diretor
|
||||
- implementar gestao de sessoes revogaveis por tela administrativa
|
||||
@ -0,0 +1,197 @@
|
||||
# Escopo De Configuracao Funcional Governada No Admin
|
||||
|
||||
## Objetivo
|
||||
|
||||
Definir quais configuracoes funcionais o `orquestrador-admin` pode consultar e alterar sem transformar o painel em uma superficie de mudanca irrestrita do runtime do `orquestrador-product`.
|
||||
|
||||
Esta etapa fixa a **fronteira funcional de configuracao**.
|
||||
As telas e rotas especificas da fase 4 vao consumir esse contrato depois.
|
||||
|
||||
## Decisao
|
||||
|
||||
O `admin` pode consultar um conjunto governado de configuracoes funcionais do sistema.
|
||||
Dessas configuracoes, apenas o papel `diretor` pode alterar o estado desejado.
|
||||
O papel `colaborador` fica com leitura para acompanhamento operacional do sistema.
|
||||
|
||||
A fronteira compartilhada inicial fica em `shared/contracts/system_functional_configuration.py`.
|
||||
O detalhamento especifico do que o painel governa no bot de atendimento fica em `docs/architecture/admin-bot-governed-configuration-scope.md`.`r`nA separacao entre o runtime de atendimento e o runtime de geracao de tools fica em `docs/architecture/admin-model-runtime-separation.md`.
|
||||
|
||||
## O que entra na fronteira administrativa
|
||||
|
||||
As primeiras configuracoes funcionais aprovadas para o painel sao:
|
||||
|
||||
1. `allowed_model_catalog`
|
||||
2. `atendimento_runtime_profile`
|
||||
3. `tool_generation_runtime_profile`
|
||||
4. `bot_behavior_policy`
|
||||
5. `channel_operation_policy`
|
||||
6. `published_runtime_state`
|
||||
|
||||
### 1. `allowed_model_catalog`
|
||||
|
||||
Superficie somente leitura usada para o painel saber quais modelos estao homologados pela plataforma.
|
||||
|
||||
Serve para:
|
||||
|
||||
- montar listas de selecao na tela administrativa
|
||||
- impedir configuracao de modelo fora do catalogo permitido
|
||||
- diferenciar modelos liberados para atendimento e para geracao de tools
|
||||
|
||||
Nao serve para:
|
||||
|
||||
- cadastrar credenciais de provedor
|
||||
- alterar limites de infraestrutura
|
||||
- homologar modelo novo diretamente pela UI
|
||||
|
||||
### 2. `atendimento_runtime_profile`
|
||||
|
||||
Configuracao funcional governada do modelo do bot que atende o cliente final.
|
||||
|
||||
Inclui:
|
||||
|
||||
- provedor selecionado
|
||||
- modelo selecionado
|
||||
- temperatura
|
||||
- limite de saida
|
||||
- referencia de prompt publicada
|
||||
- referencia de politica de tools
|
||||
|
||||
Regra:
|
||||
|
||||
- `colaborador` consulta
|
||||
- `diretor` altera
|
||||
|
||||
### 3. `tool_generation_runtime_profile`
|
||||
|
||||
Configuracao funcional governada do modelo usado para gerar e validar novas tools.
|
||||
|
||||
Inclui:
|
||||
|
||||
- provedor selecionado
|
||||
- modelo selecionado
|
||||
- perfil de raciocinio
|
||||
- limite de saida
|
||||
- referencia de politica de validacao
|
||||
|
||||
Regra obrigatoria:
|
||||
|
||||
- esse perfil e separado do perfil de atendimento
|
||||
- trocar o modelo de geracao nao troca automaticamente o modelo do bot
|
||||
|
||||
### 4. `bot_behavior_policy`
|
||||
|
||||
Politicas funcionais do fluxo do bot.
|
||||
|
||||
Inclui:
|
||||
|
||||
- modo de fallback
|
||||
- handoff para humano
|
||||
- intencoes que forcam escalonamento
|
||||
- limite de chamadas de tool por turno
|
||||
- politica de confirmacao para acao critica
|
||||
|
||||
Essa configuracao existe para o painel governar o comportamento funcional do atendimento, e nao o codigo interno do orquestrador.
|
||||
|
||||
### 5. `channel_operation_policy`
|
||||
|
||||
Politicas funcionais por canal.
|
||||
|
||||
Inclui:
|
||||
|
||||
- canal habilitado ou desabilitado
|
||||
- modo de manutencao
|
||||
- rota funcional padrao
|
||||
- referencia da janela operacional
|
||||
|
||||
Essa superficie permite governar disponibilidade funcional sem dar acesso a infraestrutura bruta.
|
||||
|
||||
### 6. `published_runtime_state`
|
||||
|
||||
Superficie somente leitura do estado efetivo publicado no `product`.
|
||||
|
||||
Inclui:
|
||||
|
||||
- escopo configurado
|
||||
- versao ativa
|
||||
- quem publicou
|
||||
- quando publicou
|
||||
- quando o produto aplicou a mudanca
|
||||
|
||||
Serve para:
|
||||
|
||||
- auditoria
|
||||
- transparencia na dashboard
|
||||
- comparacao entre estado desejado no admin e estado efetivo no produto
|
||||
|
||||
## O que fica fora da fronteira administrativa
|
||||
|
||||
As seguintes superficies nao entram como configuracao funcional alteravel no painel:
|
||||
|
||||
- segredos e credenciais de provedor
|
||||
- API keys
|
||||
- strings de conexao com banco
|
||||
- variaveis de ambiente de deploy
|
||||
- configuracao de autoscaling e infraestrutura
|
||||
- schema de banco operacional
|
||||
- payloads tecnicos internos de execucao
|
||||
- alteracao direta em tabelas operacionais do `product`
|
||||
|
||||
## Regras obrigatorias
|
||||
|
||||
### 1. Leitura ampla, escrita governada
|
||||
|
||||
A leitura dessas configuracoes nasce sob `view_system`.
|
||||
|
||||
Consequencia pratica:
|
||||
|
||||
- `colaborador` pode consultar a configuracao funcional vigente
|
||||
- `diretor` tambem consulta
|
||||
- apenas `diretor` altera configuracoes governadas com `manage_settings`
|
||||
|
||||
### 2. Sem escrita direta no produto
|
||||
|
||||
O painel administrativo nao escreve diretamente no runtime do `product` durante uma request de UI.
|
||||
|
||||
A fronteira correta eh:
|
||||
|
||||
- o `admin` registra estado funcional desejado
|
||||
- o estado e versionado, auditado e aprovado
|
||||
- o `product` consome apenas configuracao publicada
|
||||
|
||||
### 3. Separacao entre atendimento e geracao de tools
|
||||
|
||||
Os dois runtimes precisam continuar independentes.
|
||||
|
||||
Portanto:
|
||||
|
||||
- o modelo do atendimento vive em `atendimento_runtime_profile`
|
||||
- o modelo de geracao vive em `tool_generation_runtime_profile`
|
||||
- cada perfil pode ter rollout, auditoria e fallback proprios
|
||||
|
||||
### 4. Estado efetivo precisa ser observavel
|
||||
|
||||
Toda configuracao governada precisa gerar uma superficie de consulta sobre o estado efetivo publicado no `product`.
|
||||
|
||||
Consequencia pratica:
|
||||
|
||||
- a dashboard administrativa consegue mostrar o que esta ativo de verdade
|
||||
- o sistema evita divergencia silenciosa entre desejo do admin e runtime do produto
|
||||
|
||||
## Consequencias positivas
|
||||
|
||||
- permite escolher modelo do bot pelo painel sem expor segredos de infraestrutura
|
||||
- prepara a tela de configuracoes do sistema para `diretor`
|
||||
- mantem `colaborador` com visibilidade do fluxo do bot e do estado vigente
|
||||
- reforca a separacao entre governanca administrativa e hot path do atendimento
|
||||
- prepara versionamento e auditoria das configuracoes antes da integracao completa entre `admin` e `product`
|
||||
|
||||
## Proximos passos naturais
|
||||
|
||||
- criar rotas administrativas para configuracao funcional do sistema
|
||||
- criar tela administrativa de configuracoes do sistema
|
||||
- criar superficie visual para estado publicado e versoes ativas
|
||||
- definir publicacao e consumo dessas configuracoes entre `admin` e `product`
|
||||
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
# Separacao Entre Modelo Do Atendimento E Modelo De Geracao De Tools
|
||||
|
||||
## Objetivo
|
||||
|
||||
Definir a fronteira entre o runtime de modelo usado no atendimento ao cliente e o runtime de modelo usado para gerar e validar novas tools.
|
||||
|
||||
Esta etapa consolida uma regra importante da arquitetura: os dois perfis de modelo nao podem compartilhar configuracao nem ciclo de publicacao.
|
||||
|
||||
## Decisao
|
||||
|
||||
O sistema passa a tratar esses runtimes como perfis independentes.
|
||||
|
||||
Perfis:
|
||||
|
||||
1. `atendimento_runtime_profile`
|
||||
2. `tool_generation_runtime_profile`
|
||||
|
||||
A separacao formal fica em `shared/contracts/model_runtime_separation.py`.
|
||||
|
||||
## Regras obrigatorias
|
||||
|
||||
### 1. Configuracoes distintas
|
||||
|
||||
Cada runtime possui sua propria `config_key`.
|
||||
|
||||
Portanto:
|
||||
|
||||
- o atendimento usa `atendimento_runtime_profile`
|
||||
- a geracao de tools usa `tool_generation_runtime_profile`
|
||||
- uma mudanca de configuracao nunca reutiliza a mesma chave para os dois contextos
|
||||
|
||||
### 2. Catalogos com alvo separado
|
||||
|
||||
Os modelos homologados precisam carregar o alvo funcional correto.
|
||||
|
||||
Portanto:
|
||||
|
||||
- modelos homologados para atendimento entram sob `runtime_target = atendimento`
|
||||
- modelos homologados para geracao entram sob `runtime_target = tool_generation`
|
||||
- um modelo pode existir nos dois catalogos, mas a selecao continua independente
|
||||
|
||||
### 3. Publicacao independente
|
||||
|
||||
Os dois runtimes possuem publicacao independente.
|
||||
|
||||
Consequencia pratica:
|
||||
|
||||
- publicar uma mudanca no atendimento nao publica a geracao de tools
|
||||
- publicar uma mudanca na geracao de tools nao muda o bot que responde ao cliente
|
||||
- cada perfil pode ter sua propria auditoria e versao ativa
|
||||
|
||||
### 4. Rollback independente
|
||||
|
||||
Cada runtime precisa poder voltar ao estado anterior sem afetar o outro.
|
||||
|
||||
Consequencia pratica:
|
||||
|
||||
- rollback do atendimento nao mexe no runtime de geracao
|
||||
- rollback da geracao nao mexe no atendimento em producao
|
||||
|
||||
### 5. Sem propagacao implicita
|
||||
|
||||
Nao e permitido que uma alteracao em um runtime seja espelhada automaticamente no outro.
|
||||
|
||||
Isso impede:
|
||||
|
||||
- trocar o modelo do bot e, por efeito colateral, trocar o modelo de geracao
|
||||
- usar defaults compartilhados para empurrar mudancas silenciosas nos dois fluxos
|
||||
- misturar SLO, custo e risco do atendimento com o pipeline de tools
|
||||
|
||||
## Responsabilidade por runtime
|
||||
|
||||
### Atendimento
|
||||
|
||||
- alvo funcional: responder ao cliente final
|
||||
- servico consumidor: `product`
|
||||
- impacto direto: experiencia do atendimento e fluxo conversacional
|
||||
|
||||
### Geracao de tools
|
||||
|
||||
- alvo funcional: gerar e validar novas tools
|
||||
- servico consumidor: `admin`
|
||||
- impacto direto: pipeline de governanca, geracao e validacao
|
||||
|
||||
## Consequencias positivas
|
||||
|
||||
- protege o atendimento de experimentos de geracao de codigo
|
||||
- permite escolher modelos diferentes para custo, latencia e qualidade em cada fluxo
|
||||
- simplifica auditoria e rollback de configuracao
|
||||
- prepara as futuras telas e rotas de configuracao do sistema sem ambiguidade
|
||||
@ -0,0 +1,299 @@
|
||||
# Escopo De Dados Operacionais Do Product Visiveis No Admin
|
||||
|
||||
## Objetivo
|
||||
|
||||
Definir, de forma explicita, quais dados operacionais do `orquestrador-product` podem ser consultados pelo `orquestrador-admin` na fase inicial de relatorios e configuracao.
|
||||
|
||||
Esta definicao cobre o **que** o admin pode ler.
|
||||
A estrategia de leitura desses dados sem acoplar o hot path do atendimento fica detalhada em `docs/architecture/admin-report-reading-strategy.md`.
|
||||
A materializacao concreta desses relatorios fica detalhada em `docs/architecture/admin-report-materialization-strategy.md`.
|
||||
|
||||
## Principios obrigatorios
|
||||
|
||||
1. O `product` continua sendo a fonte operacional primaria.
|
||||
2. O `admin` nasce com acesso de leitura orientado a relatorios, nunca como escritor direto dessas tabelas.
|
||||
3. O hot path do atendimento nao deve depender de consulta online ao `admin`.
|
||||
4. Dados de identidade do cliente final, texto livre e segredos operacionais nao entram automaticamente na fronteira administrativa.
|
||||
5. Sempre que um indicador puder ser atendido por agregado, o agregado deve ser preferido a leitura detalhada.
|
||||
|
||||
## Datasets permitidos nesta fase
|
||||
|
||||
O contrato compartilhado correspondente fica em `shared/contracts/product_operational_data.py`.
|
||||
|
||||
### 1. Estoque comercial
|
||||
|
||||
Fonte atual:
|
||||
|
||||
- `vehicles`
|
||||
|
||||
Uso administrativo esperado:
|
||||
|
||||
- disponibilidade comercial
|
||||
- distribuicao por categoria
|
||||
- faixa de preco
|
||||
- entrada de novos itens no estoque
|
||||
|
||||
Campos permitidos:
|
||||
|
||||
- `id`
|
||||
- `modelo`
|
||||
- `categoria`
|
||||
- `preco`
|
||||
- `created_at`
|
||||
|
||||
### 2. Pedidos de venda
|
||||
|
||||
Fonte atual:
|
||||
|
||||
- `orders`
|
||||
|
||||
Uso administrativo esperado:
|
||||
|
||||
- volume de pedidos
|
||||
- pedidos ativos e cancelados
|
||||
- ticket medio
|
||||
- cancelamentos por periodo
|
||||
|
||||
Campos permitidos:
|
||||
|
||||
- `numero_pedido`
|
||||
- `vehicle_id`
|
||||
- `modelo_veiculo`
|
||||
- `valor_veiculo`
|
||||
- `status`
|
||||
- `motivo_cancelamento`
|
||||
- `data_cancelamento`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
Campos bloqueados:
|
||||
|
||||
- `user_id`
|
||||
- `cpf`
|
||||
|
||||
### 3. Agenda de revisoes
|
||||
|
||||
Fonte atual:
|
||||
|
||||
- `review_schedules`
|
||||
|
||||
Uso administrativo esperado:
|
||||
|
||||
- ocupacao de slots
|
||||
- revisoes agendadas por periodo
|
||||
- taxa de cancelamento
|
||||
- fila operacional da oficina
|
||||
|
||||
Campos permitidos:
|
||||
|
||||
- `protocolo`
|
||||
- `placa`
|
||||
- `data_hora`
|
||||
- `status`
|
||||
- `created_at`
|
||||
|
||||
Campos bloqueados:
|
||||
|
||||
- `user_id`
|
||||
|
||||
### 4. Frota de locacao
|
||||
|
||||
Fonte atual:
|
||||
|
||||
- `rental_vehicles`
|
||||
|
||||
Uso administrativo esperado:
|
||||
|
||||
- disponibilidade da frota
|
||||
- status operacional por categoria
|
||||
- tarifa diaria vigente
|
||||
|
||||
Campos permitidos:
|
||||
|
||||
- `id`
|
||||
- `placa`
|
||||
- `modelo`
|
||||
- `categoria`
|
||||
- `ano`
|
||||
- `valor_diaria`
|
||||
- `status`
|
||||
- `created_at`
|
||||
|
||||
### 5. Contratos de locacao
|
||||
|
||||
Fonte atual:
|
||||
|
||||
- `rental_contracts`
|
||||
|
||||
Uso administrativo esperado:
|
||||
|
||||
- contratos ativos e encerrados
|
||||
- devolucoes em atraso
|
||||
- receita prevista versus receita final
|
||||
- ocupacao da frota no tempo
|
||||
|
||||
Campos permitidos:
|
||||
|
||||
- `contrato_numero`
|
||||
- `rental_vehicle_id`
|
||||
- `placa`
|
||||
- `modelo_veiculo`
|
||||
- `categoria`
|
||||
- `data_inicio`
|
||||
- `data_fim_prevista`
|
||||
- `data_devolucao`
|
||||
- `valor_diaria`
|
||||
- `valor_previsto`
|
||||
- `valor_final`
|
||||
- `status`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
Campos bloqueados:
|
||||
|
||||
- `user_id`
|
||||
- `cpf`
|
||||
- `observacoes`
|
||||
|
||||
### 6. Pagamentos de locacao
|
||||
|
||||
Fonte atual:
|
||||
|
||||
- `rental_payments`
|
||||
|
||||
Uso administrativo esperado:
|
||||
|
||||
- arrecadacao por periodo
|
||||
- pagamentos conciliados por contrato
|
||||
- inadimplencia operacional
|
||||
|
||||
Campos permitidos:
|
||||
|
||||
- `protocolo`
|
||||
- `contrato_numero`
|
||||
- `placa`
|
||||
- `valor`
|
||||
- `data_pagamento`
|
||||
- `created_at`
|
||||
|
||||
Campos bloqueados:
|
||||
|
||||
- `user_id`
|
||||
- `rental_contract_id`
|
||||
- `favorecido`
|
||||
- `identificador_comprovante`
|
||||
- `observacoes`
|
||||
|
||||
### 7. Telemetria conversacional
|
||||
|
||||
Fonte atual:
|
||||
|
||||
- `conversation_turns`
|
||||
|
||||
Uso administrativo esperado:
|
||||
|
||||
- volume de atendimento
|
||||
- latencia por turno
|
||||
- distribuicao por dominio
|
||||
- uso de tools
|
||||
- falhas operacionais por status
|
||||
|
||||
Campos permitidos:
|
||||
|
||||
- `request_id`
|
||||
- `conversation_id`
|
||||
- `channel`
|
||||
- `turn_status`
|
||||
- `intent`
|
||||
- `domain`
|
||||
- `action`
|
||||
- `tool_name`
|
||||
- `elapsed_ms`
|
||||
- `started_at`
|
||||
- `completed_at`
|
||||
|
||||
Campos bloqueados:
|
||||
|
||||
- `user_id`
|
||||
- `external_id`
|
||||
- `username`
|
||||
- `user_message`
|
||||
- `assistant_response`
|
||||
- `tool_arguments`
|
||||
- `error_detail`
|
||||
|
||||
### 8. Entregas de integracao
|
||||
|
||||
Fonte atual:
|
||||
|
||||
- `integration_deliveries`
|
||||
|
||||
Uso administrativo esperado:
|
||||
|
||||
- taxa de sucesso por provedor
|
||||
- volume de eventos entregues
|
||||
- entregas pendentes ou com falha
|
||||
- tentativas de reenvio
|
||||
|
||||
Campos permitidos:
|
||||
|
||||
- `route_id`
|
||||
- `event_type`
|
||||
- `provider`
|
||||
- `status`
|
||||
- `attempts`
|
||||
- `dispatched_at`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
Campos bloqueados:
|
||||
|
||||
- `payload_json`
|
||||
- `recipient_email`
|
||||
- `recipient_name`
|
||||
- `rendered_subject`
|
||||
- `rendered_body`
|
||||
- `provider_message_id`
|
||||
- `last_error`
|
||||
|
||||
## Fontes fora do escopo administrativo nesta fase
|
||||
|
||||
O admin **nao** deve consultar diretamente, nesta fase:
|
||||
|
||||
- `customers`
|
||||
- `users`
|
||||
- stores de estado conversacional de hot path
|
||||
- payloads brutos de tools e mensagens do usuario
|
||||
- comprovantes e identificadores sensiveis de pagamento
|
||||
- configuracoes internas de provedor e credenciais
|
||||
|
||||
## Regra de autorizacao
|
||||
|
||||
A leitura desses dados nasce amarrada a `view_reports`.
|
||||
|
||||
Consequencia pratica:
|
||||
|
||||
- `colaborador` pode consultar os dados operacionais liberados para relatorio
|
||||
- `diretor` herda essa leitura e acumula as etapas de aprovacao e configuracao
|
||||
- permissao adicional sera exigida apenas quando a consulta implicar governanca, aprovacao ou configuracao
|
||||
|
||||
## Decisao tomada nesta etapa
|
||||
|
||||
O `admin` pode consultar apenas datasets operacionais explicitamente declarados em contrato compartilhado e sempre em modo somente leitura.
|
||||
|
||||
A fronteira inicial favorece relatorios de:
|
||||
|
||||
- vendas
|
||||
- arrecadacao
|
||||
- operacao
|
||||
- telemetria de atendimento
|
||||
- entregas de integracao
|
||||
|
||||
## Decisao de materializacao relacionada
|
||||
|
||||
Para esses datasets, a fase inicial escolhe:
|
||||
|
||||
- `etl_incremental` como estrategia de sincronizacao
|
||||
- `snapshot_table` no lado administrativo como persistencia de leitura
|
||||
- `dedicated_view` sobre os snapshots como superficie de consulta para APIs e UI
|
||||
- nenhuma replica operacional do banco do produto no dashboard administrativo
|
||||
@ -0,0 +1,128 @@
|
||||
# Estrategia De Materializacao Dos Relatorios Administrativos
|
||||
|
||||
## Objetivo
|
||||
|
||||
Escolher como os relatorios administrativos vao materializar o read model definido para a fase 4.
|
||||
|
||||
A decisao precisava fechar quatro alternativas candidatas:
|
||||
|
||||
- replica
|
||||
- ETL
|
||||
- snapshots
|
||||
- views dedicadas
|
||||
|
||||
## Decisao
|
||||
|
||||
A fase inicial de relatorios do `orquestrador-admin` vai usar a seguinte composicao:
|
||||
|
||||
1. `etl_incremental` como mecanismo de sincronizacao
|
||||
2. `snapshot_table` no lado administrativo como persistencia de leitura
|
||||
3. `dedicated_view` sobre os snapshots como superficie de consulta para APIs e UI
|
||||
4. nenhuma replica operacional do banco do `product` para abrir dashboards administrativos
|
||||
|
||||
Em resumo:
|
||||
|
||||
- **nao** usar replica como mecanismo primario da fase inicial
|
||||
- **sim** usar ETL incremental
|
||||
- **sim** persistir snapshots sanitizados
|
||||
- **sim** expor views dedicadas sobre esses snapshots
|
||||
|
||||
## Por que nao comecar por replica
|
||||
|
||||
Replica isolaria menos do que parece.
|
||||
Ela ainda manteria o admin muito proximo do schema operacional live, incentivando query ad hoc, joins pesados e acoplamento ao desenho interno do `product`.
|
||||
|
||||
Tambem traria custo operacional cedo demais:
|
||||
|
||||
- infraestrutura adicional
|
||||
- observabilidade de replicacao
|
||||
- risco de leitura errada por atraso ou schema drift
|
||||
- falsa sensacao de que qualquer tabela do produto pode virar dashboard
|
||||
|
||||
Para a fase inicial, replica aumenta a superficie tecnica sem resolver a necessidade principal, que eh governar exatamente **o que** sai do produto e **como** isso chega ao admin.
|
||||
|
||||
## Por que ETL incremental
|
||||
|
||||
ETL incremental encaixa melhor no que ja decidimos para o sistema:
|
||||
|
||||
- preserva o hot path do atendimento
|
||||
- permite sanitizacao e minimizacao antes do dado chegar ao admin
|
||||
- suporta watermark, cursor e reprocessamento controlado
|
||||
- facilita auditoria do ciclo de consolidacao
|
||||
- prepara evolucao futura para jobs, workers ou pipelines por evento
|
||||
|
||||
O ETL aqui nao precisa nascer grande.
|
||||
Ele pode comecar como job incremental simples e evoluir sem quebrar o contrato do painel.
|
||||
|
||||
## Por que snapshots
|
||||
|
||||
Snapshots sao a melhor base inicial de persistencia para relatorios administrativos porque:
|
||||
|
||||
- congelam um recorte coerente do dataset consolidado
|
||||
- permitem metadados como `generated_at`, `source_watermark` e `dataset_version`
|
||||
- reduzem risco de consultas inconsistentes durante sincronizacao
|
||||
- simplificam retry, backfill e comparacao entre execucoes
|
||||
|
||||
Na pratica, os snapshots pertencem ao contexto administrativo, nao ao banco operacional do produto.
|
||||
|
||||
## Por que views dedicadas
|
||||
|
||||
Views dedicadas ficam por cima dos snapshots para desacoplar a UI e as APIs do formato bruto de consolidacao.
|
||||
|
||||
Elas permitem:
|
||||
|
||||
- esconder colunas tecnicas de ETL
|
||||
- estabilizar o contrato consumido pelos relatorios
|
||||
- organizar uma view por caso de uso de negocio
|
||||
- evoluir agregacoes e joins internos sem quebrar a tela
|
||||
|
||||
Regra importante:
|
||||
|
||||
- essas views sao dedicadas ao contexto administrativo
|
||||
- elas nao apontam para tabelas live do produto
|
||||
- elas leem apenas snapshots ja sanitizados
|
||||
|
||||
## Fluxo alvo
|
||||
|
||||
```text
|
||||
product operational tables
|
||||
|
|
||||
v
|
||||
etl_incremental boundary
|
||||
|
|
||||
v
|
||||
admin snapshot tables
|
||||
|
|
||||
v
|
||||
admin dedicated views
|
||||
|
|
||||
v
|
||||
admin report routes and dashboard
|
||||
```
|
||||
|
||||
## Regras obrigatorias
|
||||
|
||||
1. O painel nunca consulta replica ou tabela live do produto durante request web.
|
||||
2. O ETL incremental so exporta datasets e campos aprovados em contrato compartilhado.
|
||||
3. Cada snapshot precisa carregar watermark e timestamp de geracao.
|
||||
4. Cada view dedicada existe para um caso de uso de relatorio, nunca como espelho generico do schema operacional.
|
||||
5. Escrita administrativa em tabela operacional do produto continua proibida.
|
||||
|
||||
## Consequencias praticas para a fase 4
|
||||
|
||||
Com essa decisao, os proximos itens da fase ficam orientados assim:
|
||||
|
||||
- rotas administrativas de relatorio devem ler views dedicadas do admin
|
||||
- relatorios de vendas, arrecadacao e operacao devem nascer sobre snapshots sanitizados
|
||||
- a UI deve exibir frescor e estado da ultima consolidacao
|
||||
- qualquer refresh manual conversa com a camada de sincronizacao, nao com o banco operacional live
|
||||
|
||||
## Evolucao futura permitida
|
||||
|
||||
Se no futuro houver escala suficiente para replica, ela pode entrar como **fonte de extração** do ETL, e nao como backend direto do dashboard.
|
||||
|
||||
Ou seja:
|
||||
|
||||
- replica pode aparecer depois como detalhe de implementacao
|
||||
- o contrato do painel continua o mesmo
|
||||
- a fronteira principal segue sendo ETL -> snapshots -> views dedicadas
|
||||
@ -0,0 +1,154 @@
|
||||
# Estrategia De Leitura De Relatorios Sem Acoplar O Hot Path
|
||||
|
||||
## Objetivo
|
||||
|
||||
Definir como o `orquestrador-admin` deve ler dados operacionais para relatorios sem transformar o `orquestrador-product` em backend sincrono de dashboard.
|
||||
|
||||
Esta etapa fixa a **topologia de leitura**.
|
||||
A materializacao concreta dessa topologia foi definida em `docs/architecture/admin-report-materialization-strategy.md`.
|
||||
|
||||
## Decisao
|
||||
|
||||
Os relatorios administrativos devem ser servidos a partir de um **read model administrativo assincrono**.
|
||||
|
||||
Em outras palavras:
|
||||
|
||||
1. O `product` continua escrevendo o estado operacional primario.
|
||||
2. Uma camada de sincronizacao fora do hot path materializa dados de leitura para relatorio.
|
||||
3. O `admin` consulta apenas esse read model, nunca as tabelas operacionais live do `product` em uma request web do painel.
|
||||
|
||||
## Topologia alvo
|
||||
|
||||
```text
|
||||
product operational writes
|
||||
|
|
||||
v
|
||||
sync/export boundary outside hot path
|
||||
|
|
||||
v
|
||||
admin reporting read model
|
||||
|
|
||||
v
|
||||
admin report APIs and dashboard
|
||||
```
|
||||
|
||||
## Regras obrigatorias
|
||||
|
||||
### 1. Sem query direta do painel no banco operacional do produto
|
||||
|
||||
Nao e permitido que uma rota web do painel administrativo execute consultas pesadas ou agregacoes diretamente nas tabelas operacionais do `product`.
|
||||
|
||||
Isso inclui:
|
||||
|
||||
- scans amplos em `orders`, `rental_contracts`, `rental_payments`, `conversation_turns`
|
||||
- joins ad hoc para dashboard em tempo de request
|
||||
- leituras que disputem lock, cache ou I/O com o atendimento
|
||||
|
||||
### 2. Leitura eventual, nao transacional
|
||||
|
||||
O painel administrativo deve operar com **consistencia eventual**.
|
||||
|
||||
Consequencia pratica:
|
||||
|
||||
- relatorios mostram o dado consolidado mais recente disponivel
|
||||
- a UI deve exibir metadados de frescor, como `updated_at`, `generated_at` ou `source_watermark`
|
||||
- o sistema nao promete refletir cada evento operacional no mesmo instante em que ele acontece
|
||||
|
||||
### 3. Materializacao fora do hot path
|
||||
|
||||
Toda consolidacao, enriquecimento, agregacao ou recorte temporal deve acontecer em processo assincrono.
|
||||
|
||||
Exemplos validos de processo assincrono:
|
||||
|
||||
- job agendado
|
||||
- worker orientado a eventos
|
||||
- pipeline incremental por cursor
|
||||
- rotina batch com watermark
|
||||
|
||||
### 4. Read model proprio do admin
|
||||
|
||||
O `admin` deve ter sua propria superficie de leitura para relatorios.
|
||||
|
||||
Essa superficie pode morar:
|
||||
|
||||
- no banco administrativo
|
||||
- em um schema analitico separado
|
||||
- em tabelas materializadas especificas para relatorio
|
||||
|
||||
O importante nesta etapa nao e o lugar fisico, e sim a regra:
|
||||
|
||||
- a query do painel le um read model pronto
|
||||
- a transformacao do dado acontece antes da request do usuario interno
|
||||
|
||||
### 5. Escrita administrativa continua proibida
|
||||
|
||||
A estrategia de leitura nao muda a fronteira de escrita.
|
||||
|
||||
Portanto:
|
||||
|
||||
- o `admin` nao escreve diretamente nas tabelas operacionais do `product`
|
||||
- qualquer acao de governanca que altere operacao deve seguir fluxo proprio, versionado e auditavel
|
||||
|
||||
## Responsabilidades por servico
|
||||
|
||||
### `orquestrador-product`
|
||||
|
||||
Responsavel por:
|
||||
|
||||
- persistir o estado operacional primario
|
||||
- manter ids tecnicos, timestamps e chaves publicas necessarias para reconciliacao
|
||||
- expor uma fronteira segura para exportacao ou sincronizacao
|
||||
- continuar respondendo ao atendimento sem depender do `admin`
|
||||
|
||||
### `orquestrador-admin`
|
||||
|
||||
Responsavel por:
|
||||
|
||||
- armazenar ou consultar o read model de relatorio
|
||||
- servir rotas administrativas de relatorio
|
||||
- informar frescor e origem do dado para a UI
|
||||
- aplicar filtros e agregacoes sobre a superficie de leitura consolidada
|
||||
|
||||
## O que a UI deve assumir
|
||||
|
||||
As telas administrativas de relatorio devem nascer com a expectativa de dado consolidado e nao de espelho instantaneo do operacional.
|
||||
|
||||
Consequencia pratica:
|
||||
|
||||
- cada relatorio deve carregar carimbo de atualizacao
|
||||
- um refresh manual dispara no maximo uma rotina de sincronizacao, nunca uma query pesada live no produto
|
||||
- empty states e avisos de defasagem fazem parte do contrato visual
|
||||
|
||||
## Padrao de frescor inicial
|
||||
|
||||
Enquanto a implementacao completa nao chega, os datasets operacionais ficam classificados em metas de frescor no contrato compartilhado:
|
||||
|
||||
- `near_real_time` para vendas, revisoes e locacao
|
||||
- `intra_hour` para estoque, telemetria e entregas de integracao
|
||||
|
||||
Essas metas servem para orientar UX, monitoracao e futuras implementacoes da sincronizacao.
|
||||
Elas nao significam leitura live do banco operacional.
|
||||
|
||||
## O que fica explicitamente proibido
|
||||
|
||||
- dashboard administrativo consultando `product` por HTTP sincrono a cada abertura de pagina
|
||||
- admin executando agregacao pesada em banco primario de atendimento durante request web
|
||||
- relatorios dependendo de lock em tabela operacional para responder ao usuario interno
|
||||
- uso de payload bruto e PII fora do contrato compartilhado de dados operacionais
|
||||
|
||||
## Consequencias positivas
|
||||
|
||||
- protege latencia do atendimento
|
||||
- reduz risco de regressao operacional por carga analitica
|
||||
- permite evoluir relatorios com independencia do runtime conversacional
|
||||
- facilita observabilidade de frescor e falha da sincronizacao
|
||||
- prepara o terreno para ETL incremental, snapshots sanitizados e views dedicadas sem quebrar o painel
|
||||
|
||||
## Decisao complementar ja tomada
|
||||
|
||||
A topologia acima agora foi materializada assim:
|
||||
|
||||
- `etl_incremental` como fronteira de sincronizacao
|
||||
- `snapshot_table` no admin para persistencia de leitura
|
||||
- `dedicated_view` sobre snapshots para servir APIs e dashboard
|
||||
- sem replica operacional do banco do produto nesta fase
|
||||
@ -0,0 +1,128 @@
|
||||
# Estrategia De Deploy Independente Para Product E Admin
|
||||
|
||||
Este documento define a estrategia de deploy para manter `orquestrador-product`
|
||||
e `orquestrador-admin` como servicos distintos, porem ligados.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Permitir que:
|
||||
|
||||
- o atendimento continue estavel mesmo se o admin estiver fora do ar
|
||||
- o admin evolua com login, painel, relatorios e geracao de tools sem impactar o hot path
|
||||
- os dois servicos possam ser versionados e publicados com cadencias diferentes
|
||||
|
||||
## Servico de produto
|
||||
|
||||
Nome operacional sugerido:
|
||||
|
||||
- `orquestrador-product`
|
||||
|
||||
Responsabilidades:
|
||||
|
||||
- Telegram
|
||||
- orquestracao
|
||||
- execucao de tools publicadas
|
||||
- regras operacionais do atendimento
|
||||
|
||||
Unit `systemd` sugerida:
|
||||
|
||||
- `deploy/systemd/orquestrador-product.service.example`
|
||||
|
||||
## Servico administrativo
|
||||
|
||||
Nome operacional sugerido:
|
||||
|
||||
- `orquestrador-admin`
|
||||
|
||||
Responsabilidades:
|
||||
|
||||
- autenticacao interna
|
||||
- painel administrativo
|
||||
- relatorios
|
||||
- configuracao do sistema
|
||||
- geracao, validacao, aprovacao e publicacao de tools
|
||||
|
||||
Unit `systemd` sugerida:
|
||||
|
||||
- `deploy/systemd/orquestrador-admin.service.example`
|
||||
|
||||
## Principios
|
||||
|
||||
1. Deploy do `admin` nao deve exigir restart do `product`.
|
||||
2. Deploy do `product` nao deve depender do `admin` estar online.
|
||||
3. Mudancas em `shared/contracts` devem ser compativeis para frente e para tras durante a janela de rollout.
|
||||
4. O `product` consome somente estado publicado e aprovado.
|
||||
5. O `admin` nao entra no hot path do atendimento.
|
||||
|
||||
## Configuracao de ambiente
|
||||
|
||||
Sugestao de arquivos distintos:
|
||||
|
||||
- `.env.product`
|
||||
- `.env.admin`
|
||||
|
||||
### `product`
|
||||
|
||||
Mantem:
|
||||
|
||||
- Vertex do atendimento
|
||||
- Redis do estado conversacional
|
||||
- Telegram
|
||||
- bancos do runtime operacional
|
||||
|
||||
### `admin`
|
||||
|
||||
Mantem:
|
||||
|
||||
- credenciais do painel interno
|
||||
- banco administrativo
|
||||
- modelo de geracao de codigo
|
||||
- configuracoes de relatorios e publicacao
|
||||
|
||||
## Estrategia de rollout
|
||||
|
||||
### Mudancas so no admin
|
||||
|
||||
1. publicar codigo do `admin`
|
||||
2. atualizar dependencias do `admin`
|
||||
3. reiniciar apenas `orquestrador-admin`
|
||||
|
||||
### Mudancas so no product
|
||||
|
||||
1. publicar codigo do `product`
|
||||
2. atualizar dependencias do `product`
|
||||
3. reiniciar apenas `orquestrador-product`
|
||||
|
||||
### Mudancas em contratos compartilhados
|
||||
|
||||
1. publicar contrato novo de forma aditiva
|
||||
2. subir primeiro o servico consumidor mais tolerante
|
||||
3. subir o outro servico depois
|
||||
4. so remover campos antigos numa fase posterior
|
||||
|
||||
## Banco e publicacao de estado
|
||||
|
||||
Nesta fase, a estrategia recomendada e:
|
||||
|
||||
- `admin` grava seus proprios metadados e artefatos
|
||||
- `product` consome somente dados publicados e estaveis
|
||||
- nenhuma dependencia sincrona do `product` para consultar `admin` em tempo de atendimento
|
||||
|
||||
## Observabilidade
|
||||
|
||||
Cada servico deve ter:
|
||||
|
||||
- logs proprios
|
||||
- unit `systemd` propria
|
||||
- variaveis de ambiente proprias
|
||||
- healthcheck proprio
|
||||
|
||||
## Situacao atual
|
||||
|
||||
Hoje o runtime real em producao ainda e o de `product`.
|
||||
|
||||
Esta estrategia ja prepara o caminho para:
|
||||
|
||||
- manter o deploy atual do produto
|
||||
- introduzir o `admin` como segundo servico
|
||||
- fazer a transicao sem mover `app/` agora
|
||||
@ -0,0 +1,149 @@
|
||||
# Estrutura Alvo do Monorepo
|
||||
|
||||
Este documento define a estrutura alvo do monorepo apos a decisao de separar o runtime de produto do servico administrativo.
|
||||
|
||||
## Objetivo
|
||||
Manter dois servicos distintos, mas ligados:
|
||||
|
||||
- `orquestrador-product`: atendimento e operacao do produto
|
||||
- `orquestrador-admin`: autenticacao interna, configuracao, relatorios e governanca de tools
|
||||
|
||||
A prioridade desta fase e estrutural:
|
||||
- preservar o runtime atual do produto
|
||||
- introduzir o scaffold do servico administrativo
|
||||
- criar um lugar claro para contratos compartilhados
|
||||
|
||||
## Decisao de transicao
|
||||
Nesta etapa, o codigo atual do produto permanece em `app/`.
|
||||
Nao vamos mover o runtime de produto agora para evitar churn desnecessario, quebra de import e risco operacional.
|
||||
|
||||
Portanto, a estrutura final de curto e medio prazo fica assim:
|
||||
|
||||
```text
|
||||
app/ # runtime atual do produto
|
||||
admin_app/ # novo runtime administrativo
|
||||
shared/ # contratos e artefatos compartilhados entre servicos
|
||||
|
||||
docs/
|
||||
adr/
|
||||
architecture/
|
||||
|
||||
deploy/
|
||||
systemd/
|
||||
# evoluira para suportar servicos distintos
|
||||
```
|
||||
|
||||
## Estrutura do servico de produto
|
||||
O servico de produto continua centralizado em `app/`.
|
||||
|
||||
Responsabilidades:
|
||||
- atendimento conversacional
|
||||
- integracoes com canais externos
|
||||
- orquestracao em tempo de execucao
|
||||
- leitura de tools publicadas
|
||||
- regras operacionais de vendas, revisao e locacao
|
||||
|
||||
## Estrutura do servico administrativo
|
||||
O servico administrativo passa a nascer em `admin_app/`.
|
||||
|
||||
Estrutura alvo inicial:
|
||||
|
||||
```text
|
||||
admin_app/
|
||||
app_factory.py
|
||||
main.py
|
||||
__main__.py
|
||||
api/
|
||||
dependencies.py
|
||||
router.py
|
||||
routes/
|
||||
system.py
|
||||
# auth.py
|
||||
# staff_accounts.py
|
||||
# tool_drafts.py
|
||||
# reports.py
|
||||
core/
|
||||
settings.py
|
||||
security.py
|
||||
db/
|
||||
models/
|
||||
staff_account.py
|
||||
# tool_draft.py
|
||||
# tool_generation_job.py
|
||||
# tool_publication.py
|
||||
# audit_log.py
|
||||
repositories/
|
||||
# staff_account_repository.py
|
||||
# tool_draft_repository.py
|
||||
services/
|
||||
# auth_service.py
|
||||
# tool_draft_service.py
|
||||
# report_service.py
|
||||
```
|
||||
|
||||
## Estrutura compartilhada
|
||||
Tudo que for contrato entre servicos deve ficar em `shared/`.
|
||||
|
||||
Regras:
|
||||
- `shared/` nao deve conter regra de negocio de atendimento
|
||||
- `shared/` nao deve conter dependencias do hot path do Telegram
|
||||
- `shared/` deve armazenar apenas contratos, DTOs, enums, nomes de eventos e utilitarios realmente compartilhados
|
||||
|
||||
Estrutura inicial:
|
||||
|
||||
```text
|
||||
shared/
|
||||
contracts/
|
||||
access_control.py
|
||||
tool_publication.py
|
||||
# settings_snapshot.py
|
||||
# report_filters.py
|
||||
```
|
||||
|
||||
## Regras de organizacao do monorepo
|
||||
|
||||
1. `app/` continua sendo o produto ate eventual migracao planejada.
|
||||
2. `admin_app/` nasce isolado e nao deve importar modulos internos de atendimento por conveniencia.
|
||||
3. `shared/` e o unico lugar recomendado para contratos reutilizados por ambos os servicos.
|
||||
4. O servico administrativo nao deve depender do runtime do Telegram para inicializar.
|
||||
5. O servico de produto nao deve depender do servico administrativo no hot path.
|
||||
6. Hierarquia de acesso administrativa deve nascer em shared/contracts/access_control.py.
|
||||
|
||||
## Import boundaries
|
||||
|
||||
### Permitido
|
||||
- `admin_app` importar `shared`
|
||||
- `app` importar `shared`
|
||||
|
||||
### Nao permitido
|
||||
- `admin_app` importar `app.services.orchestration` para executar atendimento
|
||||
- `app` importar `admin_app.services` no fluxo de atendimento
|
||||
- colocar metadados administrativos dentro de `app/services/orchestration`
|
||||
|
||||
## Estrategia de evolucao
|
||||
|
||||
### Fase atual
|
||||
- criar scaffold do `admin_app`
|
||||
- criar `shared/`
|
||||
- documentar a topologia do monorepo
|
||||
|
||||
### Fase seguinte
|
||||
- implementar `StaffAccount`
|
||||
- criar auth administrativa
|
||||
- subir primeiras rotas internas no `admin_app`
|
||||
|
||||
### Fase posterior
|
||||
- publicar contratos compartilhados em `shared/`
|
||||
- plugar pipeline de drafts, validacao e publicacao de tools
|
||||
|
||||
## Impacto em deploy
|
||||
Por enquanto, o deploy atual do produto permanece como esta.
|
||||
Quando o admin ganhar runtime real, o deploy vai evoluir para dois servicos distintos:
|
||||
|
||||
- `orquestrador-product`
|
||||
- `orquestrador-admin`
|
||||
|
||||
Sem mover o runtime atual de `app/` nesta etapa.
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,242 @@
|
||||
# Contratos Compartilhados E Hierarquia De Acesso
|
||||
|
||||
Este documento define os primeiros contratos compartilhados entre `orquestrador-product`
|
||||
e `orquestrador-admin`, com foco especial na hierarquia de acesso do runtime administrativo.
|
||||
|
||||
## Objetivo
|
||||
|
||||
Criar uma base comum para:
|
||||
|
||||
- autenticacao e autorizacao administrativa
|
||||
- publicacao de tools do `admin` para o `product`
|
||||
- leitura operacional segura do `admin` sobre o `product`
|
||||
- configuracao funcional governada entre `admin` e `product`
|
||||
- evolucao independente dos dois servicos sem acoplamento indevido
|
||||
|
||||
## Hierarquia inicial de acesso
|
||||
|
||||
Os papeis administrativos ficam centralizados em `shared/contracts/access_control.py`.
|
||||
|
||||
Hierarquia:
|
||||
|
||||
1. `colaborador`
|
||||
2. `diretor`
|
||||
|
||||
### `colaborador`
|
||||
|
||||
Responsavel por operacao interna de acompanhamento e cadastro inicial de tools.
|
||||
|
||||
Permissoes iniciais:
|
||||
|
||||
- `view_system`
|
||||
- `view_reports`
|
||||
- `view_audit_logs`
|
||||
- `manage_tool_drafts`
|
||||
|
||||
### `diretor`
|
||||
|
||||
Responsavel por configuracao, aprovacao, publicacao e gestao de acesso interno.
|
||||
|
||||
Permissoes iniciais:
|
||||
|
||||
- todas as de `colaborador`
|
||||
- `review_tool_generations`
|
||||
- `publish_tools`
|
||||
- `manage_settings`
|
||||
- `manage_staff_accounts`
|
||||
|
||||
## Regras de desenho
|
||||
|
||||
1. Os papeis nascem em contrato compartilhado para que `admin` e `product` falem a mesma lingua.
|
||||
2. O `product` nao usa essa hierarquia para atendimento ao cliente final.
|
||||
3. O `admin` usa essa hierarquia para autenticacao, autorizacao e auditoria.
|
||||
4. Toda evolucao deve ser additive-first para nao bloquear deploy independente.
|
||||
|
||||
## Contrato de publicacao de tool
|
||||
|
||||
O contrato inicial fica em `shared/contracts/tool_publication.py`.
|
||||
|
||||
Ele cobre:
|
||||
|
||||
- `ServiceName`
|
||||
- `ToolLifecycleStatus`
|
||||
- `ToolParameterType`
|
||||
- `ToolParameterContract`
|
||||
- `PublishedToolContract`
|
||||
- `ToolPublicationEnvelope`
|
||||
|
||||
## Contrato de leitura operacional do produto
|
||||
|
||||
O contrato inicial fica em `shared/contracts/product_operational_data.py`.
|
||||
|
||||
Ele cobre:
|
||||
|
||||
- datasets operacionais que o `admin` pode consultar do `product`
|
||||
- dominios atuais: `inventory`, `sales`, `review`, `rental`, `conversation`, `integration`
|
||||
- granularidade inicial de leitura por registro e por agregado
|
||||
- campos liberados para relatorio e operacao
|
||||
- campos bloqueados quando carregam identidade do cliente, texto livre ou segredos operacionais
|
||||
- estrategia de leitura por `admin_read_model`, consistencia eventual e leitura sem query direta do painel no banco operacional do produto
|
||||
- estrategia de materializacao inicial por `etl_incremental`, persistida em `snapshot_table` e exposta ao painel por `dedicated_view`
|
||||
|
||||
### Regra de permissao
|
||||
|
||||
A leitura desses datasets nasce sob `view_reports`.
|
||||
|
||||
Isso significa:
|
||||
|
||||
- `colaborador` pode consultar relatorios e snapshots operacionais
|
||||
- `diretor` herda essa leitura
|
||||
- a permissao nao autoriza escrita nem governanca sobre tabelas operacionais
|
||||
|
||||
### Regra de minimizacao
|
||||
|
||||
Mesmo quando um dataset do produto entra na fronteira compartilhada, o `admin` nao recebe automaticamente todos os seus campos.
|
||||
|
||||
A fronteira correta eh:
|
||||
|
||||
- expor indicadores operacionais, ids tecnicos e chaves publicas necessarias ao relatorio
|
||||
- bloquear `cpf`, `email`, `external_id`, payloads brutos, mensagens livres e identificadores sensiveis
|
||||
- preferir agregados quando o mesmo objetivo nao exigir leitura linha a linha
|
||||
|
||||
### Regra de isolamento do hot path
|
||||
|
||||
As consultas administrativas de relatorio nao devem ser executadas diretamente sobre as tabelas operacionais do produto a partir de uma request web do painel.
|
||||
|
||||
A fronteira correta eh:
|
||||
|
||||
- o `product` escreve estado operacional
|
||||
- uma camada assincrona de `etl_incremental` materializa snapshots sanitizados no admin
|
||||
- o painel e as APIs administrativas consultam `dedicated_view` construidas sobre esses snapshots
|
||||
- nenhuma view administrativa pode apontar diretamente para tabelas live do `product`
|
||||
|
||||
## Contrato de configuracao funcional governada
|
||||
|
||||
O contrato inicial fica em `shared/contracts/system_functional_configuration.py`.
|
||||
|
||||
Ele cobre:
|
||||
|
||||
- quais configuracoes funcionais o `admin` pode consultar do sistema
|
||||
- quais configuracoes podem ser alteradas apenas por `diretor`
|
||||
- a separacao entre runtime de atendimento e runtime de geracao de tools
|
||||
- a diferenca entre estado governado no `admin` e estado efetivo publicado no `product`
|
||||
- a proibicao de alterar segredos, infra e tabelas operacionais a partir do painel
|
||||
|
||||
### Superficies iniciais declaradas
|
||||
|
||||
As primeiras configuracoes funcionais compartilhadas sao:
|
||||
|
||||
- `allowed_model_catalog`
|
||||
- `atendimento_runtime_profile`
|
||||
- `tool_generation_runtime_profile`
|
||||
- `bot_behavior_policy`
|
||||
- `channel_operation_policy`
|
||||
- `published_runtime_state`
|
||||
|
||||
### Regra de permissao
|
||||
|
||||
A leitura dessas configuracoes nasce sob `view_system`.
|
||||
|
||||
Isso significa:
|
||||
|
||||
- `colaborador` pode consultar configuracoes efetivas, catalogos homologados e estado publicado
|
||||
- `diretor` herda essa leitura
|
||||
- apenas `diretor` pode alterar configuracoes governadas, usando `manage_settings`
|
||||
|
||||
### Regra de governanca
|
||||
|
||||
A fronteira correta eh:
|
||||
|
||||
- `diretor` altera apenas configuracoes funcionais governadas
|
||||
- toda alteracao nasce como estado administrativo versionado
|
||||
- o `product` consome apenas configuracao publicada e aprovada
|
||||
- o painel nao altera segredos, variaveis de ambiente, credenciais, schema operacional ou comportamento interno sem governanca
|
||||
|
||||
### Regra de separacao entre modelos
|
||||
|
||||
A escolha de modelo do bot de atendimento e a escolha de modelo para geracao de tools nao devem compartilhar a mesma chave de configuracao.
|
||||
|
||||
A fronteira correta eh:
|
||||
|
||||
- `atendimento_runtime_profile` governa o modelo que responde ao cliente final
|
||||
- `tool_generation_runtime_profile` governa o modelo usado para gerar e validar tools
|
||||
- cada perfil pode evoluir, ser auditado e ser publicado em ritmos diferentes
|
||||
|
||||
## Contrato de governanca do bot
|
||||
|
||||
O contrato inicial fica em `shared/contracts/bot_governed_configuration.py`.
|
||||
|
||||
Ele cobre, em nivel de campo, quais configuracoes do bot de atendimento ficam sob governanca administrativa.
|
||||
|
||||
As primeiras superficies governadas sao:
|
||||
|
||||
- selecao de modelo do bot: `provider`, `model_name`
|
||||
- geracao de resposta: `temperature`, `max_output_tokens`, `prompt_profile_ref`
|
||||
- uso de tools: `tool_policy_ref`, `max_tool_calls_per_turn`, `confirmation_policy`
|
||||
- fallback e handoff: `fallback_mode`, `handoff_enabled`, `handoff_intents`
|
||||
- operacao por canal: `enabled`, `maintenance_mode`, `default_route`, `operation_window_ref`
|
||||
|
||||
### Regra de permissao
|
||||
|
||||
A leitura dessas configuracoes continua sob `view_system`.
|
||||
|
||||
A alteracao governada continua restrita a `diretor`, com `manage_settings`.
|
||||
|
||||
### Regra de fronteira
|
||||
|
||||
Esse contrato deixa explicito que:
|
||||
|
||||
- runtime de geracao de tools nao entra como configuracao do bot de atendimento
|
||||
- o painel governa referencias e politicas funcionais, nao segredos nem infraestrutura
|
||||
- nenhuma configuracao do bot e aplicada por escrita direta no banco ou runtime live do `product`
|
||||
- toda mudanca passa por publicacao versionada e auditavel
|
||||
|
||||
## Contrato de separacao entre runtimes de modelo
|
||||
|
||||
O contrato inicial fica em `shared/contracts/model_runtime_separation.py`.
|
||||
|
||||
Ele cobre:
|
||||
|
||||
- os alvos `atendimento` e `tool_generation`
|
||||
- a `config_key` de cada runtime
|
||||
- o servico consumidor de cada perfil de modelo
|
||||
- a exigencia de publicacao e rollback independentes
|
||||
- a proibicao de propagacao implicita entre os dois runtimes
|
||||
|
||||
### Regra de fronteira
|
||||
|
||||
Esse contrato deixa explicito que:
|
||||
|
||||
- o runtime de atendimento e consumido pelo `product`
|
||||
- o runtime de geracao e consumido pelo `admin`
|
||||
- trocar um perfil nao troca automaticamente o outro
|
||||
- os dois podem compartilhar um provedor homologado, mas nao compartilham estado de configuracao
|
||||
|
||||
## Como isso sera usado depois
|
||||
|
||||
### No `orquestrador-admin`
|
||||
|
||||
- criar `StaffAccount`
|
||||
- associar `StaffAccount.role`
|
||||
- controlar acesso a UI, as rotas e a aprovacao de tools
|
||||
- emitir `ToolPublicationEnvelope` quando uma tool for publicada
|
||||
- construir relatorios usando apenas datasets declarados no contrato compartilhado
|
||||
- consultar read models administrativos em vez de tabelas live do produto
|
||||
- governar configuracoes funcionais sem escrever diretamente no banco operacional do `product`
|
||||
|
||||
### No `orquestrador-product`
|
||||
|
||||
- consumir apenas tools publicadas
|
||||
- validar status e versao do contrato recebido
|
||||
- expor fronteiras seguras para sincronizacao incremental dos datasets permitidos ao `admin`
|
||||
- consumir apenas configuracoes funcionais publicadas e aprovadas
|
||||
- evitar dependencia do runtime do admin no hot path
|
||||
|
||||
## Proximos passos naturais
|
||||
|
||||
- criar rotas administrativas para relatorios
|
||||
- criar rotas administrativas para configuracao funcional do sistema
|
||||
- estruturar snapshots e views de vendas, arrecadacao e operacao
|
||||
- manter a escrita administrativa fora das tabelas operacionais do produto
|
||||
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
# Generated Tools
|
||||
|
||||
Diretorio isolado para modulos publicados pelo fluxo administrativo de tools.
|
||||
@ -0,0 +1 @@
|
||||
"""Isolated runtime package for admin-governed generated tools."""
|
||||
@ -0,0 +1,58 @@
|
||||
# Shared Contracts
|
||||
|
||||
Esta pasta existe para concentrar contratos e artefatos compartilhados entre:
|
||||
|
||||
- `app/` (produto)
|
||||
- `admin_app/` (administrativo)
|
||||
|
||||
Ela nao deve receber regra de negocio do atendimento nem codigo acoplado ao hot path do produto.
|
||||
|
||||
## Contratos iniciais
|
||||
|
||||
Nesta fase, os primeiros contratos compartilhados sao:
|
||||
|
||||
- `access_control.py`
|
||||
- define a hierarquia inicial de acesso interno
|
||||
- papeis: `colaborador`, `diretor`
|
||||
- `colaborador` consulta o fluxo operacional e cadastra novas tools em draft
|
||||
- `diretor` revisa, aprova, publica tools e cadastra novos colaboradores
|
||||
|
||||
- `tool_publication.py`
|
||||
- define o contrato minimo de publicacao de tools do `admin` para o `product`
|
||||
- inclui envelope de publicacao, status de ciclo de vida e schema de parametros
|
||||
|
||||
- `product_operational_data.py`
|
||||
- define quais datasets operacionais do `product` podem ser consultados pelo `admin`
|
||||
- explicita dominios, granularidade de leitura, campos permitidos e campos bloqueados
|
||||
- reforca que o acesso administrativo nasce como leitura orientada a relatorios
|
||||
- declara que a leitura deve acontecer por `admin_read_model`, com consistencia eventual e sem query direta do painel no banco operacional do produto
|
||||
- formaliza a materializacao inicial por `etl_incremental` em `snapshot_table`, servida por `dedicated_view`
|
||||
- deixa explicito que a fase inicial nao usa replica operacional do produto para abrir dashboards administrativos
|
||||
|
||||
- `system_functional_configuration.py`
|
||||
- define quais configuracoes funcionais o `admin` pode consultar e quais podem ser alteradas
|
||||
- separa o runtime do bot de atendimento do runtime de geracao de tools
|
||||
- estabelece catalogo homologado de modelos, politicas do bot, politicas de canal e estado efetivo publicado
|
||||
- reforca que apenas `diretor` altera configuracoes governadas com `manage_settings`
|
||||
- deixa explicito que o painel nao altera segredos, credenciais ou tabelas operacionais do produto
|
||||
|
||||
- `bot_governed_configuration.py`
|
||||
- detalha quais campos do bot ficam sob governanca administrativa
|
||||
- cobre selecao de modelo, geracao de resposta, uso de tools, fallback, handoff e operacao por canal
|
||||
- deixa explicito que a governanca do bot usa publicacao versionada e nao escrita direta no runtime do produto
|
||||
- reforca que runtime de geracao de tools nao e configuracao do bot de atendimento
|
||||
|
||||
- `model_runtime_separation.py`
|
||||
- formaliza que atendimento e geracao de tools usam perfis de modelo distintos
|
||||
- separa config key, catalogo alvo, publicacao e rollback entre os dois runtimes
|
||||
- deixa explicito que uma mudanca em um runtime nao propaga automaticamente para o outro
|
||||
|
||||
## Regras
|
||||
|
||||
- `shared/contracts` deve guardar apenas contratos estaveis entre servicos
|
||||
- nada aqui deve importar modulos internos de `app/` ou `admin_app/`
|
||||
- as mudancas devem ser additive-first para permitir deploy independente entre `product` e `admin`
|
||||
- contratos de leitura operacional nao autorizam escrita administrativa nas tabelas do produto
|
||||
- relatorios administrativos devem consumir read models assincronos, nunca scans pesados no hot path do atendimento
|
||||
- views dedicadas de relatorio so podem ser construidas sobre snapshots sanitizados do admin, nunca sobre tabelas live do produto
|
||||
- configuracoes funcionais governadas nao autorizam escrita direta no runtime do `product` durante request web do painel
|
||||
@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class StaffRole(str, Enum):
|
||||
COLABORADOR = "colaborador"
|
||||
DIRETOR = "diretor"
|
||||
|
||||
|
||||
class AdminPermission(str, Enum):
|
||||
VIEW_SYSTEM = "view_system"
|
||||
VIEW_REPORTS = "view_reports"
|
||||
VIEW_AUDIT_LOGS = "view_audit_logs"
|
||||
MANAGE_TOOL_DRAFTS = "manage_tool_drafts"
|
||||
REVIEW_TOOL_GENERATIONS = "review_tool_generations"
|
||||
PUBLISH_TOOLS = "publish_tools"
|
||||
MANAGE_SETTINGS = "manage_settings"
|
||||
MANAGE_STAFF_ACCOUNTS = "manage_staff_accounts"
|
||||
|
||||
|
||||
_LEGACY_ROLE_ALIASES = {
|
||||
"viewer": StaffRole.COLABORADOR,
|
||||
"staff": StaffRole.COLABORADOR,
|
||||
"admin": StaffRole.DIRETOR,
|
||||
}
|
||||
|
||||
_ROLE_HIERARCHY = {
|
||||
StaffRole.COLABORADOR: 10,
|
||||
StaffRole.DIRETOR: 20,
|
||||
}
|
||||
|
||||
_ROLE_PERMISSIONS = {
|
||||
StaffRole.COLABORADOR: frozenset(
|
||||
{
|
||||
AdminPermission.VIEW_SYSTEM,
|
||||
AdminPermission.VIEW_REPORTS,
|
||||
AdminPermission.VIEW_AUDIT_LOGS,
|
||||
AdminPermission.MANAGE_TOOL_DRAFTS,
|
||||
}
|
||||
),
|
||||
StaffRole.DIRETOR: frozenset(
|
||||
{
|
||||
AdminPermission.VIEW_SYSTEM,
|
||||
AdminPermission.VIEW_REPORTS,
|
||||
AdminPermission.VIEW_AUDIT_LOGS,
|
||||
AdminPermission.MANAGE_TOOL_DRAFTS,
|
||||
AdminPermission.REVIEW_TOOL_GENERATIONS,
|
||||
AdminPermission.PUBLISH_TOOLS,
|
||||
AdminPermission.MANAGE_SETTINGS,
|
||||
AdminPermission.MANAGE_STAFF_ACCOUNTS,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def normalize_staff_role(role: StaffRole | str) -> StaffRole:
|
||||
if isinstance(role, StaffRole):
|
||||
return role
|
||||
|
||||
normalized = str(role).strip().lower()
|
||||
if normalized in _LEGACY_ROLE_ALIASES:
|
||||
return _LEGACY_ROLE_ALIASES[normalized]
|
||||
return StaffRole(normalized)
|
||||
|
||||
|
||||
def normalize_admin_permission(permission: AdminPermission | str) -> AdminPermission:
|
||||
if isinstance(permission, AdminPermission):
|
||||
return permission
|
||||
return AdminPermission(str(permission).strip().lower())
|
||||
|
||||
|
||||
def permissions_for_role(role: StaffRole | str) -> frozenset[AdminPermission]:
|
||||
normalized_role = normalize_staff_role(role)
|
||||
return _ROLE_PERMISSIONS[normalized_role]
|
||||
|
||||
|
||||
def role_includes(role: StaffRole | str, minimum_role: StaffRole | str) -> bool:
|
||||
normalized_role = normalize_staff_role(role)
|
||||
normalized_minimum = normalize_staff_role(minimum_role)
|
||||
return _ROLE_HIERARCHY[normalized_role] >= _ROLE_HIERARCHY[normalized_minimum]
|
||||
|
||||
|
||||
def role_has_permission(role: StaffRole | str, permission: AdminPermission | str) -> bool:
|
||||
normalized_permission = normalize_admin_permission(permission)
|
||||
return normalized_permission in permissions_for_role(role)
|
||||
@ -0,0 +1,151 @@
|
||||
"""Define quais configuracoes do bot ficam sob governanca administrativa."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from shared.contracts.access_control import AdminPermission
|
||||
|
||||
|
||||
class BotGovernanceArea(str, Enum):
|
||||
MODEL_SELECTION = "model_selection"
|
||||
RESPONSE_GENERATION = "response_generation"
|
||||
TOOL_USAGE = "tool_usage"
|
||||
FALLBACK_AND_HANDOFF = "fallback_and_handoff"
|
||||
CHANNEL_OPERATION = "channel_operation"
|
||||
|
||||
|
||||
class BotGovernanceMutability(str, Enum):
|
||||
DIRECTOR_GOVERNED = "director_governed"
|
||||
|
||||
|
||||
class BotGovernedSettingContract(BaseModel):
|
||||
setting_key: str
|
||||
parent_config_key: str
|
||||
field_name: str
|
||||
area: BotGovernanceArea
|
||||
description: str
|
||||
read_permission: AdminPermission = AdminPermission.VIEW_SYSTEM
|
||||
write_permission: AdminPermission = AdminPermission.MANAGE_SETTINGS
|
||||
mutability: BotGovernanceMutability = BotGovernanceMutability.DIRECTOR_GOVERNED
|
||||
versioned_publication_required: bool = True
|
||||
direct_product_write_allowed: bool = False
|
||||
|
||||
|
||||
BOT_GOVERNED_SETTINGS: tuple[BotGovernedSettingContract, ...] = (
|
||||
BotGovernedSettingContract(
|
||||
setting_key="bot_model_provider",
|
||||
parent_config_key="atendimento_runtime_profile",
|
||||
field_name="provider",
|
||||
area=BotGovernanceArea.MODEL_SELECTION,
|
||||
description="Provedor do modelo usado pelo bot de atendimento.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="bot_model_name",
|
||||
parent_config_key="atendimento_runtime_profile",
|
||||
field_name="model_name",
|
||||
area=BotGovernanceArea.MODEL_SELECTION,
|
||||
description="Modelo selecionado para responder ao cliente final.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="bot_temperature",
|
||||
parent_config_key="atendimento_runtime_profile",
|
||||
field_name="temperature",
|
||||
area=BotGovernanceArea.RESPONSE_GENERATION,
|
||||
description="Temperatura aplicada nas respostas do bot.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="bot_max_output_tokens",
|
||||
parent_config_key="atendimento_runtime_profile",
|
||||
field_name="max_output_tokens",
|
||||
area=BotGovernanceArea.RESPONSE_GENERATION,
|
||||
description="Limite de saida usado no runtime de atendimento.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="bot_prompt_profile_ref",
|
||||
parent_config_key="atendimento_runtime_profile",
|
||||
field_name="prompt_profile_ref",
|
||||
area=BotGovernanceArea.RESPONSE_GENERATION,
|
||||
description="Referencia do perfil de prompt publicado para o bot.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="bot_tool_policy_ref",
|
||||
parent_config_key="atendimento_runtime_profile",
|
||||
field_name="tool_policy_ref",
|
||||
area=BotGovernanceArea.TOOL_USAGE,
|
||||
description="Referencia da politica de uso de tools pelo bot.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="bot_fallback_mode",
|
||||
parent_config_key="bot_behavior_policy",
|
||||
field_name="fallback_mode",
|
||||
area=BotGovernanceArea.FALLBACK_AND_HANDOFF,
|
||||
description="Modo funcional de fallback quando o bot nao conclui a tarefa.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="bot_handoff_enabled",
|
||||
parent_config_key="bot_behavior_policy",
|
||||
field_name="handoff_enabled",
|
||||
area=BotGovernanceArea.FALLBACK_AND_HANDOFF,
|
||||
description="Habilita o encaminhamento para atendimento humano.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="bot_handoff_intents",
|
||||
parent_config_key="bot_behavior_policy",
|
||||
field_name="handoff_intents",
|
||||
area=BotGovernanceArea.FALLBACK_AND_HANDOFF,
|
||||
description="Lista de intencoes que exigem handoff humano.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="bot_max_tool_calls_per_turn",
|
||||
parent_config_key="bot_behavior_policy",
|
||||
field_name="max_tool_calls_per_turn",
|
||||
area=BotGovernanceArea.TOOL_USAGE,
|
||||
description="Limite de chamadas de tools por turno conversacional.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="bot_confirmation_policy",
|
||||
parent_config_key="bot_behavior_policy",
|
||||
field_name="confirmation_policy",
|
||||
area=BotGovernanceArea.TOOL_USAGE,
|
||||
description="Politica de confirmacao antes de acao critica no fluxo.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="channel_enabled",
|
||||
parent_config_key="channel_operation_policy",
|
||||
field_name="enabled",
|
||||
area=BotGovernanceArea.CHANNEL_OPERATION,
|
||||
description="Habilita ou desabilita o bot em um canal homologado.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="channel_maintenance_mode",
|
||||
parent_config_key="channel_operation_policy",
|
||||
field_name="maintenance_mode",
|
||||
area=BotGovernanceArea.CHANNEL_OPERATION,
|
||||
description="Liga manutencao controlada em um canal do bot.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="channel_default_route",
|
||||
parent_config_key="channel_operation_policy",
|
||||
field_name="default_route",
|
||||
area=BotGovernanceArea.CHANNEL_OPERATION,
|
||||
description="Define a rota funcional padrao por canal.",
|
||||
),
|
||||
BotGovernedSettingContract(
|
||||
setting_key="channel_operation_window_ref",
|
||||
parent_config_key="channel_operation_policy",
|
||||
field_name="operation_window_ref",
|
||||
area=BotGovernanceArea.CHANNEL_OPERATION,
|
||||
description="Referencia a janela operacional aplicada por canal.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_bot_governed_setting(setting_key: str) -> BotGovernedSettingContract | None:
|
||||
normalized = str(setting_key or "").strip().lower()
|
||||
for setting in BOT_GOVERNED_SETTINGS:
|
||||
if setting.setting_key == normalized:
|
||||
return setting
|
||||
return None
|
||||
@ -0,0 +1,85 @@
|
||||
"""Define a separacao entre runtime de atendimento e runtime de geracao de tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from shared.contracts.access_control import AdminPermission
|
||||
from shared.contracts.tool_publication import ServiceName
|
||||
|
||||
|
||||
class ModelRuntimeTarget(str, Enum):
|
||||
ATENDIMENTO = "atendimento"
|
||||
TOOL_GENERATION = "tool_generation"
|
||||
|
||||
|
||||
class ModelRuntimePurpose(str, Enum):
|
||||
CUSTOMER_RESPONSE = "customer_response"
|
||||
TOOL_GENERATION_AND_VALIDATION = "tool_generation_and_validation"
|
||||
|
||||
|
||||
class ModelRuntimeSeparationRule(str, Enum):
|
||||
SEPARATE_CONFIG_KEYS = "separate_config_keys"
|
||||
SEPARATE_CATALOG_TARGETS = "separate_catalog_targets"
|
||||
INDEPENDENT_PUBLICATION = "independent_publication"
|
||||
INDEPENDENT_ROLLBACK = "independent_rollback"
|
||||
NO_IMPLICIT_PROPAGATION = "no_implicit_propagation"
|
||||
|
||||
|
||||
class ModelRuntimeSeparationContract(BaseModel):
|
||||
runtime_target: ModelRuntimeTarget
|
||||
config_key: str
|
||||
catalog_runtime_target: ModelRuntimeTarget
|
||||
purpose: ModelRuntimePurpose
|
||||
consumed_by_service: ServiceName
|
||||
description: str
|
||||
read_permission: AdminPermission = AdminPermission.VIEW_SYSTEM
|
||||
write_permission: AdminPermission = AdminPermission.MANAGE_SETTINGS
|
||||
published_independently: bool = True
|
||||
rollback_independently: bool = True
|
||||
cross_target_propagation_allowed: bool = False
|
||||
affects_customer_response: bool = False
|
||||
can_generate_code: bool = False
|
||||
|
||||
|
||||
MODEL_RUNTIME_PROFILES: tuple[ModelRuntimeSeparationContract, ...] = (
|
||||
ModelRuntimeSeparationContract(
|
||||
runtime_target=ModelRuntimeTarget.ATENDIMENTO,
|
||||
config_key="atendimento_runtime_profile",
|
||||
catalog_runtime_target=ModelRuntimeTarget.ATENDIMENTO,
|
||||
purpose=ModelRuntimePurpose.CUSTOMER_RESPONSE,
|
||||
consumed_by_service=ServiceName.PRODUCT,
|
||||
description="Runtime do modelo que responde ao cliente final no fluxo de atendimento.",
|
||||
affects_customer_response=True,
|
||||
can_generate_code=False,
|
||||
),
|
||||
ModelRuntimeSeparationContract(
|
||||
runtime_target=ModelRuntimeTarget.TOOL_GENERATION,
|
||||
config_key="tool_generation_runtime_profile",
|
||||
catalog_runtime_target=ModelRuntimeTarget.TOOL_GENERATION,
|
||||
purpose=ModelRuntimePurpose.TOOL_GENERATION_AND_VALIDATION,
|
||||
consumed_by_service=ServiceName.ADMIN,
|
||||
description="Runtime do modelo usado para gerar e validar novas tools no contexto administrativo.",
|
||||
affects_customer_response=False,
|
||||
can_generate_code=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
MODEL_RUNTIME_SEPARATION_RULES: tuple[ModelRuntimeSeparationRule, ...] = (
|
||||
ModelRuntimeSeparationRule.SEPARATE_CONFIG_KEYS,
|
||||
ModelRuntimeSeparationRule.SEPARATE_CATALOG_TARGETS,
|
||||
ModelRuntimeSeparationRule.INDEPENDENT_PUBLICATION,
|
||||
ModelRuntimeSeparationRule.INDEPENDENT_ROLLBACK,
|
||||
ModelRuntimeSeparationRule.NO_IMPLICIT_PROPAGATION,
|
||||
)
|
||||
|
||||
|
||||
def get_model_runtime_contract(runtime_target: ModelRuntimeTarget | str) -> ModelRuntimeSeparationContract | None:
|
||||
normalized = str(runtime_target or "").strip().lower()
|
||||
for runtime_contract in MODEL_RUNTIME_PROFILES:
|
||||
if runtime_contract.runtime_target.value == normalized:
|
||||
return runtime_contract
|
||||
return None
|
||||
@ -0,0 +1,375 @@
|
||||
"""Define o escopo de leitura operacional do admin sobre o servico de produto."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from shared.contracts.access_control import AdminPermission
|
||||
|
||||
|
||||
class OperationalDataDomain(str, Enum):
|
||||
INVENTORY = "inventory"
|
||||
SALES = "sales"
|
||||
REVIEW = "review"
|
||||
RENTAL = "rental"
|
||||
CONVERSATION = "conversation"
|
||||
INTEGRATION = "integration"
|
||||
|
||||
|
||||
class OperationalDataSensitivity(str, Enum):
|
||||
OPERATIONAL = "operational"
|
||||
INTERNAL_IDENTIFIER = "internal_identifier"
|
||||
CUSTOMER_IDENTIFIER = "customer_identifier"
|
||||
FREE_TEXT = "free_text"
|
||||
SECRET = "secret"
|
||||
|
||||
|
||||
class OperationalReadGranularity(str, Enum):
|
||||
AGGREGATE = "aggregate"
|
||||
RECORD = "record"
|
||||
|
||||
|
||||
class OperationalReadModel(str, Enum):
|
||||
ADMIN_READ_MODEL = "admin_read_model"
|
||||
|
||||
|
||||
class OperationalConsistencyModel(str, Enum):
|
||||
EVENTUAL = "eventual"
|
||||
|
||||
|
||||
class OperationalFreshnessTarget(str, Enum):
|
||||
NEAR_REAL_TIME = "near_real_time"
|
||||
INTRA_HOUR = "intra_hour"
|
||||
INTRA_DAY = "intra_day"
|
||||
|
||||
|
||||
class OperationalSyncStrategy(str, Enum):
|
||||
ETL_INCREMENTAL = "etl_incremental"
|
||||
|
||||
|
||||
class OperationalStorageShape(str, Enum):
|
||||
SNAPSHOT_TABLE = "snapshot_table"
|
||||
|
||||
|
||||
class OperationalQuerySurface(str, Enum):
|
||||
DEDICATED_VIEW = "dedicated_view"
|
||||
|
||||
|
||||
class OperationalFieldContract(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
sensitivity: OperationalDataSensitivity = OperationalDataSensitivity.OPERATIONAL
|
||||
|
||||
|
||||
class OperationalDatasetContract(BaseModel):
|
||||
dataset_key: str
|
||||
domain: OperationalDataDomain
|
||||
description: str
|
||||
source_table: str
|
||||
read_permission: AdminPermission = AdminPermission.VIEW_REPORTS
|
||||
report_read_model: OperationalReadModel = OperationalReadModel.ADMIN_READ_MODEL
|
||||
consistency_model: OperationalConsistencyModel = OperationalConsistencyModel.EVENTUAL
|
||||
sync_strategy: OperationalSyncStrategy = OperationalSyncStrategy.ETL_INCREMENTAL
|
||||
storage_shape: OperationalStorageShape = OperationalStorageShape.SNAPSHOT_TABLE
|
||||
query_surface: OperationalQuerySurface = OperationalQuerySurface.DEDICATED_VIEW
|
||||
uses_product_replica: bool = False
|
||||
direct_product_query_allowed: bool = False
|
||||
freshness_target: OperationalFreshnessTarget = OperationalFreshnessTarget.INTRA_HOUR
|
||||
allowed_granularities: tuple[OperationalReadGranularity, ...] = Field(
|
||||
default=(
|
||||
OperationalReadGranularity.AGGREGATE,
|
||||
OperationalReadGranularity.RECORD,
|
||||
)
|
||||
)
|
||||
write_allowed: bool = False
|
||||
allowed_fields: tuple[OperationalFieldContract, ...]
|
||||
blocked_fields: tuple[OperationalFieldContract, ...] = Field(default_factory=tuple)
|
||||
|
||||
|
||||
PRODUCT_OPERATIONAL_DATASETS: tuple[OperationalDatasetContract, ...] = (
|
||||
OperationalDatasetContract(
|
||||
dataset_key="vehicle_inventory",
|
||||
domain=OperationalDataDomain.INVENTORY,
|
||||
description="Estoque operacional de veiculos disponiveis para atendimento comercial.",
|
||||
source_table="vehicles",
|
||||
freshness_target=OperationalFreshnessTarget.INTRA_HOUR,
|
||||
allowed_fields=(
|
||||
OperationalFieldContract(name="id", description="Identificador tecnico do veiculo."),
|
||||
OperationalFieldContract(name="modelo", description="Modelo comercial do veiculo."),
|
||||
OperationalFieldContract(name="categoria", description="Categoria comercial do veiculo."),
|
||||
OperationalFieldContract(name="preco", description="Preco anunciado no estoque."),
|
||||
OperationalFieldContract(name="created_at", description="Data de entrada do registro no estoque."),
|
||||
),
|
||||
),
|
||||
OperationalDatasetContract(
|
||||
dataset_key="sales_orders",
|
||||
domain=OperationalDataDomain.SALES,
|
||||
description="Pedidos de venda usados para operacao, conversao e cancelamentos.",
|
||||
source_table="orders",
|
||||
freshness_target=OperationalFreshnessTarget.NEAR_REAL_TIME,
|
||||
allowed_fields=(
|
||||
OperationalFieldContract(name="numero_pedido", description="Numero publico do pedido."),
|
||||
OperationalFieldContract(name="vehicle_id", description="Veiculo associado ao pedido."),
|
||||
OperationalFieldContract(name="modelo_veiculo", description="Modelo comercial reservado no pedido."),
|
||||
OperationalFieldContract(name="valor_veiculo", description="Valor negociado do veiculo."),
|
||||
OperationalFieldContract(name="status", description="Status operacional do pedido."),
|
||||
OperationalFieldContract(name="motivo_cancelamento", description="Motivo operacional do cancelamento."),
|
||||
OperationalFieldContract(name="data_cancelamento", description="Momento em que o pedido foi cancelado."),
|
||||
OperationalFieldContract(name="created_at", description="Data de criacao do pedido."),
|
||||
OperationalFieldContract(name="updated_at", description="Data da ultima atualizacao do pedido."),
|
||||
),
|
||||
blocked_fields=(
|
||||
OperationalFieldContract(
|
||||
name="user_id",
|
||||
description="Identificador interno do usuario final no produto.",
|
||||
sensitivity=OperationalDataSensitivity.INTERNAL_IDENTIFIER,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="cpf",
|
||||
description="Identificador civil do cliente final.",
|
||||
sensitivity=OperationalDataSensitivity.CUSTOMER_IDENTIFIER,
|
||||
),
|
||||
),
|
||||
),
|
||||
OperationalDatasetContract(
|
||||
dataset_key="review_schedules",
|
||||
domain=OperationalDataDomain.REVIEW,
|
||||
description="Agenda operacional de revisoes e disponibilidade de slots.",
|
||||
source_table="review_schedules",
|
||||
freshness_target=OperationalFreshnessTarget.NEAR_REAL_TIME,
|
||||
allowed_fields=(
|
||||
OperationalFieldContract(name="protocolo", description="Protocolo publico do agendamento."),
|
||||
OperationalFieldContract(name="placa", description="Placa do veiculo agendado."),
|
||||
OperationalFieldContract(name="data_hora", description="Data e hora do slot de revisao."),
|
||||
OperationalFieldContract(name="status", description="Status operacional do agendamento."),
|
||||
OperationalFieldContract(name="created_at", description="Data de criacao do agendamento."),
|
||||
),
|
||||
blocked_fields=(
|
||||
OperationalFieldContract(
|
||||
name="user_id",
|
||||
description="Identificador interno do usuario final no produto.",
|
||||
sensitivity=OperationalDataSensitivity.INTERNAL_IDENTIFIER,
|
||||
),
|
||||
),
|
||||
),
|
||||
OperationalDatasetContract(
|
||||
dataset_key="rental_fleet",
|
||||
domain=OperationalDataDomain.RENTAL,
|
||||
description="Frota operacional de locacao disponivel para consulta administrativa.",
|
||||
source_table="rental_vehicles",
|
||||
freshness_target=OperationalFreshnessTarget.NEAR_REAL_TIME,
|
||||
allowed_fields=(
|
||||
OperationalFieldContract(name="id", description="Identificador tecnico do veiculo de locacao."),
|
||||
OperationalFieldContract(name="placa", description="Placa do veiculo de locacao."),
|
||||
OperationalFieldContract(name="modelo", description="Modelo do veiculo de locacao."),
|
||||
OperationalFieldContract(name="categoria", description="Categoria comercial da locacao."),
|
||||
OperationalFieldContract(name="ano", description="Ano de fabricacao do veiculo."),
|
||||
OperationalFieldContract(name="valor_diaria", description="Valor de diaria vigente."),
|
||||
OperationalFieldContract(name="status", description="Status operacional do veiculo na frota."),
|
||||
OperationalFieldContract(name="created_at", description="Data de cadastro do veiculo na frota."),
|
||||
),
|
||||
),
|
||||
OperationalDatasetContract(
|
||||
dataset_key="rental_contracts",
|
||||
domain=OperationalDataDomain.RENTAL,
|
||||
description="Contratos de locacao usados para operacao, retorno e inadimplencia.",
|
||||
source_table="rental_contracts",
|
||||
freshness_target=OperationalFreshnessTarget.NEAR_REAL_TIME,
|
||||
allowed_fields=(
|
||||
OperationalFieldContract(name="contrato_numero", description="Numero publico do contrato."),
|
||||
OperationalFieldContract(name="rental_vehicle_id", description="Identificador tecnico do veiculo locado."),
|
||||
OperationalFieldContract(name="placa", description="Placa do veiculo vinculado ao contrato."),
|
||||
OperationalFieldContract(name="modelo_veiculo", description="Modelo do veiculo locado."),
|
||||
OperationalFieldContract(name="categoria", description="Categoria da locacao."),
|
||||
OperationalFieldContract(name="data_inicio", description="Inicio da locacao."),
|
||||
OperationalFieldContract(name="data_fim_prevista", description="Fim previsto da locacao."),
|
||||
OperationalFieldContract(name="data_devolucao", description="Momento efetivo da devolucao."),
|
||||
OperationalFieldContract(name="valor_diaria", description="Valor unitario da diaria."),
|
||||
OperationalFieldContract(name="valor_previsto", description="Valor previsto ao abrir o contrato."),
|
||||
OperationalFieldContract(name="valor_final", description="Valor final consolidado da locacao."),
|
||||
OperationalFieldContract(name="status", description="Status operacional do contrato."),
|
||||
OperationalFieldContract(name="created_at", description="Data de criacao do contrato."),
|
||||
OperationalFieldContract(name="updated_at", description="Data da ultima atualizacao do contrato."),
|
||||
),
|
||||
blocked_fields=(
|
||||
OperationalFieldContract(
|
||||
name="user_id",
|
||||
description="Identificador interno do usuario final no produto.",
|
||||
sensitivity=OperationalDataSensitivity.INTERNAL_IDENTIFIER,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="cpf",
|
||||
description="Identificador civil do cliente final.",
|
||||
sensitivity=OperationalDataSensitivity.CUSTOMER_IDENTIFIER,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="observacoes",
|
||||
description="Campo livre informado durante a operacao de locacao.",
|
||||
sensitivity=OperationalDataSensitivity.FREE_TEXT,
|
||||
),
|
||||
),
|
||||
),
|
||||
OperationalDatasetContract(
|
||||
dataset_key="rental_payments",
|
||||
domain=OperationalDataDomain.RENTAL,
|
||||
description="Pagamentos de locacao usados para arrecadacao e conciliacao operacional.",
|
||||
source_table="rental_payments",
|
||||
freshness_target=OperationalFreshnessTarget.NEAR_REAL_TIME,
|
||||
allowed_fields=(
|
||||
OperationalFieldContract(name="protocolo", description="Protocolo publico do pagamento."),
|
||||
OperationalFieldContract(name="contrato_numero", description="Contrato associado ao pagamento."),
|
||||
OperationalFieldContract(name="placa", description="Placa vinculada ao contrato pago."),
|
||||
OperationalFieldContract(name="valor", description="Valor liquidado no pagamento."),
|
||||
OperationalFieldContract(name="data_pagamento", description="Momento do pagamento."),
|
||||
OperationalFieldContract(name="created_at", description="Data de registro do pagamento."),
|
||||
),
|
||||
blocked_fields=(
|
||||
OperationalFieldContract(
|
||||
name="user_id",
|
||||
description="Identificador interno do usuario final no produto.",
|
||||
sensitivity=OperationalDataSensitivity.INTERNAL_IDENTIFIER,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="rental_contract_id",
|
||||
description="Chave tecnica interna do contrato no banco operacional.",
|
||||
sensitivity=OperationalDataSensitivity.INTERNAL_IDENTIFIER,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="favorecido",
|
||||
description="Nome textual do favorecido no comprovante.",
|
||||
sensitivity=OperationalDataSensitivity.FREE_TEXT,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="identificador_comprovante",
|
||||
description="Identificador do comprovante de pagamento.",
|
||||
sensitivity=OperationalDataSensitivity.SECRET,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="observacoes",
|
||||
description="Campo livre informado durante o pagamento.",
|
||||
sensitivity=OperationalDataSensitivity.FREE_TEXT,
|
||||
),
|
||||
),
|
||||
),
|
||||
OperationalDatasetContract(
|
||||
dataset_key="conversation_turns",
|
||||
domain=OperationalDataDomain.CONVERSATION,
|
||||
description="Telemetria operacional das conversas para eficiencia, erro e uso de tools.",
|
||||
source_table="conversation_turns",
|
||||
freshness_target=OperationalFreshnessTarget.INTRA_HOUR,
|
||||
allowed_fields=(
|
||||
OperationalFieldContract(name="request_id", description="Identificador tecnico do turno processado."),
|
||||
OperationalFieldContract(name="conversation_id", description="Identificador tecnico da conversa."),
|
||||
OperationalFieldContract(name="channel", description="Canal do atendimento."),
|
||||
OperationalFieldContract(name="turn_status", description="Status do turno conversacional."),
|
||||
OperationalFieldContract(name="intent", description="Intencao classificada para o turno."),
|
||||
OperationalFieldContract(name="domain", description="Dominio operacional associado ao turno."),
|
||||
OperationalFieldContract(name="action", description="Acao tomada pelo orquestrador."),
|
||||
OperationalFieldContract(name="tool_name", description="Tool chamada durante o turno."),
|
||||
OperationalFieldContract(name="elapsed_ms", description="Tempo de processamento do turno em milissegundos."),
|
||||
OperationalFieldContract(name="started_at", description="Inicio do processamento do turno."),
|
||||
OperationalFieldContract(name="completed_at", description="Fim do processamento do turno."),
|
||||
),
|
||||
blocked_fields=(
|
||||
OperationalFieldContract(
|
||||
name="user_id",
|
||||
description="Identificador interno do usuario final no produto.",
|
||||
sensitivity=OperationalDataSensitivity.INTERNAL_IDENTIFIER,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="external_id",
|
||||
description="Identificador externo do usuario final no canal.",
|
||||
sensitivity=OperationalDataSensitivity.CUSTOMER_IDENTIFIER,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="username",
|
||||
description="Username do usuario final no canal.",
|
||||
sensitivity=OperationalDataSensitivity.CUSTOMER_IDENTIFIER,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="user_message",
|
||||
description="Mensagem original do usuario final.",
|
||||
sensitivity=OperationalDataSensitivity.FREE_TEXT,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="assistant_response",
|
||||
description="Resposta textual completa enviada ao usuario final.",
|
||||
sensitivity=OperationalDataSensitivity.FREE_TEXT,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="tool_arguments",
|
||||
description="Payload bruto dos argumentos enviados para tools.",
|
||||
sensitivity=OperationalDataSensitivity.FREE_TEXT,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="error_detail",
|
||||
description="Detalhe bruto de erro que pode carregar contexto sensivel.",
|
||||
sensitivity=OperationalDataSensitivity.FREE_TEXT,
|
||||
),
|
||||
),
|
||||
),
|
||||
OperationalDatasetContract(
|
||||
dataset_key="integration_deliveries",
|
||||
domain=OperationalDataDomain.INTEGRATION,
|
||||
description="Entrega operacional de eventos para provedores externos e observabilidade de falhas.",
|
||||
source_table="integration_deliveries",
|
||||
freshness_target=OperationalFreshnessTarget.INTRA_HOUR,
|
||||
allowed_fields=(
|
||||
OperationalFieldContract(name="route_id", description="Rota interna que originou a entrega."),
|
||||
OperationalFieldContract(name="event_type", description="Tipo de evento entregue."),
|
||||
OperationalFieldContract(name="provider", description="Provedor de integracao usado na entrega."),
|
||||
OperationalFieldContract(name="status", description="Status atual da entrega."),
|
||||
OperationalFieldContract(name="attempts", description="Quantidade de tentativas realizadas."),
|
||||
OperationalFieldContract(name="dispatched_at", description="Momento do disparo da entrega."),
|
||||
OperationalFieldContract(name="created_at", description="Data de criacao do registro de entrega."),
|
||||
OperationalFieldContract(name="updated_at", description="Data da ultima atualizacao da entrega."),
|
||||
),
|
||||
blocked_fields=(
|
||||
OperationalFieldContract(
|
||||
name="payload_json",
|
||||
description="Payload bruto do evento entregue.",
|
||||
sensitivity=OperationalDataSensitivity.FREE_TEXT,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="recipient_email",
|
||||
description="Email do destinatario final da integracao.",
|
||||
sensitivity=OperationalDataSensitivity.CUSTOMER_IDENTIFIER,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="recipient_name",
|
||||
description="Nome do destinatario final da integracao.",
|
||||
sensitivity=OperationalDataSensitivity.CUSTOMER_IDENTIFIER,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="rendered_subject",
|
||||
description="Assunto renderizado da mensagem enviada.",
|
||||
sensitivity=OperationalDataSensitivity.FREE_TEXT,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="rendered_body",
|
||||
description="Corpo renderizado da mensagem enviada.",
|
||||
sensitivity=OperationalDataSensitivity.FREE_TEXT,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="provider_message_id",
|
||||
description="Identificador bruto devolvido pelo provedor externo.",
|
||||
sensitivity=OperationalDataSensitivity.SECRET,
|
||||
),
|
||||
OperationalFieldContract(
|
||||
name="last_error",
|
||||
description="Detalhe textual do ultimo erro de entrega.",
|
||||
sensitivity=OperationalDataSensitivity.FREE_TEXT,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_operational_dataset(dataset_key: str) -> OperationalDatasetContract | None:
|
||||
normalized = str(dataset_key or "").strip().lower()
|
||||
for dataset in PRODUCT_OPERATIONAL_DATASETS:
|
||||
if dataset.dataset_key == normalized:
|
||||
return dataset
|
||||
return None
|
||||
@ -0,0 +1,258 @@
|
||||
"""Define o escopo de configuracao funcional governada entre admin e product."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from shared.contracts.access_control import AdminPermission
|
||||
|
||||
|
||||
class FunctionalConfigurationDomain(str, Enum):
|
||||
MODEL_CATALOG = "model_catalog"
|
||||
ATENDIMENTO_RUNTIME = "atendimento_runtime"
|
||||
TOOL_GENERATION_RUNTIME = "tool_generation_runtime"
|
||||
BOT_POLICY = "bot_policy"
|
||||
CHANNEL_OPERATION = "channel_operation"
|
||||
CONFIG_PUBLICATION = "config_publication"
|
||||
|
||||
|
||||
class FunctionalConfigurationMutability(str, Enum):
|
||||
READ_ONLY = "read_only"
|
||||
DIRECTOR_GOVERNED = "director_governed"
|
||||
|
||||
|
||||
class FunctionalConfigurationSource(str, Enum):
|
||||
PLATFORM_CATALOG = "platform_catalog"
|
||||
ADMIN_GOVERNED_STATE = "admin_governed_state"
|
||||
PRODUCT_EFFECTIVE_STATE = "product_effective_state"
|
||||
|
||||
|
||||
class FunctionalConfigurationPropagation(str, Enum):
|
||||
OBSERVATION_ONLY = "observation_only"
|
||||
VERSIONED_PUBLICATION = "versioned_publication"
|
||||
|
||||
|
||||
class FunctionalConfigurationFieldContract(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
writable: bool = True
|
||||
secret: bool = False
|
||||
|
||||
|
||||
class FunctionalConfigurationContract(BaseModel):
|
||||
config_key: str
|
||||
domain: FunctionalConfigurationDomain
|
||||
description: str
|
||||
source: FunctionalConfigurationSource
|
||||
read_permission: AdminPermission = AdminPermission.VIEW_SYSTEM
|
||||
write_permission: AdminPermission | None = AdminPermission.MANAGE_SETTINGS
|
||||
mutability: FunctionalConfigurationMutability = FunctionalConfigurationMutability.DIRECTOR_GOVERNED
|
||||
propagation: FunctionalConfigurationPropagation = (
|
||||
FunctionalConfigurationPropagation.VERSIONED_PUBLICATION
|
||||
)
|
||||
affects_product_runtime: bool = True
|
||||
direct_product_write_allowed: bool = False
|
||||
fields: tuple[FunctionalConfigurationFieldContract, ...]
|
||||
|
||||
|
||||
SYSTEM_FUNCTIONAL_CONFIGURATIONS: tuple[FunctionalConfigurationContract, ...] = (
|
||||
FunctionalConfigurationContract(
|
||||
config_key="allowed_model_catalog",
|
||||
domain=FunctionalConfigurationDomain.MODEL_CATALOG,
|
||||
description="Catalogo de modelos liberados pela plataforma para atendimento e geracao de tools.",
|
||||
source=FunctionalConfigurationSource.PLATFORM_CATALOG,
|
||||
write_permission=None,
|
||||
mutability=FunctionalConfigurationMutability.READ_ONLY,
|
||||
propagation=FunctionalConfigurationPropagation.OBSERVATION_ONLY,
|
||||
affects_product_runtime=False,
|
||||
fields=(
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="runtime_target",
|
||||
description="Destino funcional do modelo, como atendimento ou geracao de tools.",
|
||||
writable=False,
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="provider",
|
||||
description="Provedor homologado para o modelo.",
|
||||
writable=False,
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="model_name",
|
||||
description="Nome tecnico do modelo liberado.",
|
||||
writable=False,
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="capability_tags",
|
||||
description="Capacidades suportadas pelo modelo homologado.",
|
||||
writable=False,
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="status",
|
||||
description="Estado de homologacao do modelo no catalogo da plataforma.",
|
||||
writable=False,
|
||||
),
|
||||
),
|
||||
),
|
||||
FunctionalConfigurationContract(
|
||||
config_key="atendimento_runtime_profile",
|
||||
domain=FunctionalConfigurationDomain.ATENDIMENTO_RUNTIME,
|
||||
description="Perfil funcional ativo para o bot de atendimento no servico de produto.",
|
||||
source=FunctionalConfigurationSource.ADMIN_GOVERNED_STATE,
|
||||
fields=(
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="provider",
|
||||
description="Provedor selecionado para o atendimento.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="model_name",
|
||||
description="Modelo selecionado para o atendimento.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="temperature",
|
||||
description="Temperatura aplicada nas respostas do atendimento.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="max_output_tokens",
|
||||
description="Limite de saida usado pelo atendimento.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="prompt_profile_ref",
|
||||
description="Referencia da estrategia de prompt publicada para o atendimento.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="tool_policy_ref",
|
||||
description="Referencia da politica de uso de tools pelo atendimento.",
|
||||
),
|
||||
),
|
||||
),
|
||||
FunctionalConfigurationContract(
|
||||
config_key="tool_generation_runtime_profile",
|
||||
domain=FunctionalConfigurationDomain.TOOL_GENERATION_RUNTIME,
|
||||
description="Perfil funcional usado para geracao e validacao automatica de novas tools.",
|
||||
source=FunctionalConfigurationSource.ADMIN_GOVERNED_STATE,
|
||||
fields=(
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="provider",
|
||||
description="Provedor selecionado para a geracao de tools.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="model_name",
|
||||
description="Modelo selecionado para a geracao de tools.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="reasoning_profile",
|
||||
description="Perfil de raciocinio aprovado para geracao de codigo.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="max_output_tokens",
|
||||
description="Limite de saida usado na geracao de tools.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="validation_profile_ref",
|
||||
description="Referencia da politica de validacao automatica de tools.",
|
||||
),
|
||||
),
|
||||
),
|
||||
FunctionalConfigurationContract(
|
||||
config_key="bot_behavior_policy",
|
||||
domain=FunctionalConfigurationDomain.BOT_POLICY,
|
||||
description="Politicas funcionais do fluxo do bot para fallback, handoff e uso de tools.",
|
||||
source=FunctionalConfigurationSource.ADMIN_GOVERNED_STATE,
|
||||
fields=(
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="fallback_mode",
|
||||
description="Modo funcional de fallback quando o bot nao conclui a tarefa.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="handoff_enabled",
|
||||
description="Sinaliza se o fluxo pode encaminhar para atendimento humano.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="handoff_intents",
|
||||
description="Lista de intencoes que forcam handoff humano.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="max_tool_calls_per_turn",
|
||||
description="Limite de chamadas de tools por turno de atendimento.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="confirmation_policy",
|
||||
description="Politica de confirmacao antes de acao critica no fluxo.",
|
||||
),
|
||||
),
|
||||
),
|
||||
FunctionalConfigurationContract(
|
||||
config_key="channel_operation_policy",
|
||||
domain=FunctionalConfigurationDomain.CHANNEL_OPERATION,
|
||||
description="Politicas funcionais por canal, incluindo habilitacao, manutencao e janela operacional.",
|
||||
source=FunctionalConfigurationSource.ADMIN_GOVERNED_STATE,
|
||||
fields=(
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="channel",
|
||||
description="Canal operacional ao qual a politica se aplica.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="enabled",
|
||||
description="Indica se o canal esta habilitado para atendimento.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="maintenance_mode",
|
||||
description="Sinaliza se o canal esta em manutencao controlada.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="default_route",
|
||||
description="Rota funcional padrao usada pelo canal.",
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="operation_window_ref",
|
||||
description="Referencia da janela operacional aplicada ao canal.",
|
||||
),
|
||||
),
|
||||
),
|
||||
FunctionalConfigurationContract(
|
||||
config_key="published_runtime_state",
|
||||
domain=FunctionalConfigurationDomain.CONFIG_PUBLICATION,
|
||||
description="Estado efetivo publicado no produto para auditoria de versao e aplicacao runtime.",
|
||||
source=FunctionalConfigurationSource.PRODUCT_EFFECTIVE_STATE,
|
||||
write_permission=None,
|
||||
mutability=FunctionalConfigurationMutability.READ_ONLY,
|
||||
propagation=FunctionalConfigurationPropagation.OBSERVATION_ONLY,
|
||||
fields=(
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="config_scope",
|
||||
description="Escopo funcional da configuracao publicada.",
|
||||
writable=False,
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="active_version",
|
||||
description="Versao funcional atualmente ativa no produto.",
|
||||
writable=False,
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="published_by",
|
||||
description="Identificador administrativo de quem publicou a configuracao.",
|
||||
writable=False,
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="published_at",
|
||||
description="Momento da ultima publicacao governada.",
|
||||
writable=False,
|
||||
),
|
||||
FunctionalConfigurationFieldContract(
|
||||
name="applied_at",
|
||||
description="Momento em que o produto aplicou a configuracao em runtime.",
|
||||
writable=False,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_functional_configuration(config_key: str) -> FunctionalConfigurationContract | None:
|
||||
normalized = str(config_key or "").strip().lower()
|
||||
for configuration in SYSTEM_FUNCTIONAL_CONFIGURATIONS:
|
||||
if configuration.config_key == normalized:
|
||||
return configuration
|
||||
return None
|
||||
@ -0,0 +1,91 @@
|
||||
import unittest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from admin_app.api.dependencies import get_current_staff_principal
|
||||
from admin_app.app_factory import create_app
|
||||
from admin_app.core.settings import AdminSettings
|
||||
from shared.contracts import StaffRole
|
||||
|
||||
|
||||
class AdminAppBootstrapTests(unittest.TestCase):
|
||||
def test_admin_app_root_endpoint_returns_json_for_non_browser_requests(self):
|
||||
app = create_app(AdminSettings(admin_environment="staging"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"service": "orquestrador-admin",
|
||||
"status": "ok",
|
||||
"message": "Servico administrativo inicializado.",
|
||||
"environment": "staging",
|
||||
},
|
||||
)
|
||||
|
||||
def test_admin_app_root_endpoint_redirects_browser_to_login(self):
|
||||
app = create_app(AdminSettings())
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/", headers={"accept": "text/html"}, follow_redirects=False)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.headers["location"].endswith("/login"))
|
||||
|
||||
def test_admin_app_health_endpoint(self):
|
||||
app = create_app(AdminSettings(admin_version="1.2.3"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/health")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{"service": "orquestrador-admin", "status": "ok", "version": "1.2.3"},
|
||||
)
|
||||
|
||||
def test_admin_app_system_info_endpoint(self):
|
||||
settings = AdminSettings(
|
||||
admin_app_name="Admin Interno",
|
||||
admin_environment="development",
|
||||
admin_version="0.9.0",
|
||||
admin_api_prefix="/admin",
|
||||
admin_debug=True,
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.dependency_overrides[get_current_staff_principal] = lambda: type(
|
||||
"Principal",
|
||||
(),
|
||||
{
|
||||
"id": 1,
|
||||
"email": "colaborador@empresa.com",
|
||||
"display_name": "Colaborador",
|
||||
"role": StaffRole.COLABORADOR,
|
||||
"is_active": True,
|
||||
},
|
||||
)()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/admin/system/info")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"service": "orquestrador-admin",
|
||||
"app_name": "Admin Interno",
|
||||
"environment": "development",
|
||||
"version": "0.9.0",
|
||||
"api_prefix": "/admin",
|
||||
"debug": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import unittest
|
||||
|
||||
from admin_app.db.models import AuditLog
|
||||
|
||||
|
||||
class AuditLogModelTests(unittest.TestCase):
|
||||
def test_audit_log_declares_expected_table_and_columns(self):
|
||||
self.assertEqual(AuditLog.__tablename__, "admin_audit_logs")
|
||||
self.assertIn("actor_staff_account_id", AuditLog.__table__.columns)
|
||||
self.assertIn("event_type", AuditLog.__table__.columns)
|
||||
self.assertIn("resource_type", AuditLog.__table__.columns)
|
||||
self.assertIn("resource_id", AuditLog.__table__.columns)
|
||||
self.assertIn("outcome", AuditLog.__table__.columns)
|
||||
self.assertIn("message", AuditLog.__table__.columns)
|
||||
self.assertIn("payload_json", AuditLog.__table__.columns)
|
||||
self.assertIn("ip_address", AuditLog.__table__.columns)
|
||||
self.assertIn("user_agent", AuditLog.__table__.columns)
|
||||
|
||||
def test_audit_log_uses_staff_account_foreign_key(self):
|
||||
foreign_keys = list(AuditLog.__table__.columns["actor_staff_account_id"].foreign_keys)
|
||||
self.assertEqual(len(foreign_keys), 1)
|
||||
self.assertEqual(str(foreign_keys[0].target_fullname), "staff_accounts.id")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -0,0 +1,76 @@
|
||||
import unittest
|
||||
|
||||
from admin_app.db.models import AuditLog
|
||||
from admin_app.services import AuditService
|
||||
|
||||
|
||||
class _FakeAuditLogRepository:
|
||||
def __init__(self):
|
||||
self.entries: list[AuditLog] = []
|
||||
self._next_id = 1
|
||||
|
||||
def create(self, **kwargs) -> AuditLog:
|
||||
audit_log = AuditLog(id=self._next_id, **kwargs)
|
||||
self.entries.append(audit_log)
|
||||
self._next_id += 1
|
||||
return audit_log
|
||||
|
||||
def list_recent(self, limit: int = 50) -> list[AuditLog]:
|
||||
return list(reversed(self.entries))[:limit]
|
||||
|
||||
|
||||
class AdminAuditServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.repository = _FakeAuditLogRepository()
|
||||
self.service = AuditService(self.repository)
|
||||
|
||||
def test_record_tool_approval_keeps_actor_and_version_metadata(self):
|
||||
audit_entry = self.service.record_tool_approval(
|
||||
actor_staff_account_id=7,
|
||||
tool_name="consultar_clientes_vip",
|
||||
tool_version=3,
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
)
|
||||
|
||||
self.assertEqual(audit_entry.event_type, "tool.approval.recorded")
|
||||
self.assertEqual(audit_entry.resource_id, "consultar_clientes_vip")
|
||||
self.assertEqual(audit_entry.payload_json["tool_version"], 3)
|
||||
self.assertEqual(audit_entry.actor_staff_account_id, 7)
|
||||
|
||||
def test_record_tool_publication_keeps_actor_and_version_metadata(self):
|
||||
audit_entry = self.service.record_tool_publication(
|
||||
actor_staff_account_id=9,
|
||||
tool_name="emitir_relatorio_receita",
|
||||
tool_version=5,
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
)
|
||||
|
||||
self.assertEqual(audit_entry.event_type, "tool.publication.recorded")
|
||||
self.assertEqual(audit_entry.resource_id, "emitir_relatorio_receita")
|
||||
self.assertEqual(audit_entry.payload_json["tool_version"], 5)
|
||||
self.assertEqual(audit_entry.actor_staff_account_id, 9)
|
||||
|
||||
def test_list_recent_returns_newest_first(self):
|
||||
self.service.record_login_failed(
|
||||
email="viewer@empresa.com",
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
)
|
||||
self.service.record_tool_publication(
|
||||
actor_staff_account_id=1,
|
||||
tool_name="publicar_x",
|
||||
tool_version=1,
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="pytest",
|
||||
)
|
||||
|
||||
recent = self.service.list_recent(limit=2)
|
||||
|
||||
self.assertEqual(recent[0].event_type, "tool.publication.recorded")
|
||||
self.assertEqual(recent[1].event_type, "staff.login.failed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -0,0 +1,222 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from admin_app.core import AdminSecurityService, AdminSettings
|
||||
from admin_app.db.models import AuditLog, StaffAccount, StaffSession
|
||||
from admin_app.services import AuditService
|
||||
from admin_app.services.auth_service import AuthService
|
||||
from shared.contracts import StaffRole
|
||||
|
||||
|
||||
class _FakeStaffAccountRepository:
|
||||
def __init__(self, account: StaffAccount | None):
|
||||
self.account = account
|
||||
|
||||
def get_by_email(self, email: str) -> StaffAccount | None:
|
||||
if self.account and self.account.email == email:
|
||||
return self.account
|
||||
return None
|
||||
|
||||
def get_by_id(self, staff_account_id: int) -> StaffAccount | None:
|
||||
if self.account and self.account.id == staff_account_id:
|
||||
return self.account
|
||||
return None
|
||||
|
||||
def update_last_login(self, staff_account: StaffAccount) -> StaffAccount:
|
||||
self.account = staff_account
|
||||
return staff_account
|
||||
|
||||
|
||||
class _FakeStaffSessionRepository:
|
||||
def __init__(self):
|
||||
self.sessions: dict[int, StaffSession] = {}
|
||||
self._next_id = 1
|
||||
|
||||
def create(self, *, staff_account_id: int, refresh_token_hash: str, expires_at: datetime, ip_address: str | None, user_agent: str | None) -> StaffSession:
|
||||
session = StaffSession(
|
||||
id=self._next_id,
|
||||
staff_account_id=staff_account_id,
|
||||
refresh_token_hash=refresh_token_hash,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
self.sessions[session.id] = session
|
||||
self._next_id += 1
|
||||
return session
|
||||
|
||||
def get_by_id(self, session_id: int) -> StaffSession | None:
|
||||
return self.sessions.get(session_id)
|
||||
|
||||
def get_by_refresh_token_hash(self, refresh_token_hash: str) -> StaffSession | None:
|
||||
for session in self.sessions.values():
|
||||
if session.refresh_token_hash == refresh_token_hash:
|
||||
return session
|
||||
return None
|
||||
|
||||
def save(self, staff_session: StaffSession) -> StaffSession:
|
||||
self.sessions[staff_session.id] = staff_session
|
||||
return staff_session
|
||||
|
||||
|
||||
class _FakeAuditLogRepository:
|
||||
def __init__(self):
|
||||
self.entries: list[AuditLog] = []
|
||||
self._next_id = 1
|
||||
|
||||
def create(self, **kwargs) -> AuditLog:
|
||||
audit_log = AuditLog(id=self._next_id, **kwargs)
|
||||
self.entries.append(audit_log)
|
||||
self._next_id += 1
|
||||
return audit_log
|
||||
|
||||
def list_recent(self, limit: int = 50) -> list[AuditLog]:
|
||||
return list(reversed(self.entries))[:limit]
|
||||
|
||||
|
||||
class AdminAuthServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.security_service = AdminSecurityService(
|
||||
AdminSettings(
|
||||
admin_auth_token_secret="test-secret",
|
||||
admin_auth_password_pepper="pepper",
|
||||
)
|
||||
)
|
||||
self.account = StaffAccount(
|
||||
id=1,
|
||||
email="admin@empresa.com",
|
||||
display_name="Administrador",
|
||||
password_hash=self.security_service.hash_password("SenhaMuitoSegura!123"),
|
||||
role=StaffRole.DIRETOR,
|
||||
is_active=True,
|
||||
)
|
||||
self.account_repository = _FakeStaffAccountRepository(self.account)
|
||||
self.session_repository = _FakeStaffSessionRepository()
|
||||
self.audit_repository = _FakeAuditLogRepository()
|
||||
self.audit_service = AuditService(self.audit_repository)
|
||||
self.auth_service = AuthService(
|
||||
account_repository=self.account_repository,
|
||||
session_repository=self.session_repository,
|
||||
security_service=self.security_service,
|
||||
audit_service=self.audit_service,
|
||||
)
|
||||
|
||||
def test_login_creates_authenticated_session_with_refresh_token(self):
|
||||
session = self.auth_service.login(
|
||||
email="admin@empresa.com",
|
||||
password="SenhaMuitoSegura!123",
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="unittest",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(session)
|
||||
self.assertEqual(session.session_id, 1)
|
||||
self.assertTrue(session.access_token)
|
||||
self.assertTrue(session.refresh_token)
|
||||
self.assertEqual(session.principal.role, StaffRole.DIRETOR)
|
||||
self.assertEqual(self.audit_repository.entries[-1].event_type, "staff.login.succeeded")
|
||||
|
||||
def test_login_failure_creates_audit_entry(self):
|
||||
session = self.auth_service.login(
|
||||
email="admin@empresa.com",
|
||||
password="senha-incorreta",
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="unittest",
|
||||
)
|
||||
|
||||
self.assertIsNone(session)
|
||||
self.assertEqual(self.audit_repository.entries[-1].event_type, "staff.login.failed")
|
||||
self.assertEqual(self.audit_repository.entries[-1].outcome, "failed")
|
||||
|
||||
def test_refresh_session_rotates_refresh_token(self):
|
||||
session = self.auth_service.login(
|
||||
email="admin@empresa.com",
|
||||
password="SenhaMuitoSegura!123",
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="unittest",
|
||||
)
|
||||
|
||||
refreshed = self.auth_service.refresh_session(
|
||||
refresh_token=session.refresh_token,
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="unittest-refresh",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(refreshed)
|
||||
self.assertEqual(refreshed.session_id, session.session_id)
|
||||
self.assertNotEqual(refreshed.refresh_token, session.refresh_token)
|
||||
|
||||
def test_logout_revokes_session_and_creates_audit_entry(self):
|
||||
session = self.auth_service.login(
|
||||
email="admin@empresa.com",
|
||||
password="SenhaMuitoSegura!123",
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="unittest",
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
self.auth_service.logout(
|
||||
session.session_id,
|
||||
actor_staff_account_id=self.account.id,
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="unittest",
|
||||
)
|
||||
)
|
||||
self.assertIsNotNone(self.session_repository.get_by_id(session.session_id).revoked_at)
|
||||
self.assertEqual(self.audit_repository.entries[-1].event_type, "staff.logout.succeeded")
|
||||
|
||||
def test_get_authenticated_context_rejects_revoked_session(self):
|
||||
session = self.auth_service.login(
|
||||
email="admin@empresa.com",
|
||||
password="SenhaMuitoSegura!123",
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="unittest",
|
||||
)
|
||||
self.auth_service.logout(
|
||||
session.session_id,
|
||||
actor_staff_account_id=self.account.id,
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="unittest",
|
||||
)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
self.auth_service.get_authenticated_context(session.access_token)
|
||||
|
||||
def test_get_authenticated_context_accepts_naive_session_expiry_from_database(self):
|
||||
session = self.auth_service.login(
|
||||
email="admin@empresa.com",
|
||||
password="SenhaMuitoSegura!123",
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="unittest",
|
||||
)
|
||||
stored_session = self.session_repository.get_by_id(session.session_id)
|
||||
stored_session.expires_at = stored_session.expires_at.replace(tzinfo=None)
|
||||
self.session_repository.save(stored_session)
|
||||
|
||||
context = self.auth_service.get_authenticated_context(session.access_token)
|
||||
|
||||
self.assertEqual(context.session_id, session.session_id)
|
||||
self.assertEqual(context.principal.email, "admin@empresa.com")
|
||||
def test_refresh_session_rejects_expired_session(self):
|
||||
session = self.auth_service.login(
|
||||
email="admin@empresa.com",
|
||||
password="SenhaMuitoSegura!123",
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="unittest",
|
||||
)
|
||||
stored_session = self.session_repository.get_by_id(session.session_id)
|
||||
stored_session.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
self.session_repository.save(stored_session)
|
||||
|
||||
refreshed = self.auth_service.refresh_session(
|
||||
refresh_token=session.refresh_token,
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="unittest-refresh",
|
||||
)
|
||||
|
||||
self.assertIsNone(refreshed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@ -0,0 +1,151 @@
|
||||
import unittest
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from admin_app.api.dependencies import get_auth_service, get_current_staff_context, get_current_staff_principal
|
||||
from admin_app.app_factory import create_app
|
||||
from admin_app.core import (
|
||||
AdminAuthenticatedSession,
|
||||
AdminSettings,
|
||||
AuthenticatedStaffContext,
|
||||
AuthenticatedStaffPrincipal,
|
||||
)
|
||||
from shared.contracts import StaffRole
|
||||
|
||||
|
||||
class _FakeAuthService:
|
||||
def login(self, email: str, password: str, *, ip_address: str | None, user_agent: str | None):
|
||||
if email == "admin@empresa.com" and password == "SenhaMuitoSegura!123":
|
||||
principal = AuthenticatedStaffPrincipal(
|
||||
id=1,
|
||||
email="admin@empresa.com",
|
||||
display_name="Administrador",
|
||||
role=StaffRole.DIRETOR,
|
||||
is_active=True,
|
||||
)
|
||||
return AdminAuthenticatedSession(
|
||||
session_id=77,
|
||||
access_token="token-abc",
|
||||
refresh_token="refresh-abc",
|
||||
token_type="bearer",
|
||||
expires_in_seconds=1800,
|
||||
principal=principal,
|
||||
)
|
||||
return None
|
||||
|
||||
def refresh_session(self, refresh_token: str, *, ip_address: str | None, user_agent: str | None):
|
||||
if refresh_token == "refresh-abc":
|
||||
principal = AuthenticatedStaffPrincipal(
|
||||
id=1,
|
||||
email="admin@empresa.com",
|
||||
display_name="Administrador",
|
||||
role=StaffRole.DIRETOR,
|
||||
is_active=True,
|
||||
)
|
||||
return AdminAuthenticatedSession(
|
||||
session_id=77,
|
||||
access_token="token-new",
|
||||
refresh_token="refresh-new",
|
||||
token_type="bearer",
|
||||
expires_in_seconds=1800,
|
||||
principal=principal,
|
||||
)
|
||||
return None
|
||||
|
||||
def logout(
|
||||
self,
|
||||
session_id: int,
|
||||
*,
|
||||
actor_staff_account_id: int | None,
|
||||
ip_address: str | None,
|
||||
user_agent: str | None,
|
||||
) -> bool:
|
||||
return session_id == 77 and actor_staff_account_id == 1
|
||||
|
||||
|
||||
class AdminAuthWebTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
app = create_app(AdminSettings(admin_auth_token_secret="test-secret"))
|
||||
app.dependency_overrides[get_auth_service] = lambda: _FakeAuthService()
|
||||
app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal(
|
||||
id=1,
|
||||
email="admin@empresa.com",
|
||||
display_name="Administrador",
|
||||
role=StaffRole.DIRETOR,
|
||||
is_active=True,
|
||||
)
|
||||
app.dependency_overrides[get_current_staff_context] = lambda: AuthenticatedStaffContext(
|
||||
principal=AuthenticatedStaffPrincipal(
|
||||
id=1,
|
||||
email="admin@empresa.com",
|
||||
display_name="Administrador",
|
||||
role=StaffRole.DIRETOR,
|
||||
is_active=True,
|
||||
),
|
||||
session_id=77,
|
||||
)
|
||||
self.client = TestClient(app)
|
||||
self.app = app
|
||||
|
||||
def tearDown(self):
|
||||
self.app.dependency_overrides.clear()
|
||||
|
||||
def test_login_returns_tokens_and_staff_account(self):
|
||||
response = self.client.post(
|
||||
"/auth/login",
|
||||
json={"email": "admin@empresa.com", "password": "SenhaMuitoSegura!123"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["session_id"], 77)
|
||||
self.assertEqual(response.json()["token_type"], "bearer")
|
||||
self.assertEqual(response.json()["refresh_token"], "refresh-abc")
|
||||
self.assertEqual(response.json()["staff_account"]["role"], "diretor")
|
||||
|
||||
def test_refresh_returns_rotated_tokens(self):
|
||||
response = self.client.post(
|
||||
"/auth/refresh",
|
||||
json={"refresh_token": "refresh-abc"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["access_token"], "token-new")
|
||||
self.assertEqual(response.json()["refresh_token"], "refresh-new")
|
||||
|
||||
def test_refresh_rejects_invalid_token(self):
|
||||
response = self.client.post(
|
||||
"/auth/refresh",
|
||||
json={"refresh_token": "refresh-invalido"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.json()["detail"], "Refresh token administrativo invalido.")
|
||||
|
||||
def test_logout_revokes_current_session(self):
|
||||
response = self.client.post(
|
||||
"/auth/logout",
|
||||
headers={"Authorization": "Bearer token-abc", "User-Agent": "pytest"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["session_id"], 77)
|
||||
self.assertEqual(response.json()["status"], "ok")
|
||||
|
||||
def test_me_returns_authenticated_staff_account(self):
|
||||
response = self.client.get("/auth/me", headers={"Authorization": "Bearer token-abc"})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["email"], "admin@empresa.com")
|
||||
self.assertEqual(response.json()["role"], "diretor")
|
||||
|
||||
def test_system_access_returns_permissions_for_authenticated_staff(self):
|
||||
response = self.client.get("/system/access", headers={"Authorization": "Bearer token-abc"})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("manage_settings", response.json()["permissions"])
|
||||
self.assertEqual(response.json()["staff_account"]["role"], "diretor")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue