Compare commits

...

17 Commits

Author SHA1 Message Date
Vitor Hugo Belorio Simão a40e3df6ff 📝 docs(admin): documentar responsabilidades da camada api interna
Adiciona comentarios curtos em arquivos da camada administrativa para deixar mais explicitas as responsabilidades de sessoes, roteamento, autenticacao, auditoria, configuracao e gestao de colaboradores.

O objetivo deste commit e melhorar a leitura do codigo e o onboarding sem alterar comportamento funcional do sistema.
6 days ago
Vitor Hugo Belorio Simão 3a7bfcf59b feat(admin): alinhar esteira governada de propostas e iteracoes de tools
Implementa a nova semantica da esteira administrativa para propostas de tools, separando triagem humana, execucao da pipeline, revisao de codigo e ativacao governada.

Tambem consolida iteracoes de geracao na mesma versao funcional, conecta refatoracao guiada por feedback da diretoria e adiciona compatibilidade com artefatos legados de revisao, aprovacao e source publicado no runtime.
6 days ago
Vitor Hugo Belorio Simão 7e380a9c65 feat(runtime): concluir execucao isolada de tools na fase 7 1 week ago
Vitor Hugo Belorio Simão de455b8566 feat(admin): concluir pipeline governada de tools na fase 6 1 week ago
Vitor Hugo Belorio Simão 640e422498 🚧 construct(admin): automatizar pipeline governada de tools na fase 6 2 weeks ago
Vitor Hugo Belorio Simão 2e3a695878 feat(admin): concluir fluxo governado de tools na fase 5 2 weeks ago
Vitor Hugo Belorio Simão 3dcf80eaaa feat(admin): consolidar governanca segura de tools na fase 5 2 weeks ago
Vitor Hugo Belorio Simão b3662906bc feat(admin): iniciar governanca versionada de tools na fase 5 2 weeks ago
Vitor Hugo Belorio Simão d6e765ce3c feat(admin): concluir telas da fase 4 no painel interno
Entrega as telas de configuracoes do sistema, relatorios comerciais, locacao e monitoramento operacional do bot dentro da sessao web do admin, com navegacao integrada ao dashboard e carregamento real pela sessao do painel.

Tambem simplifica a linguagem das superficies, remove detalhes tecnicos desnecessarios para o usuario, corrige o ponto quebrado que abria contrato em JSON bruto e ajusta grids, cards e quebra de conteudo para melhorar a leitura nas telas da fase 4.
2 weeks ago
Vitor Hugo Belorio Simão 9a31b0c5ae feat(admin): estruturar configuracao e relatorios da fase 4
Entrega a camada de backend da fase 4 com rotas administrativas para configuracao funcional do sistema, separacao explicita dos runtimes do atendimento e da geracao de tools, e as estruturas iniciais de relatorios de vendas, arrecadacao, locacao, fluxo do bot e telemetria conversacional.

Tambem adiciona a protecao de escrita no runtime administrativo para bloquear writes diretos nas tabelas operacionais do product, expoe esse snapshot no sistema e amplia a cobertura com testes web para configuracao, relatorios e governanca de escrita.
2 weeks ago
Vitor Hugo Belorio Simão 5ca21b598f 🧩 feat(shared): definir fronteiras de dados e configuracao da fase 4
Formaliza os contratos compartilhados para leitura operacional, estrategia de relatorios e configuracao funcional governada entre admin e product.

Tambem separa explicitamente o runtime do bot de atendimento do runtime de geracao de tools, detalha quais configuracoes do bot entram sob governanca administrativa e documenta as regras de publicacao, rollback e leitura sem acoplar o hot path do atendimento.
2 weeks ago
Vitor Hugo Belorio Simão bd662f35fa feat(admin): concluir fluxo interno do painel administrativo
Finaliza a camada operacional do painel com login protegido, dashboard interna separada, tela real de cadastro de novas tools e superficie completa de revisao para o fluxo administrativo.

Tambem adota os papeis em portugues entre colaborador e diretor, adiciona gestao de colaboradores com auditoria e reforca a navegacao para que o usuario so acesse modulos internos depois da autenticacao.
2 weeks ago
Vitor Hugo Belorio Simão e210b56b37 ♻️ refactor(admin): simplificar contrato das views
Remove campos que ficaram obsoletos depois da protecao do fluxo web e da simplificacao da tela publica de login.

Com isso, os modelos do painel refletem melhor o comportamento atual da interface administrativa e reduzem ambiguidade para as proximas iteracoes da fase 3.
2 weeks ago
Vitor Hugo Belorio Simão ed1a36ceb6 feat(admin): implementar painel administrativo base
Estrutura a fase 3 do orquestrador-admin com uma camada web propria para o painel interno, assets dedicados e uma dashboard administrativa em Bootstrap para o fluxo do staff.

Integra autenticacao web com cookies httpOnly, protege a navegacao do painel, adiciona snapshots de configuracao do sistema e entrega as primeiras superficies de gestao de tools para revisao, aprovacao e ativacao.

Tambem amplia a cobertura com testes para bootstrap do app, autenticacao web do painel, configuracao administrativa, governanca de tools e validacao da sessao administrativa no navegador.
2 weeks ago
Vitor Hugo Belorio Simão 82a12ff464 🔐 feat(admin): implementar identidade e seguranca administrativa 2 weeks ago
Vitor Hugo Belorio Simão 1541948e76 🧩 feat(shared): definir contratos e deploy entre product e admin 2 weeks ago
Vitor Hugo Belorio Simão 17583236a6 🏗️ feat(admin): criar scaffold inicial do orquestrador-admin 2 weeks ago

@ -37,7 +37,6 @@ Capacidades de negocio ja implementadas:
- consultar frota de aluguel
- abrir locacao
- registrar pagamento de aluguel
- registrar multa de aluguel
- registrar devolucao de aluguel
- responder consultas informativas sobre o aluguel atual, como contrato, placa, diaria, pagamento e data de devolucao
@ -87,7 +86,7 @@ Importante:
## Estrutura do Projeto
```text
app/
app/ # runtime atual do produto
main.py
api/
schemas.py
@ -151,16 +150,57 @@ app/
user/
mock_customer_service.py
user_service.py
admin_app/ # novo runtime administrativo
app_factory.py
main.py
__main__.py
api/
dependencies.py
router.py
routes/
system.py
core/
settings.py
db/
models/
repositories/
services/
shared/ # contratos compartilhados entre servicos
contracts/
access_control.py
tool_publication.py
scripts/
list_integration_deliveries.py
list_integration_routes.py
process_integration_deliveries.py
upsert_integration_route.py
stress_smoke.py
docs/
adr/
architecture/
tests/
...
```
## Monorepo Em Evolucao
A partir da frente de auto-incremento, o repositorio passa a evoluir como monorepo com dois servicos:
- `app/`: runtime de produto e atendimento
- `admin_app/`: runtime administrativo para auth interna, configuracao, relatorios e governanca de tools
- `shared/`: contratos compartilhados entre os dois servicos
Nesta fase, o produto continua rodando a partir de `app/` sem migracao de import path.
A estrutura alvo detalhada esta em [docs/architecture/monorepo-target-structure.md](docs/architecture/monorepo-target-structure.md).
A hierarquia inicial de acesso e os primeiros contratos entre servicos estao em [docs/architecture/shared-contracts-and-access-hierarchy.md](docs/architecture/shared-contracts-and-access-hierarchy.md).
A estrategia de deploy independente entre os dois runtimes esta em [docs/architecture/independent-deploy-strategy.md](docs/architecture/independent-deploy-strategy.md).
A estrategia de credenciais das contas administrativas esta em [docs/architecture/admin-credential-strategy.md](docs/architecture/admin-credential-strategy.md).
## Tools Disponiveis
As definicoes padrao ficam em [app/db/tool_seed.py](app/db/tool_seed.py):
@ -180,7 +220,6 @@ As definicoes padrao ficam em [app/db/tool_seed.py](app/db/tool_seed.py):
| `consultar_frota_aluguel` | lista frota disponivel para locacao |
| `abrir_locacao_aluguel` | abre contrato de locacao |
| `registrar_pagamento_aluguel` | registra pagamento vinculado ao contrato |
| `registrar_multa_aluguel` | registra multa vinculada ao contrato |
| `registrar_devolucao_aluguel` | encerra locacao e calcula valor final |
| `limpar_contexto_conversa` | zera contexto e fila |
| `continuar_proximo_pedido` | retoma o proximo item da fila |
@ -398,6 +437,11 @@ No modelo atual:
- o bootstrap pode ser rodado manualmente ou por uma unit separada;
- em producao, Redis deve estar disponivel para o estado conversacional.
Para a topologia alvo com dois servicos, veja tambem:
- [docs/architecture/independent-deploy-strategy.md](docs/architecture/independent-deploy-strategy.md)
- [deploy/systemd/orquestrador-product.service.example](deploy/systemd/orquestrador-product.service.example)
- [deploy/systemd/orquestrador-admin.service.example](deploy/systemd/orquestrador-admin.service.example)
## Arquivos Uteis
- [TEST_CASES.md](TEST_CASES.md)
@ -413,3 +457,7 @@ Os proximos ganhos mais valiosos para o projeto sao:
- evoluir a trilha de auditoria e consultas operacionais
- criar uma camada de avaliacao semantica e replay de conversas
- integrar o orquestrador com sistemas reais de operacao

@ -0,0 +1 @@
"""Runtime administrativo do orquestrador."""

@ -0,0 +1,3 @@
from admin_app.main import app
__all__ = ["app"]

@ -0,0 +1 @@
"""Camada HTTP do servico administrativo."""

@ -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,70 @@
from fastapi import Request, Response
from admin_app.core import AdminAuthenticatedSession, AdminSettings
PANEL_ACCESS_COOKIE_NAME = "orquestrador_admin_panel_access"
PANEL_REFRESH_COOKIE_NAME = "orquestrador_admin_panel_refresh"
PANEL_COOKIE_SAMESITE = "lax"
# Sessão web do painel. Realiza a ponte entre o AuthService (que realiza a autenticação e geração do token) e o navegador usando cookies HTTP.
# É o adaptador entre a autenticação administrativa orientada a tokens e o modo como o painel web mantém sessão no navegador.
def get_panel_access_cookie(request: Request) -> str | None:
return request.cookies.get(PANEL_ACCESS_COOKIE_NAME)
def get_panel_refresh_cookie(request: Request) -> str | None:
return request.cookies.get(PANEL_REFRESH_COOKIE_NAME)
def set_panel_auth_cookies(
response: Response,
session: AdminAuthenticatedSession,
settings: AdminSettings,
) -> None:
cookie_path = build_panel_cookie_path(settings)
use_secure = should_use_secure_cookies(settings)
response.set_cookie(
key=PANEL_ACCESS_COOKIE_NAME,
value=session.access_token,
max_age=session.expires_in_seconds,
httponly=True,
secure=use_secure,
samesite=PANEL_COOKIE_SAMESITE,
path=cookie_path,
)
response.set_cookie(
key=PANEL_REFRESH_COOKIE_NAME,
value=session.refresh_token,
max_age=settings.admin_auth_refresh_token_ttl_days * 24 * 60 * 60,
httponly=True,
secure=use_secure,
samesite=PANEL_COOKIE_SAMESITE,
path=cookie_path,
)
def clear_panel_auth_cookies(response: Response, settings: AdminSettings) -> None:
cookie_path = build_panel_cookie_path(settings)
response.delete_cookie(
key=PANEL_ACCESS_COOKIE_NAME,
path=cookie_path,
httponly=True,
samesite=PANEL_COOKIE_SAMESITE,
)
response.delete_cookie(
key=PANEL_REFRESH_COOKIE_NAME,
path=cookie_path,
httponly=True,
samesite=PANEL_COOKIE_SAMESITE,
)
def build_panel_cookie_path(settings: AdminSettings) -> str:
normalized_prefix = settings.admin_api_prefix.rstrip("/")
return normalized_prefix or "/"
def should_use_secure_cookies(settings: AdminSettings) -> bool:
return settings.admin_environment.lower() == "production" and not settings.admin_debug

@ -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 @@
"""Rotas administrativas do servico interno."""

@ -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,180 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from admin_app.api.dependencies import (
get_auth_service,
get_current_panel_staff_context,
get_settings,
)
from admin_app.api.panel_session import (
clear_panel_auth_cookies,
get_panel_access_cookie,
get_panel_refresh_cookie,
set_panel_auth_cookies,
)
from admin_app.api.schemas import (
AdminAuthenticatedStaffResponse,
AdminLoginRequest,
AdminPanelLogoutResponse,
AdminPanelWebSessionResponse,
)
from admin_app.core import AdminAuthenticatedSession, AdminSettings, AuthenticatedStaffContext
from admin_app.services import AuthService
# Autenticação do painel web.
router = APIRouter(prefix="/panel/auth", tags=["panel-auth"])
@router.post("/login", response_model=AdminPanelWebSessionResponse)
def panel_login(
payload: AdminLoginRequest,
request: Request,
response: Response,
settings: AdminSettings = Depends(get_settings),
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.",
)
set_panel_auth_cookies(response, session, settings)
return _build_panel_session_response(
session=session,
message="Sessao administrativa web iniciada.",
redirect_to=_build_prefixed_path(settings.admin_api_prefix, "/panel/admin"),
)
@router.post("/refresh", response_model=AdminPanelWebSessionResponse)
def panel_refresh(
request: Request,
response: Response,
settings: AdminSettings = Depends(get_settings),
auth_service: AuthService = Depends(get_auth_service),
):
refresh_token = get_panel_refresh_cookie(request)
if not refresh_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Sessao administrativa web sem refresh token.",
)
ip_address, user_agent = _extract_request_metadata(request)
session = auth_service.refresh_session(
refresh_token=refresh_token,
ip_address=ip_address,
user_agent=user_agent,
)
if session is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Sessao administrativa web invalida para refresh.",
)
set_panel_auth_cookies(response, session, settings)
return _build_panel_session_response(
session=session,
message="Sessao administrativa web renovada.",
)
@router.get("/session", response_model=AdminPanelWebSessionResponse)
def panel_session(
current_context: AuthenticatedStaffContext = Depends(get_current_panel_staff_context),
settings: AdminSettings = Depends(get_settings),
):
return AdminPanelWebSessionResponse(
service="orquestrador-admin",
status="ok",
message="Sessao administrativa web ativa.",
session_id=current_context.session_id,
expires_in_seconds=settings.admin_auth_access_token_ttl_minutes * 60,
staff_account=AdminAuthenticatedStaffResponse(**current_context.principal.model_dump()),
redirect_to=None,
)
@router.post("/logout", response_model=AdminPanelLogoutResponse)
def panel_logout(
request: Request,
response: Response,
settings: AdminSettings = Depends(get_settings),
auth_service: AuthService = Depends(get_auth_service),
):
ip_address, user_agent = _extract_request_metadata(request)
session_id: int | None = None
access_token = get_panel_access_cookie(request)
if access_token:
try:
current_context = auth_service.get_authenticated_context(access_token)
except ValueError:
current_context = None
if current_context is not None:
auth_service.logout(
current_context.session_id,
actor_staff_account_id=current_context.principal.id,
ip_address=ip_address,
user_agent=user_agent,
)
session_id = current_context.session_id
refresh_token = get_panel_refresh_cookie(request)
if session_id is None and refresh_token:
session_id = auth_service.logout_by_refresh_token(
refresh_token,
ip_address=ip_address,
user_agent=user_agent,
)
clear_panel_auth_cookies(response, settings)
return AdminPanelLogoutResponse(
service="orquestrador-admin",
status="ok",
message="Sessao administrativa web encerrada.",
session_id=session_id,
redirect_to=_build_prefixed_path(settings.admin_api_prefix, "/login"),
)
def _build_panel_session_response(
session: AdminAuthenticatedSession,
*,
message: str,
redirect_to: str | None = None,
) -> AdminPanelWebSessionResponse:
return AdminPanelWebSessionResponse(
service="orquestrador-admin",
status="ok",
message=message,
session_id=session.session_id,
expires_in_seconds=session.expires_in_seconds,
staff_account=AdminAuthenticatedStaffResponse(**session.principal.model_dump()),
redirect_to=redirect_to,
)
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
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}"

@ -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,143 @@
from fastapi import APIRouter, Depends
from admin_app.api.dependencies import get_settings, require_panel_admin_permission
from admin_app.api.schemas import (
AdminBotFlowReportOverviewResponse,
AdminConversationTelemetryReportOverviewResponse,
AdminRentalReportOverviewResponse,
AdminRevenueReportOverviewResponse,
AdminSalesReportOverviewResponse,
)
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal
from admin_app.services import ReportService
from shared.contracts import AdminPermission
router = APIRouter(prefix="/panel/reports", tags=["panel-reports"])
def _build_service(settings: AdminSettings) -> ReportService:
return ReportService(settings)
@router.get(
"/sales/overview",
response_model=AdminSalesReportOverviewResponse,
)
def panel_sales_reports_overview(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
require_panel_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(
"/arrecadacao/overview",
response_model=AdminRevenueReportOverviewResponse,
)
def panel_revenue_reports_overview(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
require_panel_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(
"/locacao/overview",
response_model=AdminRentalReportOverviewResponse,
)
def panel_rental_reports_overview(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
require_panel_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(
"/fluxo-bot/overview",
response_model=AdminBotFlowReportOverviewResponse,
)
def panel_bot_flow_reports_overview(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
require_panel_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(
"/telemetria-conversacional/overview",
response_model=AdminConversationTelemetryReportOverviewResponse,
)
def panel_conversation_telemetry_reports_overview(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
require_panel_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"],
)

@ -0,0 +1,598 @@
from fastapi import APIRouter, Depends, HTTPException, status
from admin_app.api.dependencies import (
get_settings,
get_tool_management_service,
require_panel_admin_permission,
)
from admin_app.api.schemas import (
AdminToolContractsResponse,
AdminToolDraftIntakeRequest,
AdminToolDraftIntakeResponse,
AdminToolDraftListResponse,
AdminToolGenerationPipelineResponse,
AdminToolGovernanceDecisionRequest,
AdminToolGovernanceTransitionResponse,
AdminToolManagementActionResponse,
AdminToolOverviewResponse,
AdminToolOptionalGovernanceDecisionRequest,
AdminToolPublicationListResponse,
AdminToolReviewDecisionRequest,
AdminToolReviewDetailResponse,
AdminToolReviewQueueResponse,
)
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal
from admin_app.services import ToolManagementService
from shared.contracts import AdminPermission, StaffRole, role_has_permission
router = APIRouter(prefix="/panel/tools", tags=["panel-tools"])
@router.get(
"/overview",
response_model=AdminToolOverviewResponse,
)
def panel_tools_overview(
settings: AdminSettings = Depends(get_settings),
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
payload = service.build_overview_payload()
return AdminToolOverviewResponse(
service="orquestrador-admin",
mode=payload["mode"],
metrics=payload["metrics"],
workflow=payload["workflow"],
actions=_build_panel_actions(settings, current_staff.role),
next_steps=payload["next_steps"],
)
@router.get(
"/contracts",
response_model=AdminToolContractsResponse,
)
def panel_tool_contracts(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
payload = service.build_contracts_payload()
return AdminToolContractsResponse(
service="orquestrador-admin",
publication_source_service=payload["publication_source_service"],
publication_target_service=payload["publication_target_service"],
lifecycle_statuses=payload["lifecycle_statuses"],
parameter_types=payload["parameter_types"],
publication_fields=payload["publication_fields"],
published_tool_fields=payload["published_tool_fields"],
)
@router.get(
"/drafts",
response_model=AdminToolDraftListResponse,
)
def panel_tool_drafts(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
payload = service.build_drafts_payload()
return AdminToolDraftListResponse(
service="orquestrador-admin",
storage_status=payload["storage_status"],
message=payload["message"],
drafts=payload["drafts"],
supported_statuses=payload["supported_statuses"],
)
@router.post(
"/drafts/intake",
response_model=AdminToolDraftIntakeResponse,
)
def panel_tool_draft_intake(
draft: AdminToolDraftIntakeRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
try:
payload = service.create_draft_submission(
draft.model_dump(),
owner_staff_account_id=current_staff.id,
owner_name=current_staff.display_name,
owner_role=current_staff.role,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return AdminToolDraftIntakeResponse(
service="orquestrador-admin",
storage_status=payload["storage_status"],
message=payload["message"],
submission_policy=payload["submission_policy"],
draft_preview=payload["draft_preview"],
warnings=payload["warnings"],
next_steps=payload["next_steps"],
)
@router.post(
"/pipeline/{version_id}/run",
response_model=AdminToolGenerationPipelineResponse,
)
def panel_tool_pipeline_run(
version_id: str,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
try:
payload = service.run_generation_pipeline_in_worker(
version_id,
runner_staff_account_id=current_staff.id,
runner_name=current_staff.display_name,
runner_role=current_staff.role,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_pipeline_response(payload)
@router.post(
"/drafts/{version_id}/authorize-generation",
response_model=AdminToolGovernanceTransitionResponse,
)
def panel_tool_draft_authorize_generation(
version_id: str,
decision: AdminToolGovernanceDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.authorize_generation(
version_id,
actor_staff_account_id=current_staff.id,
actor_name=current_staff.display_name,
actor_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/drafts/{version_id}/close",
response_model=AdminToolGovernanceTransitionResponse,
)
def panel_tool_draft_close(
version_id: str,
decision: AdminToolOptionalGovernanceDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.close_proposal(
version_id,
actor_staff_account_id=current_staff.id,
actor_name=current_staff.display_name,
actor_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.get(
"/review-queue",
response_model=AdminToolReviewQueueResponse,
)
def panel_tool_review_queue(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
payload = service.build_review_queue_payload()
return AdminToolReviewQueueResponse(
service="orquestrador-admin",
queue_mode=payload["queue_mode"],
message=payload["message"],
items=payload["items"],
supported_statuses=payload["supported_statuses"],
)
@router.get(
"/review-queue/{version_id}",
response_model=AdminToolReviewDetailResponse,
)
def panel_tool_review_queue_detail(
version_id: str,
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.build_review_detail_payload(version_id)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return _build_review_detail_response(payload)
@router.post(
"/review-queue/{version_id}/review",
response_model=AdminToolGovernanceTransitionResponse,
)
def panel_tool_review_queue_review(
version_id: str,
decision: AdminToolReviewDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.review_version(
version_id,
reviewer_staff_account_id=current_staff.id,
reviewer_name=current_staff.display_name,
reviewer_role=current_staff.role,
decision_notes=decision.decision_notes,
reviewed_generated_code=decision.reviewed_generated_code,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/review-queue/{version_id}/request-changes",
response_model=AdminToolGovernanceTransitionResponse,
)
def panel_tool_review_queue_request_changes(
version_id: str,
decision: AdminToolGovernanceDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.request_changes(
version_id,
actor_staff_account_id=current_staff.id,
actor_name=current_staff.display_name,
actor_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/review-queue/{version_id}/close",
response_model=AdminToolGovernanceTransitionResponse,
)
def panel_tool_review_queue_close(
version_id: str,
decision: AdminToolOptionalGovernanceDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.close_proposal(
version_id,
actor_staff_account_id=current_staff.id,
actor_name=current_staff.display_name,
actor_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/review-queue/{version_id}/approve",
response_model=AdminToolGovernanceTransitionResponse,
)
def panel_tool_review_queue_approve(
version_id: str,
decision: AdminToolReviewDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.approve_version(
version_id,
approver_staff_account_id=current_staff.id,
approver_name=current_staff.display_name,
approver_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.get(
"/publications",
response_model=AdminToolPublicationListResponse,
)
def panel_tool_publications(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.PUBLISH_TOOLS)
),
):
payload = service.build_publications_payload()
return AdminToolPublicationListResponse(
service="orquestrador-admin",
source=payload["source"],
target_service=payload["target_service"],
publications=payload["publications"],
)
@router.post(
"/publications/{version_id}/publish",
response_model=AdminToolGovernanceTransitionResponse,
)
def panel_tool_publications_publish(
version_id: str,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.PUBLISH_TOOLS)
),
):
try:
payload = service.publish_version(
version_id,
publisher_staff_account_id=current_staff.id,
publisher_name=current_staff.display_name,
publisher_role=current_staff.role,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/publications/{version_id}/deactivate",
response_model=AdminToolGovernanceTransitionResponse,
)
def panel_tool_publications_deactivate(
version_id: str,
decision: AdminToolGovernanceDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.PUBLISH_TOOLS)
),
):
try:
payload = service.deactivate_version(
version_id,
actor_staff_account_id=current_staff.id,
actor_name=current_staff.display_name,
actor_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/publications/{version_id}/rollback",
response_model=AdminToolGovernanceTransitionResponse,
)
def panel_tool_publications_rollback(
version_id: str,
decision: AdminToolGovernanceDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.PUBLISH_TOOLS)
),
):
try:
payload = service.rollback_version(
version_id,
actor_staff_account_id=current_staff.id,
actor_name=current_staff.display_name,
actor_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
def _build_pipeline_response(payload: dict) -> AdminToolGenerationPipelineResponse:
return AdminToolGenerationPipelineResponse(
service="orquestrador-admin",
message=payload["message"],
version_id=payload["version_id"],
tool_name=payload["tool_name"],
version_number=payload["version_number"],
status=payload["status"],
current_step=payload["current_step"],
steps=payload["steps"],
queue_entry=payload["queue_entry"],
automated_validations=payload.get("automated_validations", []),
execution=payload.get("execution"),
next_steps=payload["next_steps"],
)
def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceTransitionResponse:
return AdminToolGovernanceTransitionResponse(
service="orquestrador-admin",
message=payload["message"],
version_id=payload["version_id"],
tool_name=payload["tool_name"],
version_number=payload["version_number"],
status=payload["status"],
queue_entry=payload["queue_entry"],
publication=payload["publication"],
next_steps=payload["next_steps"],
)
def _build_review_detail_response(payload: dict) -> AdminToolReviewDetailResponse:
return AdminToolReviewDetailResponse(
service="orquestrador-admin",
version_id=payload["version_id"],
tool_name=payload["tool_name"],
display_name=payload["display_name"],
domain=payload["domain"],
version_number=payload["version_number"],
status=payload["status"],
summary=payload["summary"],
description=payload["description"],
business_goal=payload["business_goal"],
owner_name=payload["owner_name"],
parameters=payload["parameters"],
queue_entry=payload["queue_entry"],
automated_validations=payload["automated_validations"],
automated_validation_summary=payload["automated_validation_summary"],
generated_module=payload["generated_module"],
generated_callable=payload["generated_callable"],
generated_source_code=payload["generated_source_code"],
execution=payload.get("execution"),
generation_context=payload["generation_context"],
human_gate=payload["human_gate"],
decision_history=payload["decision_history"],
next_steps=payload["next_steps"],
)
def _build_panel_actions(
settings: AdminSettings,
current_role: StaffRole | str | None = None,
) -> list[AdminToolManagementActionResponse]:
actions = [
AdminToolManagementActionResponse(
key="overview",
label="Overview web de tools",
href=_build_prefixed_path(settings.admin_api_prefix, "/panel/tools/overview"),
required_permission=AdminPermission.MANAGE_TOOL_DRAFTS,
description="Snapshot do dominio de tools pronto para leitura no painel.",
),
AdminToolManagementActionResponse(
key="contracts",
label="Contratos web de tools",
href=_build_prefixed_path(settings.admin_api_prefix, "/panel/tools/contracts"),
required_permission=AdminPermission.MANAGE_TOOL_DRAFTS,
description="Base contratual para a tela de revisao e aprovacao.",
),
AdminToolManagementActionResponse(
key="draft_intake",
label="Pre-cadastro web de tool",
href=_build_prefixed_path(settings.admin_api_prefix, "/panel/tools/drafts/intake"),
required_permission=AdminPermission.MANAGE_TOOL_DRAFTS,
description="Valida e persiste o draft diretamente na sessao web do painel.",
),
AdminToolManagementActionResponse(
key="review_queue",
label="Fila web de revisao",
href=_build_prefixed_path(settings.admin_api_prefix, "/panel/tools/review-queue"),
required_permission=AdminPermission.REVIEW_TOOL_GENERATIONS,
description="Leitura da fila de revisao sob a sessao web do painel.",
),
AdminToolManagementActionResponse(
key="publications",
label="Publicacoes web",
href=_build_prefixed_path(settings.admin_api_prefix, "/panel/tools/publications"),
required_permission=AdminPermission.PUBLISH_TOOLS,
description="Catalogo de tools ativas e prontas para ativacao no produto.",
),
]
if current_role is None:
return actions
return [action for action in actions if role_has_permission(current_role, action.required_permission)]
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}"

@ -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}"

@ -0,0 +1,607 @@
from fastapi import APIRouter, Depends, HTTPException, status
from admin_app.api.dependencies import (
get_settings,
get_tool_management_service,
require_admin_permission,
)
from admin_app.api.schemas import (
AdminToolContractsResponse,
AdminToolDraftIntakeRequest,
AdminToolDraftIntakeResponse,
AdminToolDraftListResponse,
AdminToolGenerationPipelineResponse,
AdminToolGovernanceDecisionRequest,
AdminToolGovernanceTransitionResponse,
AdminToolManagementActionResponse,
AdminToolOverviewResponse,
AdminToolOptionalGovernanceDecisionRequest,
AdminToolPublicationListResponse,
AdminToolReviewDecisionRequest,
AdminToolReviewDetailResponse,
AdminToolReviewQueueResponse,
)
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal
from admin_app.services import ToolManagementService
from shared.contracts import AdminPermission, StaffRole, role_has_permission
# API de intake (processo de captação e triagem inicial de demandas, requisitos ou solicitações antes do início efetivo do projeto), pipeline, review, publish, deactivate e rollback de tools.
router = APIRouter(prefix="/tools", tags=["tools"])
@router.get(
"/overview",
response_model=AdminToolOverviewResponse,
)
def tools_overview(
settings: AdminSettings = Depends(get_settings),
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
payload = service.build_overview_payload()
return AdminToolOverviewResponse(
service="orquestrador-admin",
mode=payload["mode"],
metrics=payload["metrics"],
workflow=payload["workflow"],
actions=_build_actions(settings, current_staff.role),
next_steps=payload["next_steps"],
)
@router.get(
"/contracts",
response_model=AdminToolContractsResponse,
)
def tool_contracts(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
payload = service.build_contracts_payload()
return AdminToolContractsResponse(
service="orquestrador-admin",
publication_source_service=payload["publication_source_service"],
publication_target_service=payload["publication_target_service"],
lifecycle_statuses=payload["lifecycle_statuses"],
parameter_types=payload["parameter_types"],
publication_fields=payload["publication_fields"],
published_tool_fields=payload["published_tool_fields"],
)
@router.get(
"/drafts",
response_model=AdminToolDraftListResponse,
)
def tool_drafts(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
payload = service.build_drafts_payload()
return AdminToolDraftListResponse(
service="orquestrador-admin",
storage_status=payload["storage_status"],
message=payload["message"],
drafts=payload["drafts"],
supported_statuses=payload["supported_statuses"],
)
@router.post(
"/drafts/intake",
response_model=AdminToolDraftIntakeResponse,
)
def tool_draft_intake(
draft: AdminToolDraftIntakeRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
try:
payload = service.create_draft_submission(
draft.model_dump(),
owner_staff_account_id=current_staff.id,
owner_name=current_staff.display_name,
owner_role=current_staff.role,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return AdminToolDraftIntakeResponse(
service="orquestrador-admin",
storage_status=payload["storage_status"],
message=payload["message"],
submission_policy=payload["submission_policy"],
draft_preview=payload["draft_preview"],
warnings=payload["warnings"],
next_steps=payload["next_steps"],
)
@router.post(
"/pipeline/{version_id}/run",
response_model=AdminToolGenerationPipelineResponse,
)
def tool_pipeline_run(
version_id: str,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
try:
payload = service.run_generation_pipeline_in_worker(
version_id,
runner_staff_account_id=current_staff.id,
runner_name=current_staff.display_name,
runner_role=current_staff.role,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_pipeline_response(payload)
@router.post(
"/drafts/{version_id}/authorize-generation",
response_model=AdminToolGovernanceTransitionResponse,
)
def tool_draft_authorize_generation(
version_id: str,
decision: AdminToolGovernanceDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.authorize_generation(
version_id,
actor_staff_account_id=current_staff.id,
actor_name=current_staff.display_name,
actor_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/drafts/{version_id}/close",
response_model=AdminToolGovernanceTransitionResponse,
)
def tool_draft_close(
version_id: str,
decision: AdminToolOptionalGovernanceDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.close_proposal(
version_id,
actor_staff_account_id=current_staff.id,
actor_name=current_staff.display_name,
actor_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.get(
"/review-queue",
response_model=AdminToolReviewQueueResponse,
)
def tool_review_queue(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
payload = service.build_review_queue_payload()
return AdminToolReviewQueueResponse(
service="orquestrador-admin",
queue_mode=payload["queue_mode"],
message=payload["message"],
items=payload["items"],
supported_statuses=payload["supported_statuses"],
)
@router.get(
"/review-queue/{version_id}",
response_model=AdminToolReviewDetailResponse,
)
def tool_review_queue_detail(
version_id: str,
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.build_review_detail_payload(version_id)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return _build_review_detail_response(payload)
@router.post(
"/review-queue/{version_id}/review",
response_model=AdminToolGovernanceTransitionResponse,
)
def tool_review_queue_review(
version_id: str,
decision: AdminToolReviewDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.review_version(
version_id,
reviewer_staff_account_id=current_staff.id,
reviewer_name=current_staff.display_name,
reviewer_role=current_staff.role,
decision_notes=decision.decision_notes,
reviewed_generated_code=decision.reviewed_generated_code,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/review-queue/{version_id}/request-changes",
response_model=AdminToolGovernanceTransitionResponse,
)
def tool_review_queue_request_changes(
version_id: str,
decision: AdminToolGovernanceDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.request_changes(
version_id,
actor_staff_account_id=current_staff.id,
actor_name=current_staff.display_name,
actor_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/review-queue/{version_id}/close",
response_model=AdminToolGovernanceTransitionResponse,
)
def tool_review_queue_close(
version_id: str,
decision: AdminToolOptionalGovernanceDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.close_proposal(
version_id,
actor_staff_account_id=current_staff.id,
actor_name=current_staff.display_name,
actor_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/review-queue/{version_id}/approve",
response_model=AdminToolGovernanceTransitionResponse,
)
def tool_review_queue_approve(
version_id: str,
decision: AdminToolReviewDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.approve_version(
version_id,
approver_staff_account_id=current_staff.id,
approver_name=current_staff.display_name,
approver_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.get(
"/publications",
response_model=AdminToolPublicationListResponse,
)
def tool_publications(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.PUBLISH_TOOLS)
),
):
payload = service.build_publications_payload()
return AdminToolPublicationListResponse(
service="orquestrador-admin",
source=payload["source"],
target_service=payload["target_service"],
publications=payload["publications"],
)
@router.post(
"/publications/{version_id}/publish",
response_model=AdminToolGovernanceTransitionResponse,
)
def tool_publications_publish(
version_id: str,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.PUBLISH_TOOLS)
),
):
try:
payload = service.publish_version(
version_id,
publisher_staff_account_id=current_staff.id,
publisher_name=current_staff.display_name,
publisher_role=current_staff.role,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/publications/{version_id}/deactivate",
response_model=AdminToolGovernanceTransitionResponse,
)
def tool_publications_deactivate(
version_id: str,
decision: AdminToolGovernanceDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.PUBLISH_TOOLS)
),
):
try:
payload = service.deactivate_version(
version_id,
actor_staff_account_id=current_staff.id,
actor_name=current_staff.display_name,
actor_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/publications/{version_id}/rollback",
response_model=AdminToolGovernanceTransitionResponse,
)
def tool_publications_rollback(
version_id: str,
decision: AdminToolGovernanceDecisionRequest,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.PUBLISH_TOOLS)
),
):
try:
payload = service.rollback_version(
version_id,
actor_staff_account_id=current_staff.id,
actor_name=current_staff.display_name,
actor_role=current_staff.role,
decision_notes=decision.decision_notes,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
def _build_pipeline_response(payload: dict) -> AdminToolGenerationPipelineResponse:
return AdminToolGenerationPipelineResponse(
service="orquestrador-admin",
message=payload["message"],
version_id=payload["version_id"],
tool_name=payload["tool_name"],
version_number=payload["version_number"],
status=payload["status"],
current_step=payload["current_step"],
steps=payload["steps"],
queue_entry=payload["queue_entry"],
automated_validations=payload.get("automated_validations", []),
execution=payload.get("execution"),
next_steps=payload["next_steps"],
)
def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceTransitionResponse:
return AdminToolGovernanceTransitionResponse(
service="orquestrador-admin",
message=payload["message"],
version_id=payload["version_id"],
tool_name=payload["tool_name"],
version_number=payload["version_number"],
status=payload["status"],
queue_entry=payload["queue_entry"],
publication=payload["publication"],
next_steps=payload["next_steps"],
)
def _build_review_detail_response(payload: dict) -> AdminToolReviewDetailResponse:
return AdminToolReviewDetailResponse(
service="orquestrador-admin",
version_id=payload["version_id"],
tool_name=payload["tool_name"],
display_name=payload["display_name"],
domain=payload["domain"],
version_number=payload["version_number"],
status=payload["status"],
summary=payload["summary"],
description=payload["description"],
business_goal=payload["business_goal"],
owner_name=payload["owner_name"],
parameters=payload["parameters"],
queue_entry=payload["queue_entry"],
automated_validations=payload["automated_validations"],
automated_validation_summary=payload["automated_validation_summary"],
generated_module=payload["generated_module"],
generated_callable=payload["generated_callable"],
generated_source_code=payload["generated_source_code"],
execution=payload.get("execution"),
generation_context=payload["generation_context"],
human_gate=payload["human_gate"],
decision_history=payload["decision_history"],
next_steps=payload["next_steps"],
)
def _build_actions(
settings: AdminSettings,
current_role: StaffRole | str | None = None,
) -> list[AdminToolManagementActionResponse]:
actions = [
AdminToolManagementActionResponse(
key="overview",
label="Overview de tools",
href=_build_prefixed_path(settings.admin_api_prefix, "/tools/overview"),
required_permission=AdminPermission.MANAGE_TOOL_DRAFTS,
description="Snapshot inicial da governanca de tools no admin.",
),
AdminToolManagementActionResponse(
key="contracts",
label="Contratos compartilhados",
href=_build_prefixed_path(settings.admin_api_prefix, "/tools/contracts"),
required_permission=AdminPermission.MANAGE_TOOL_DRAFTS,
description="Enumera lifecycle, tipos de parametro e campos de publicacao.",
),
AdminToolManagementActionResponse(
key="drafts",
label="Fila de drafts",
href=_build_prefixed_path(settings.admin_api_prefix, "/tools/drafts"),
required_permission=AdminPermission.MANAGE_TOOL_DRAFTS,
description="Lista os drafts administrativos persistidos antes da geracao e revisao.",
),
AdminToolManagementActionResponse(
key="draft_intake",
label="Pre-cadastro de tool",
href=_build_prefixed_path(settings.admin_api_prefix, "/tools/drafts/intake"),
required_permission=AdminPermission.MANAGE_TOOL_DRAFTS,
description="Valida e persiste o draft administrativo da nova tool.",
),
AdminToolManagementActionResponse(
key="review_queue",
label="Fila de revisao",
href=_build_prefixed_path(settings.admin_api_prefix, "/tools/review-queue"),
required_permission=AdminPermission.REVIEW_TOOL_GENERATIONS,
description="Superficie para validacao, revisao tecnica e aprovacao humana.",
),
AdminToolManagementActionResponse(
key="publications",
label="Catalogo de publicacoes",
href=_build_prefixed_path(settings.admin_api_prefix, "/tools/publications"),
required_permission=AdminPermission.PUBLISH_TOOLS,
description="Catalogo bootstrap de tools ativas voltadas ao runtime de produto.",
),
]
if current_role is None:
return actions
return [action for action in actions if role_has_permission(current_role, action.required_permission)]
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,33 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from admin_app.api.router import api_router
from admin_app.core.settings import AdminSettings, get_admin_settings
from admin_app.view import PANEL_STATIC_DIRECTORY, PANEL_STATIC_MOUNT_NAME, panel_router
# Fabrica explicita do runtime administrativo para facilitar testes e futura configuracao.
def create_app(settings: AdminSettings | None = None) -> FastAPI:
resolved_settings = settings or get_admin_settings()
app = FastAPI(
title=resolved_settings.admin_app_name,
version=resolved_settings.admin_version,
debug=resolved_settings.admin_debug,
)
app.state.admin_settings = resolved_settings
app.mount(
_build_panel_static_path(resolved_settings.admin_api_prefix),
StaticFiles(directory=str(PANEL_STATIC_DIRECTORY)),
name=PANEL_STATIC_MOUNT_NAME,
)
app.include_router(api_router, prefix=resolved_settings.admin_api_prefix)
app.include_router(panel_router, prefix=resolved_settings.admin_api_prefix)
return app
def _build_panel_static_path(api_prefix: str) -> str:
normalized_prefix = api_prefix.rstrip("/")
if normalized_prefix:
return f"{normalized_prefix}/panel/assets"
return "/panel/assets"

@ -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,135 @@
from functools import lru_cache
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class AdminSettings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
extra="ignore",
)
admin_app_name: str = "Orquestrador Admin"
admin_environment: str = "production"
admin_debug: bool = False
admin_version: str = "0.1.0"
admin_api_prefix: str = ""
admin_db_host: str = "127.0.0.1"
admin_db_port: int = 3306
admin_db_user: str = "root"
admin_db_password: str = ""
admin_db_name: str = "orquestrador_admin"
admin_db_cloud_sql_connection_name: str | None = None
admin_auth_password_hash_scheme: str = "pbkdf2_sha256"
admin_auth_password_hash_iterations: int = 390000
admin_auth_password_min_length: int = 12
admin_auth_password_require_uppercase: bool = True
admin_auth_password_require_lowercase: bool = True
admin_auth_password_require_digit: bool = True
admin_auth_password_require_symbol: bool = True
admin_auth_password_pepper: str | None = None
admin_auth_token_secret: str = "local-admin-token-secret-change-me"
admin_auth_token_issuer: str = "orquestrador-admin"
admin_auth_access_token_ttl_minutes: int = 30
admin_auth_refresh_token_ttl_days: int = 7
admin_auth_refresh_token_bytes: int = 32
admin_bootstrap_enabled: bool = False
admin_bootstrap_email: str | None = None
admin_bootstrap_display_name: str | None = None
admin_bootstrap_password: str | None = None
admin_bootstrap_role: str = "diretor"
# ---- Runtime de geraÃÆÃ†â€™Ãƒâ€šÃ§ÃÆÃ†â€™Ãƒâ€šÃ£o de tools (separado do runtime de atendimento) ----
# Mapeado ao tool_generation_runtime_profile do contrato shared/contracts/model_runtime_separation.py.
# Nunca compartilhar estes valores com o runtime de atendimento do product.
admin_tool_generation_model: str = "gemini-3-pro-preview"
admin_tool_generation_fallback_model: str = "gemini-2.5-pro"
admin_tool_generation_timeout_seconds: int = 120
admin_tool_generation_max_output_tokens: int = 8192
admin_tool_generation_temperature: float = 0.2
admin_tool_generation_worker_max_workers: int = 1
@field_validator("admin_debug", mode="before")
@classmethod
def parse_debug_aliases(cls, value):
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"debug", "development", "dev"}:
return True
if normalized in {"release", "production", "prod"}:
return False
return value
@field_validator(
"admin_environment",
"admin_api_prefix",
"admin_auth_password_hash_scheme",
"admin_auth_token_secret",
"admin_auth_token_issuer",
"admin_bootstrap_role",
mode="before",
)
@classmethod
def normalize_required_text_settings(cls, value):
if isinstance(value, str):
return value.strip()
return value
@field_validator(
"admin_bootstrap_email",
"admin_bootstrap_display_name",
"admin_bootstrap_password",
"admin_auth_password_pepper",
mode="before",
)
@classmethod
def normalize_optional_text_settings(cls, value):
if isinstance(value, str):
stripped = value.strip()
return stripped or None
return value
@field_validator("admin_auth_password_min_length")
@classmethod
def validate_password_min_length(cls, value: int) -> int:
if value < 12:
raise ValueError("admin_auth_password_min_length must be >= 12")
return value
@field_validator("admin_auth_password_hash_iterations")
@classmethod
def validate_password_hash_iterations(cls, value: int) -> int:
if value < 100_000:
raise ValueError("admin_auth_password_hash_iterations must be >= 100000")
return value
@field_validator("admin_auth_access_token_ttl_minutes")
@classmethod
def validate_access_token_ttl(cls, value: int) -> int:
if value < 5:
raise ValueError("admin_auth_access_token_ttl_minutes must be >= 5")
return value
@field_validator("admin_auth_refresh_token_ttl_days")
@classmethod
def validate_refresh_token_ttl(cls, value: int) -> int:
if value < 1:
raise ValueError("admin_auth_refresh_token_ttl_days must be >= 1")
return value
@field_validator("admin_auth_refresh_token_bytes")
@classmethod
def validate_refresh_token_bytes(cls, value: int) -> int:
if value < 16:
raise ValueError("admin_auth_refresh_token_bytes must be >= 16")
return value
@lru_cache(maxsize=1)
def get_admin_settings() -> AdminSettings:
return AdminSettings()

@ -0,0 +1 @@
"""Persistencia do servico administrativo."""

@ -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,240 @@
from datetime import datetime, timezone
from admin_app.core import (
AdminAuthenticatedSession,
AdminSecurityService,
AuthenticatedStaffContext,
AuthenticatedStaffPrincipal,
)
from admin_app.db.models import StaffAccount, StaffSession
from admin_app.repositories import StaffAccountRepository, StaffSessionRepository
from admin_app.services.audit_service import AuditService
class AuthService:
def __init__(
self,
account_repository: StaffAccountRepository,
session_repository: StaffSessionRepository,
security_service: AdminSecurityService,
audit_service: AuditService,
):
self.account_repository = account_repository
self.session_repository = session_repository
self.security_service = security_service
self.audit_service = audit_service
def login(
self,
email: str,
password: str,
*,
ip_address: str | None,
user_agent: str | None,
) -> AdminAuthenticatedSession | None:
normalized_email = self.normalize_email(email)
account = self.authenticate_account(email=normalized_email, password=password)
if account is None:
self.audit_service.record_login_failed(
email=normalized_email,
ip_address=ip_address,
user_agent=user_agent,
)
return None
session = self._create_authenticated_session(
account,
ip_address=ip_address,
user_agent=user_agent,
)
self.audit_service.record_login_succeeded(
actor_staff_account_id=account.id,
session_id=session.session_id,
email=account.email,
role=account.role.value,
ip_address=ip_address,
user_agent=user_agent,
)
return session
def refresh_session(
self,
refresh_token: str,
*,
ip_address: str | None,
user_agent: str | None,
) -> AdminAuthenticatedSession | None:
token_hash = self.security_service.hash_refresh_token(refresh_token)
staff_session = self.session_repository.get_by_refresh_token_hash(token_hash)
if not self._is_session_active(staff_session):
return None
account = self.account_repository.get_by_id(staff_session.staff_account_id)
if account is None or not account.is_active:
return None
return self._rotate_session(
staff_session,
account,
ip_address=ip_address,
user_agent=user_agent,
)
def logout(
self,
session_id: int,
*,
actor_staff_account_id: int | None,
ip_address: str | None,
user_agent: str | None,
) -> bool:
staff_session = self.session_repository.get_by_id(session_id)
if staff_session is None:
return False
if staff_session.revoked_at is None:
staff_session.revoked_at = datetime.now(timezone.utc)
self.session_repository.save(staff_session)
if actor_staff_account_id is not None:
self.audit_service.record_logout_succeeded(
actor_staff_account_id=actor_staff_account_id,
session_id=session_id,
ip_address=ip_address,
user_agent=user_agent,
)
return True
def logout_by_refresh_token(
self,
refresh_token: str,
*,
ip_address: str | None,
user_agent: str | None,
) -> int | None:
token_hash = self.security_service.hash_refresh_token(refresh_token)
staff_session = self.session_repository.get_by_refresh_token_hash(token_hash)
if staff_session is None:
return None
account = self.account_repository.get_by_id(staff_session.staff_account_id)
actor_staff_account_id = account.id if account is not None and account.is_active else None
self.logout(
staff_session.id,
actor_staff_account_id=actor_staff_account_id,
ip_address=ip_address,
user_agent=user_agent,
)
return staff_session.id
def get_authenticated_context(self, access_token: str) -> AuthenticatedStaffContext:
claims = self.security_service.decode_access_token(access_token)
staff_session = self.session_repository.get_by_id(claims.sid)
if not self._is_session_active(staff_session):
raise ValueError("Administrative session is not available")
account = self.account_repository.get_by_id(int(claims.sub))
if account is None or not account.is_active:
raise ValueError("Staff account is not available")
if self.normalize_email(account.email) != self.normalize_email(claims.email):
raise ValueError("Token principal mismatch")
return AuthenticatedStaffContext(
principal=self.build_principal(account),
session_id=staff_session.id,
)
def authenticate_account(self, email: str, password: str) -> StaffAccount | None:
normalized_email = self.normalize_email(email)
account = self.account_repository.get_by_email(normalized_email)
if account is None or not account.is_active:
return None
if not self.security_service.verify_password(password, account.password_hash):
return None
account.last_login_at = datetime.now(timezone.utc)
return self.account_repository.update_last_login(account)
@staticmethod
def normalize_email(email: str) -> str:
return email.strip().lower()
@staticmethod
def build_principal(account: StaffAccount) -> AuthenticatedStaffPrincipal:
return AuthenticatedStaffPrincipal(
id=account.id,
email=account.email,
display_name=account.display_name,
role=account.role,
is_active=account.is_active,
)
def _create_authenticated_session(
self,
account: StaffAccount,
*,
ip_address: str | None,
user_agent: str | None,
) -> AdminAuthenticatedSession:
refresh_token = self.security_service.generate_refresh_token()
refresh_token_hash = self.security_service.hash_refresh_token(refresh_token)
expires_at = self.security_service.build_refresh_token_expiry()
staff_session = self.session_repository.create(
staff_account_id=account.id,
refresh_token_hash=refresh_token_hash,
expires_at=expires_at,
ip_address=ip_address,
user_agent=user_agent,
)
principal = self.build_principal(account)
access_token = self.security_service.issue_access_token(principal, staff_session.id)
return AdminAuthenticatedSession(
session_id=staff_session.id,
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in_seconds=self.security_service.settings.admin_auth_access_token_ttl_minutes * 60,
principal=principal,
)
def _rotate_session(
self,
staff_session: StaffSession,
account: StaffAccount,
*,
ip_address: str | None,
user_agent: str | None,
) -> AdminAuthenticatedSession:
refresh_token = self.security_service.generate_refresh_token()
staff_session.refresh_token_hash = self.security_service.hash_refresh_token(refresh_token)
staff_session.expires_at = self.security_service.build_refresh_token_expiry()
staff_session.last_used_at = datetime.now(timezone.utc)
staff_session.ip_address = ip_address
staff_session.user_agent = user_agent
persisted_session = self.session_repository.save(staff_session)
principal = self.build_principal(account)
access_token = self.security_service.issue_access_token(principal, persisted_session.id)
return AdminAuthenticatedSession(
session_id=persisted_session.id,
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer",
expires_in_seconds=self.security_service.settings.admin_auth_access_token_ttl_minutes * 60,
principal=principal,
)
@staticmethod
def _normalize_datetime(value: datetime) -> datetime:
if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
@classmethod
def _is_session_active(cls, staff_session: StaffSession | None) -> bool:
if staff_session is None:
return False
if staff_session.revoked_at is not None:
return False
expires_at = cls._normalize_datetime(staff_session.expires_at)
return expires_at >= datetime.now(timezone.utc)

@ -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

@ -0,0 +1,9 @@
from admin_app.view.assets import PANEL_STATIC_DIRECTORY, PANEL_STATIC_MOUNT_NAME
from admin_app.view.router import panel_router
__all__ = [
"PANEL_STATIC_DIRECTORY",
"PANEL_STATIC_MOUNT_NAME",
"panel_router",
]

@ -0,0 +1,4 @@
from pathlib import Path
PANEL_STATIC_MOUNT_NAME = "admin_panel_assets"
PANEL_STATIC_DIRECTORY = Path(__file__).resolve().parent / "static"

File diff suppressed because it is too large Load Diff

@ -0,0 +1,958 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from admin_app.api.dependencies import get_optional_panel_staff_context
from admin_app.core import AdminSettings, AuthenticatedStaffContext, get_admin_settings
from admin_app.services import ToolManagementService
from admin_app.view.assets import PANEL_STATIC_MOUNT_NAME
from admin_app.view.rendering import (
render_bot_monitoring_page,
render_collaborator_management_page,
render_login_page,
render_panel_home,
render_rental_reports_page,
render_sales_revenue_reports_page,
render_system_configuration_page,
render_tool_intake_page,
render_tool_review_page,
)
from admin_app.view.view_models import (
AdminBotMonitoringPageView,
AdminCollaboratorManagementPageView,
AdminLoginPageView,
AdminPanelHomeView,
AdminPanelMetric,
AdminPanelModuleCard,
AdminPanelNavigationItem,
AdminPanelQuickAction,
AdminPanelRoadmapItem,
AdminPanelSurfaceLink,
AdminRentalReportsPageView,
AdminSalesRevenueReportsPageView,
AdminSystemConfigurationPageView,
AdminToolIntakeDomainOption,
AdminToolIntakePageView,
AdminToolIntakeParameterTypeOption,
AdminToolReviewPageView,
AdminToolReviewWorkflowStep,
)
from shared.contracts import AdminPermission, StaffRole, role_has_permission
panel_router = APIRouter(tags=["panel"])
@panel_router.get("/panel", name="panel_entry")
def panel_entry(
request: Request,
current_context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context),
) -> RedirectResponse:
target_route_name = "panel_home" if current_context is not None else "admin_login_view"
return _redirect_to_route(request, target_route_name)
@panel_router.get("/panel/admin", response_class=HTMLResponse, name="panel_home")
def panel_home(
request: Request,
current_context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context),
) -> Response:
if current_context is None:
return _redirect_to_route(request, "admin_login_view")
settings = _resolve_settings(request)
view = _build_home_view(request, settings, current_context)
css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css"))
js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js"))
return HTMLResponse(render_panel_home(view, css_href=css_href, js_href=js_href))
@panel_router.get("/login", response_class=HTMLResponse, name="admin_login_view")
def login_page(
request: Request,
current_context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context),
) -> Response:
if current_context is not None:
return _redirect_to_route(request, "panel_home")
settings = _resolve_settings(request)
view = _build_login_view(request, settings)
css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css"))
js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js"))
return HTMLResponse(render_login_page(view, css_href=css_href, js_href=js_href))
@panel_router.get("/panel/tools/new", response_class=HTMLResponse, name="admin_tool_intake_view")
def tool_intake_page(
request: Request,
current_context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context),
) -> Response:
if current_context is None:
return _redirect_to_route(request, "admin_login_view")
settings = _resolve_settings(request)
view = _build_tool_intake_view(request, settings, current_context.principal.role)
css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css"))
js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js"))
return HTMLResponse(render_tool_intake_page(view, css_href=css_href, js_href=js_href))
@panel_router.get("/panel/tools/review", response_class=HTMLResponse, name="admin_tool_review_view")
def tool_review_page(
request: Request,
current_context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context),
) -> Response:
if current_context is None:
return _redirect_to_route(request, "admin_login_view")
settings = _resolve_settings(request)
view = _build_tool_review_view(request, settings)
css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css"))
js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js"))
return HTMLResponse(render_tool_review_page(view, css_href=css_href, js_href=js_href))
@panel_router.get("/panel/colaboradores/gestao", response_class=HTMLResponse, name="admin_collaborator_management_view")
def collaborator_management_page(
request: Request,
current_context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context),
) -> Response:
if current_context is None:
return _redirect_to_route(request, "admin_login_view")
if not role_has_permission(current_context.principal.role, AdminPermission.MANAGE_STAFF_ACCOUNTS):
return _redirect_to_route(request, "panel_home")
settings = _resolve_settings(request)
view = _build_collaborator_management_view(request, settings)
css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css"))
js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js"))
return HTMLResponse(render_collaborator_management_page(view, css_href=css_href, js_href=js_href))
@panel_router.get("/panel/sistema/configuracoes", response_class=HTMLResponse, name="admin_system_configuration_view")
def system_configuration_page(
request: Request,
current_context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context),
) -> Response:
if current_context is None:
return _redirect_to_route(request, "admin_login_view")
if not role_has_permission(current_context.principal.role, AdminPermission.VIEW_SYSTEM):
return _redirect_to_route(request, "panel_home")
settings = _resolve_settings(request)
view = _build_system_configuration_view(request, settings)
css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css"))
js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js"))
return HTMLResponse(render_system_configuration_page(view, css_href=css_href, js_href=js_href))
@panel_router.get("/panel/relatorios/vendas-arrecadacao", response_class=HTMLResponse, name="admin_sales_revenue_reports_view")
def sales_revenue_reports_page(
request: Request,
current_context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context),
) -> Response:
if current_context is None:
return _redirect_to_route(request, "admin_login_view")
if not role_has_permission(current_context.principal.role, AdminPermission.VIEW_REPORTS):
return _redirect_to_route(request, "panel_home")
settings = _resolve_settings(request)
view = _build_sales_revenue_reports_view(request, settings)
css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css"))
js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js"))
return HTMLResponse(render_sales_revenue_reports_page(view, css_href=css_href, js_href=js_href))
@panel_router.get("/panel/relatorios/locacao", response_class=HTMLResponse, name="admin_rental_reports_view")
def rental_reports_page(
request: Request,
current_context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context),
) -> Response:
if current_context is None:
return _redirect_to_route(request, "admin_login_view")
if not role_has_permission(current_context.principal.role, AdminPermission.VIEW_REPORTS):
return _redirect_to_route(request, "panel_home")
settings = _resolve_settings(request)
view = _build_rental_reports_view(request, settings)
css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css"))
js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js"))
return HTMLResponse(render_rental_reports_page(view, css_href=css_href, js_href=js_href))
@panel_router.get("/panel/monitoramento/bot", response_class=HTMLResponse, name="admin_bot_monitoring_view")
def bot_monitoring_page(
request: Request,
current_context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context),
) -> Response:
if current_context is None:
return _redirect_to_route(request, "admin_login_view")
if not role_has_permission(current_context.principal.role, AdminPermission.VIEW_REPORTS):
return _redirect_to_route(request, "panel_home")
settings = _resolve_settings(request)
view = _build_bot_monitoring_view(request, settings)
css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css"))
js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js"))
return HTMLResponse(render_bot_monitoring_page(view, css_href=css_href, js_href=js_href))
def _build_home_view(
request: Request,
settings: AdminSettings,
current_context: AuthenticatedStaffContext,
) -> AdminPanelHomeView:
panel_href = str(request.url_for("panel_home"))
tool_intake_view_href = str(request.url_for("admin_tool_intake_view"))
tool_review_view_href = str(request.url_for("admin_tool_review_view"))
collaborator_management_view_href = str(request.url_for("admin_collaborator_management_view"))
system_configuration_view_href = str(request.url_for("admin_system_configuration_view"))
sales_revenue_reports_view_href = str(request.url_for("admin_sales_revenue_reports_view"))
rental_reports_view_href = str(request.url_for("admin_rental_reports_view"))
bot_monitoring_view_href = str(request.url_for("admin_bot_monitoring_view"))
audit_href = _build_prefixed_path(settings.admin_api_prefix, "/audit/events")
can_manage_collaborators = role_has_permission(
current_context.principal.role,
AdminPermission.MANAGE_STAFF_ACCOUNTS,
)
navigation = [
AdminPanelNavigationItem(
label="Dashboard",
href=panel_href,
description="Entrada principal do ambiente interno.",
badge="Ativo",
is_active=True,
),
AdminPanelNavigationItem(
label="Cadastro de tools",
href=tool_intake_view_href,
description="Pre-cadastro validado para novas tools antes da revisao.",
badge="Novo",
),
AdminPanelNavigationItem(
label="Revisao de tools",
href=tool_review_view_href,
description="Fluxo humano de revisao, aprovacao e ativacao.",
badge="Operacao",
),
AdminPanelNavigationItem(
label="Configuracoes do sistema",
href=system_configuration_view_href,
description="Leitura funcional, runtime e governanca do admin em uma tela dedicada.",
badge="Fase 4",
),
AdminPanelNavigationItem(
label="Relatorios comerciais",
href=sales_revenue_reports_view_href,
description="Tela combinada para vendas e arrecadacao dentro da sessao do painel.",
badge="Relatorios",
),
AdminPanelNavigationItem(
label="Relatorios de locacao",
href=rental_reports_view_href,
description="Tela dedicada para frota, contratos e ocupacao na sessao do painel.",
badge="Locacao",
),
AdminPanelNavigationItem(
label="Monitoramento do bot",
href=bot_monitoring_view_href,
description="Fluxo operacional e telemetria conversacional em uma superficie dedicada.",
badge="Bot",
),
AdminPanelNavigationItem(
label="Areas do sistema",
href="#modules",
description="Mapa claro dos modulos internos disponiveis.",
badge="Painel",
),
AdminPanelNavigationItem(
label="Fluxo recomendado",
href="#workflow",
description="Sequencia sugerida para operar o admin.",
badge="Guia",
),
]
quick_actions = [
AdminPanelQuickAction(
label="Cadastrar tool",
href=tool_intake_view_href,
button_class="btn-dark",
),
AdminPanelQuickAction(
label="Revisar tools",
href=tool_review_view_href,
button_class="btn-outline-dark",
),
AdminPanelQuickAction(
label="Config. sistema",
href=system_configuration_view_href,
button_class="btn-outline-secondary",
),
AdminPanelQuickAction(
label="Relatorios",
href=sales_revenue_reports_view_href,
button_class="btn-outline-dark",
),
AdminPanelQuickAction(
label="Locacao",
href=rental_reports_view_href,
button_class="btn-outline-dark",
),
AdminPanelQuickAction(
label="Monitorar bot",
href=bot_monitoring_view_href,
button_class="btn-outline-dark",
),
]
modules = [
AdminPanelModuleCard(
eyebrow="Fluxo de entrada",
title="Cadastro de tools",
description="Tela real para o colaborador preencher metadados, definir parametros e validar o pre-cadastro antes da revisao humana.",
status_label="Tela ativa",
status_variant="success",
highlights=(
"Formulario protegido por sessao web",
"Draft persistido logo apos a validacao",
"Direcao clara para revisao de diretor",
),
cta_label="Abrir cadastro",
href=tool_intake_view_href,
is_available=True,
),
AdminPanelModuleCard(
eyebrow="Fluxo principal",
title="Revisao de tools",
description="Area operacional do painel para leitura da fila, aprovacao humana e ativacao controlada.",
status_label="Tela ativa",
status_variant="success",
highlights=(
"Fila protegida por sessao web",
"Catalogo ativo para comparacao",
"Leitura clara do workflow de aprovacao",
),
cta_label="Abrir revisao",
href=tool_review_view_href,
is_available=True,
),
AdminPanelModuleCard(
eyebrow="Acompanhamento",
title="Configuracao do sistema",
description="Tela dedicada para consultar configuracao funcional, runtime administrativo e governanca do sistema sem sair do painel.",
status_label="Tela ativa",
status_variant="primary",
highlights=(
"Catalogo funcional e governanca do bot conectados",
"Runtime e seguranca carregados por permissao",
"Separacao de modelos visivel na mesma superficie",
),
cta_label="Abrir configuracoes",
href=system_configuration_view_href,
is_available=True,
),
AdminPanelModuleCard(
eyebrow="Relatorios operacionais",
title="Relatorios de vendas e arrecadacao",
description="Tela dedicada para acompanhar o bootstrap de vendas e arrecadacao com leitura pronta para sessao web do painel.",
status_label="Tela ativa",
status_variant="info",
highlights=(
"Vendas e arrecadacao na mesma superficie",
"Leitura protegida por sessao web",
"Materializacao e proximos passos visiveis na UI",
),
cta_label="Abrir relatorios",
href=sales_revenue_reports_view_href,
is_available=True,
),
AdminPanelModuleCard(
eyebrow="Relatorios operacionais",
title="Relatorios de locacao",
description="Tela dedicada para acompanhar a estrutura inicial de frota e contratos de locacao pela sessao web do painel.",
status_label="Tela ativa",
status_variant="info",
highlights=(
"Frota e contratos lidos na mesma superficie",
"Leitura protegida por sessao web",
"Catalogo inicial e materializacao visiveis na UI",
),
cta_label="Abrir locacao",
href=rental_reports_view_href,
is_available=True,
),
AdminPanelModuleCard(
eyebrow="Monitoramento operacional",
title="Monitoramento do bot",
description="Tela dedicada para acompanhar fluxo operacional e telemetria conversacional pela sessao web do painel.",
status_label="Tela ativa",
status_variant="info",
highlights=(
"Fluxo do bot e telemetria na mesma superficie",
"Leitura protegida por sessao web",
"Materializacao e proximos passos visiveis na UI",
),
cta_label="Abrir monitoramento",
href=bot_monitoring_view_href,
is_available=True,
),
AdminPanelModuleCard(
eyebrow="Governanca",
title="Auditoria operacional",
description="Eventos de login, logout, aprovacao e publicacao continuam registrados para rastreabilidade.",
status_label="Auditavel",
status_variant="secondary",
highlights=(
"Historico de operacao interna",
"Base para filtros e timeline",
"Suporte a conformidade do fluxo administrativo",
),
),
]
surface_links = [
AdminPanelSurfaceLink(
method="Acesso",
label="Dashboard administrativa",
href=panel_href,
description="Entrada principal do time interno depois do login.",
),
AdminPanelSurfaceLink(
method="Cadastro",
label="Nova tool",
href=tool_intake_view_href,
description="Formulario real para validar o pre-cadastro de uma nova tool.",
),
AdminPanelSurfaceLink(
method="Operacao",
label="Revisao de tools",
href=tool_review_view_href,
description="Area com fila, contrato e catalogo ativo para tomada de decisao.",
),
AdminPanelSurfaceLink(
method="Sistema",
label="Configuracoes do sistema",
href=system_configuration_view_href,
description="Tela dedicada para leitura funcional, runtime e governanca administrativa.",
),
AdminPanelSurfaceLink(
method="Relatorios",
label="Vendas e arrecadacao",
href=sales_revenue_reports_view_href,
description="Tela combinada para a primeira camada visual dos relatorios comerciais da fase 4.",
),
AdminPanelSurfaceLink(
method="Relatorios",
label="Locacao",
href=rental_reports_view_href,
description="Tela dedicada para a estrutura inicial de frota, contratos e ocupacao da fase 4.",
),
AdminPanelSurfaceLink(
method="Monitoramento",
label="Bot operacional",
href=bot_monitoring_view_href,
description="Tela dedicada para fluxo operacional e telemetria conversacional do bot na fase 4.",
),
AdminPanelSurfaceLink(
method="Auditoria",
label="Eventos administrativos",
href=audit_href,
description="Consulta de eventos internos para rastrear operacoes sensiveis.",
),
]
roadmap = [
AdminPanelRoadmapItem(
step="01",
title="Entrar pelo login administrativo",
description="A sessao web libera o ambiente interno e evita navegacao confusa antes da autenticacao.",
status_label="Obrigatorio",
),
AdminPanelRoadmapItem(
step="02",
title="Passar pela dashboard",
description="A home protegida organiza os modulos e mostra por onde comecar a operacao.",
status_label="Entrada",
),
AdminPanelRoadmapItem(
step="03",
title="Cadastrar ou validar o pre-draft",
description="Use a nova tela para descrever a tool, seus parametros e o objetivo operacional antes da revisao.",
status_label="Cadastro",
),
AdminPanelRoadmapItem(
step="04",
title="Abrir revisao de tools",
description="Encaminhe a ferramenta para analise humana, aprovacao e ativacao controlada.",
status_label="Principal",
),
AdminPanelRoadmapItem(
step="05",
title="Consultar runtime e auditoria",
description="Quando necessario, acompanhe configuracao e eventos do admin para suportar a decisao operacional.",
status_label="Suporte",
),
]
if can_manage_collaborators:
navigation.insert(
3,
AdminPanelNavigationItem(
label="Colaboradores",
href=collaborator_management_view_href,
description="Cadastro e governanca de acessos internos, exclusivo para diretor.",
badge="Diretor",
),
)
quick_actions.insert(
2,
AdminPanelQuickAction(
label="Gerir equipe",
href=collaborator_management_view_href,
button_class="btn-outline-dark",
),
)
modules.insert(
2,
AdminPanelModuleCard(
eyebrow="Governanca de acesso",
title="Gestao de colaboradores",
description="Tela dedicada para o diretor criar contas internas, acompanhar o status da equipe e manter a entrada administrativa sob controle.",
status_label="Tela ativa",
status_variant="dark",
highlights=(
"Criacao de colaborador com senha inicial",
"Ativacao e desativacao sem tocar no banco manualmente",
"Fluxo exclusivo para diretor",
),
cta_label="Abrir equipe",
href=collaborator_management_view_href,
is_available=True,
),
)
surface_links.insert(
3,
AdminPanelSurfaceLink(
method="Equipe",
label="Gestao de colaboradores",
href=collaborator_management_view_href,
description="Controle de acesso interno para cadastrar e administrar a equipe administrativa.",
),
)
roadmap = [
AdminPanelRoadmapItem(
step="01",
title="Entrar pelo login administrativo",
description="A sessao web libera o ambiente interno e evita navegacao confusa antes da autenticacao.",
status_label="Obrigatorio",
),
AdminPanelRoadmapItem(
step="02",
title="Passar pela dashboard",
description="A home protegida organiza os modulos e mostra por onde comecar a operacao.",
status_label="Entrada",
),
AdminPanelRoadmapItem(
step="03",
title="Cadastrar ou validar o pre-draft",
description="Use a nova tela para descrever a tool, seus parametros e o objetivo operacional antes da revisao.",
status_label="Cadastro",
),
AdminPanelRoadmapItem(
step="04",
title="Organizar a equipe interna",
description="Diretores podem cadastrar novos colaboradores e controlar rapidamente o status de acesso administrativo.",
status_label="Diretor",
),
AdminPanelRoadmapItem(
step="05",
title="Abrir revisao de tools",
description="Encaminhe a ferramenta para analise humana, aprovacao e ativacao controlada.",
status_label="Principal",
),
AdminPanelRoadmapItem(
step="06",
title="Consultar runtime e auditoria",
description="Quando necessario, acompanhe configuracao e eventos do admin para suportar a decisao operacional.",
status_label="Suporte",
),
]
return AdminPanelHomeView(
service="orquestrador-admin",
app_name=settings.admin_app_name,
panel_title="Painel Administrativo",
panel_subtitle=(
"Area interna protegida para operar o admin com mais clareza, foco e navegacao orientada por fluxo."
),
environment=settings.admin_environment,
version=settings.admin_version,
api_prefix=settings.admin_api_prefix or "/",
release_label="Bootstrap UI v1",
navigation=tuple(navigation),
quick_actions=tuple(quick_actions),
metrics=(
AdminPanelMetric(
label="Runtimes independentes",
value="2",
description="Produto e admin seguem isolados para deploy e operacao.",
),
AdminPanelMetric(
label="Perfis internos",
value=str(len(StaffRole)),
description="Hierarquia base com colaborador e diretor.",
),
AdminPanelMetric(
label="Permissoes administrativas",
value=str(len(AdminPermission)),
description="Camada pronta para crescer por modulo sem misturar contexto.",
),
AdminPanelMetric(
label="Refresh token",
value=f"{settings.admin_auth_refresh_token_ttl_days} dias",
description="Sessao web persistida com renovacao controlada.",
),
),
modules=tuple(modules),
surface_links=tuple(surface_links),
roadmap=tuple(roadmap),
)
def _build_login_view(request: Request, settings: AdminSettings) -> AdminLoginPageView:
dashboard_href = str(request.url_for("panel_home"))
auth_endpoint = _build_prefixed_path(settings.admin_api_prefix, "/panel/auth/login")
password_requirements = []
if settings.admin_auth_password_require_uppercase:
password_requirements.append("maiuscula")
if settings.admin_auth_password_require_lowercase:
password_requirements.append("minuscula")
if settings.admin_auth_password_require_digit:
password_requirements.append("digito")
if settings.admin_auth_password_require_symbol:
password_requirements.append("simbolo")
password_policy_label = (
f"Minimo de {settings.admin_auth_password_min_length} caracteres"
+ (f" com {', '.join(password_requirements)}." if password_requirements else ".")
)
return AdminLoginPageView(
app_name=settings.admin_app_name,
title="Login administrativo",
subtitle=(
"Entre primeiro com sua conta interna. A dashboard e os modulos do sistema so aparecem depois da autenticacao."
),
environment=settings.admin_environment,
version=settings.admin_version,
dashboard_href=dashboard_href,
auth_endpoint=auth_endpoint,
email_placeholder="voce@empresa.com",
password_placeholder="Sua senha administrativa",
access_token_ttl_label=f"{settings.admin_auth_access_token_ttl_minutes} minutos",
refresh_token_ttl_label=f"{settings.admin_auth_refresh_token_ttl_days} dias",
password_policy_label=password_policy_label,
security_highlights=(
"Identidade separada do usuario de atendimento",
"Rotacao de refresh token ja implementada",
"Trilha de auditoria para login e logout",
),
integration_notes=(
"A dashboard administrativa so aparece depois da autenticacao do StaffAccount.",
"Revisao, configuracao e operacao interna ficam atras da sessao web do painel.",
"Cookies httpOnly e refresh token rotacionado mantem a sessao do navegador protegida.",
),
)
def _build_tool_intake_view(
request: Request,
settings: AdminSettings,
current_role: StaffRole | str | None,
) -> AdminToolIntakePageView:
service = ToolManagementService(settings)
form_payload = service.build_draft_form_payload(submitter_role=current_role)
return AdminToolIntakePageView(
app_name=settings.admin_app_name,
title="Cadastro de nova tool",
subtitle=(
"Formulario guiado para estruturar uma proposta de tool, registrar o draft versionado e encaminhar a triagem humana antes de qualquer geracao de codigo."
),
environment=settings.admin_environment,
version=settings.admin_version,
dashboard_href=str(request.url_for("panel_home")),
review_href=str(request.url_for("admin_tool_review_view")),
intake_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/panel/tools/drafts/intake"),
domain_options=tuple(
AdminToolIntakeDomainOption(
value=item["value"],
label=item["label"],
description=item["description"],
)
for item in form_payload["domain_options"]
),
parameter_type_options=tuple(
AdminToolIntakeParameterTypeOption(
value=item["code"].value,
label=item["label"],
description=item["description"],
)
for item in form_payload["parameter_types"]
),
naming_rules=tuple(form_payload["naming_rules"]),
submission_notes=tuple(form_payload["submission_notes"]),
approval_notes=tuple(form_payload["approval_notes"]),
)
def _build_tool_review_view(request: Request, settings: AdminSettings) -> AdminToolReviewPageView:
dashboard_href = str(request.url_for("panel_home"))
overview_endpoint = _build_prefixed_path(settings.admin_api_prefix, "/panel/tools/overview")
contracts_endpoint = _build_prefixed_path(settings.admin_api_prefix, "/panel/tools/contracts")
review_queue_endpoint = _build_prefixed_path(settings.admin_api_prefix, "/panel/tools/review-queue")
publications_endpoint = _build_prefixed_path(settings.admin_api_prefix, "/panel/tools/publications")
return AdminToolReviewPageView(
app_name=settings.admin_app_name,
title="Triagem, revisao e ativacao",
subtitle=(
"Hub visual para acompanhar a proposta da tool desde a triagem para geracao, passando pelas iteracoes de codigo, ate a ativacao controlada no catalogo do produto."
),
environment=settings.admin_environment,
version=settings.admin_version,
dashboard_href=dashboard_href,
overview_endpoint=overview_endpoint,
contracts_endpoint=contracts_endpoint,
review_queue_endpoint=review_queue_endpoint,
publications_endpoint=publications_endpoint,
workflow=(
AdminToolReviewWorkflowStep(
eyebrow="Cadastro manual",
title="Criar a proposta",
description="Receber o cadastro manual da tool e consolidar o draft administrativo sem consumir geracao de codigo.",
status_label="Draft",
status_variant="info",
),
AdminToolReviewWorkflowStep(
eyebrow="Triagem humana",
title="Triar antes de gerar",
description="Decidir se a proposta realmente merece consumir geracao de codigo ou deve ser encerrada antes disso.",
status_label="Triagem",
status_variant="warning",
),
AdminToolReviewWorkflowStep(
eyebrow="Pipeline",
title="Gerar ou refatorar",
description="Executar a geracao isolada, registrar a iteracao correspondente e validar automaticamente o codigo antes da leitura humana.",
status_label="Geracao",
status_variant="info",
),
AdminToolReviewWorkflowStep(
eyebrow="Decisao humana",
title="Ler e decidir",
description="A diretoria revisa a iteracao atual do codigo, pede ajustes quando necessario ou valida a versao para aprovacao final.",
status_label="Revisao",
status_variant="warning",
),
AdminToolReviewWorkflowStep(
eyebrow="Publicacao",
title="Ativar com governanca",
description="Publicar apenas a iteracao aprovada e sincronizar o catalogo que abastece o runtime de produto.",
status_label="Ativacao",
status_variant="success",
),
),
review_notes=(
"Conferir se a proposta esta no gate correto: triagem para geracao, ajustes solicitados ou leitura do codigo atual.",
"Observar se a descricao, o objetivo operacional e os parametros deixam claro o valor de negocio da tool.",
"Ler o codigo completo da iteracao atual antes de validar, solicitar ajustes ou encerrar a proposta.",
),
approval_notes=(
"Verificar nome, descricao, semantica dos parametros e a iteracao que esta sendo aprovada antes da ativacao.",
"Confirmar se a tool respeita a separacao entre admin e product definida nas ADRs e nos contratos compartilhados.",
"Checar se a publicacao planejada e auditavel, segura e vinculada ao codigo mais recente revisado pela diretoria.",
),
activation_notes=(
"Publicacoes ativas exigem papel com permissao publish_tools e aprovacao humana vinculada a iteracao mais recente.",
"A leitura do catalogo e feita via sessao web do painel para facilitar a operacao do navegador e a auditoria do fluxo.",
"Sem permissao de publicacao, a tela continua util para triagem e revisao, mas bloqueia a ativacao no catalogo ativo.",
),
)
def _build_sales_revenue_reports_view(
request: Request,
settings: AdminSettings,
) -> AdminSalesRevenueReportsPageView:
return AdminSalesRevenueReportsPageView(
app_name=settings.admin_app_name,
title="Relatorios de vendas e arrecadacao",
subtitle=(
"Visao unica para acompanhar vendas e arrecadacao no painel administrativo."
),
environment=settings.admin_environment,
version=settings.admin_version,
dashboard_href=str(request.url_for("panel_home")),
sales_overview_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/panel/reports/sales/overview"),
revenue_overview_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/panel/reports/arrecadacao/overview"),
access_notes=(
"A equipe interna pode consultar esta tela com a permissao de relatorios.",
"Os dados exibidos aparecem de forma consolidada para leitura segura no admin.",
"Vendas e arrecadacao ficam juntas para facilitar a rotina do time.",
),
reading_notes=(
"Comece pelos indicadores de cada bloco para ter uma leitura rapida.",
"Use os cards para entender rapidamente o foco de cada relatorio.",
"A area de proximas melhorias mostra o que entra nas etapas seguintes.",
),
)
def _build_rental_reports_view(
request: Request,
settings: AdminSettings,
) -> AdminRentalReportsPageView:
return AdminRentalReportsPageView(
app_name=settings.admin_app_name,
title="Relatorios de locacao",
subtitle=(
"Visao dedicada da locacao para acompanhar frota, contratos e receita operacional."
),
environment=settings.admin_environment,
version=settings.admin_version,
dashboard_href=str(request.url_for("panel_home")),
overview_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/panel/reports/locacao/overview"),
access_notes=(
"A equipe interna pode consultar esta tela com a permissao de relatorios.",
"Os dados exibidos aparecem de forma consolidada para leitura segura no admin.",
"Locacao fica em uma tela propria para manter a leitura mais organizada.",
),
reading_notes=(
"Comece pelos indicadores principais para entender o momento da operacao.",
"Use os cards para ver rapidamente os temas cobertos nesta etapa.",
"A area de proximas melhorias indica o que ainda entra nas proximas entregas.",
),
)
def _build_bot_monitoring_view(
request: Request,
settings: AdminSettings,
) -> AdminBotMonitoringPageView:
return AdminBotMonitoringPageView(
app_name=settings.admin_app_name,
title="Monitoramento operacional do bot",
subtitle=(
"Painel unico para acompanhar o fluxo do bot e a telemetria do atendimento."
),
environment=settings.admin_environment,
version=settings.admin_version,
dashboard_href=str(request.url_for("panel_home")),
bot_flow_overview_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/panel/reports/fluxo-bot/overview"),
telemetry_overview_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/panel/reports/telemetria-conversacional/overview"),
access_notes=(
"A equipe interna pode consultar esta tela com a permissao de relatorios.",
"Os dados exibidos aparecem de forma consolidada para leitura segura no painel.",
"Fluxo e telemetria ficam juntos para agilizar a analise da operacao.",
),
reading_notes=(
"Comece pelo fluxo do bot para localizar status, desvios e pontos de atencao.",
"Use a telemetria para acompanhar volume, latencia e saude do atendimento.",
"A area de proximas melhorias resume o que ainda entra antes dos dashboards completos.",
),
)
def _build_system_configuration_view(
request: Request,
settings: AdminSettings,
) -> AdminSystemConfigurationPageView:
return AdminSystemConfigurationPageView(
app_name=settings.admin_app_name,
title="Configuracoes do sistema",
subtitle=(
"Visao unica do catalogo funcional, das regras do atendimento e das protecoes do painel."
),
environment=settings.admin_environment,
version=settings.admin_version,
dashboard_href=str(request.url_for("panel_home")),
overview_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/system/configuration"),
runtime_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/system/configuration/runtime"),
security_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/system/configuration/security"),
model_runtimes_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/system/configuration/model-runtimes"),
functional_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/system/configuration/functional"),
functional_detail_base=_build_prefixed_path(settings.admin_api_prefix, "/system/configuration/functional"),
bot_governance_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/system/configuration/functional/bot-governance"),
access_notes=(
"A equipe interna ja consegue consultar o catalogo funcional e os ajustes do atendimento nesta tela.",
"Detalhes mais sensiveis do ambiente continuam reservados para perfis com permissao elevada.",
"Toda a superficie segue somente leitura nesta etapa.",
),
governance_notes=(
"Configuracoes do atendimento e da geracao de tools aparecem separadas para evitar confusao.",
"Os ajustes do bot continuam bloqueados para escrita direta nas tabelas operacionais.",
"O foco aqui e leitura clara do estado atual antes da futura tela de edicao.",
),
)
def _build_collaborator_management_view(
request: Request,
settings: AdminSettings,
) -> AdminCollaboratorManagementPageView:
password_requirements = []
if settings.admin_auth_password_require_uppercase:
password_requirements.append("maiuscula")
if settings.admin_auth_password_require_lowercase:
password_requirements.append("minuscula")
if settings.admin_auth_password_require_digit:
password_requirements.append("digito")
if settings.admin_auth_password_require_symbol:
password_requirements.append("simbolo")
password_policy_label = (
f"Minimo de {settings.admin_auth_password_min_length} caracteres"
+ (f" com {', '.join(password_requirements)}." if password_requirements else ".")
)
return AdminCollaboratorManagementPageView(
app_name=settings.admin_app_name,
title="Gestao de colaboradores",
subtitle=(
"Tela exclusiva de diretor para organizar a equipe interna, criar novos acessos administrativos e controlar rapidamente quem segue ativo no painel."
),
environment=settings.admin_environment,
version=settings.admin_version,
dashboard_href=str(request.url_for("panel_home")),
collection_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/panel/colaboradores"),
password_policy_label=password_policy_label,
onboarding_notes=(
"Novos acessos nascem sempre com papel de colaborador.",
"A senha inicial ja respeita a mesma politica do login administrativo.",
"Ativar ou desativar colaborador nao exige acesso direto ao banco.",
),
governance_notes=(
"Somente diretor acessa esta tela e as rotas de gestao de colaboradores.",
"Cada criacao e alteracao de status gera trilha de auditoria administrativa.",
"A conta de diretor continua fora deste fluxo para evitar mudancas acidentais na governanca principal.",
),
)
def _redirect_to_route(request: Request, route_name: str) -> RedirectResponse:
return RedirectResponse(url=str(request.url_for(route_name)), status_code=302)
def _resolve_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 _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,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, ...]

@ -10,6 +10,16 @@ class Settings(BaseSettings):
google_project_id: str
google_location: str = "us-central1"
# Runtime de atendimento do product. Mantido separado do runtime de geração
# de código do admin_app, que usa AdminSettings próprios.
atendimento_model_name: str | None = None
atendimento_bundle_model_name: str | None = None
atendimento_temperature: float = 0
atendimento_max_output_tokens: int = 768
# Aliases legados mantidos por compatibilidade enquanto o runtime de
# atendimento migra para o perfil explícito de atendimento.
vertex_model_name: str = "gemini-2.5-pro"
vertex_bundle_model_name: str = "gemini-2.5-pro"
@ -31,10 +41,10 @@ class Settings(BaseSettings):
mock_seed_enabled: bool = True
auto_seed_tools: bool = True
auto_seed_mock: bool = True
environment: str = "production"
debug: bool = False
# Cloud SQL (legacy Postgres var kept only for backward compatibility in deploy scripts)
cloud_sql_connection_name: str | None = None
@ -78,10 +88,60 @@ class Settings(BaseSettings):
@field_validator("environment", "conversation_state_backend", mode="before")
@classmethod
def normalize_text_settings(cls, value):
def normalize_runtime_text_settings(cls, value):
if isinstance(value, str):
return value.strip().lower()
return value
@field_validator("atendimento_model_name", "atendimento_bundle_model_name", mode="before")
@classmethod
def normalize_optional_model_names(cls, value):
if isinstance(value, str):
stripped = value.strip()
return stripped or None
return value
@field_validator("vertex_model_name", "vertex_bundle_model_name", mode="before")
@classmethod
def normalize_required_model_names(cls, value):
if isinstance(value, str):
return value.strip()
return value
@field_validator("atendimento_temperature")
@classmethod
def validate_atendimento_temperature(cls, value: float) -> float:
if value < 0 or value > 2:
raise ValueError("atendimento_temperature must be between 0 and 2")
return value
@field_validator("atendimento_max_output_tokens")
@classmethod
def validate_atendimento_max_output_tokens(cls, value: int) -> int:
if value < 128:
raise ValueError("atendimento_max_output_tokens must be >= 128")
return value
def resolve_atendimento_model_name(self) -> str:
configured = str(self.atendimento_model_name or "").strip()
if configured:
return configured
return str(self.vertex_model_name or "").strip()
def resolve_atendimento_bundle_model_name(self) -> str:
configured = str(self.atendimento_bundle_model_name or "").strip()
if configured:
return configured
legacy = str(self.vertex_bundle_model_name or "").strip()
if legacy:
return legacy
return self.resolve_atendimento_model_name()
def build_atendimento_generation_config(self) -> dict[str, int | float]:
return {
"temperature": float(self.atendimento_temperature),
"max_output_tokens": int(self.atendimento_max_output_tokens),
}
settings = Settings()
settings = Settings()

@ -1,8 +1,12 @@
"""
"""
Rotina dedicada de bootstrap de banco de dados.
Cria tabelas e executa seed inicial de forma explicita, fora do startup do app.
"""
import json
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import inspect, text
from app.core.settings import settings
@ -23,6 +27,38 @@ from app.db.mock_models import (
)
from app.db.mock_seed import seed_mock_data
from app.db.tool_seed import seed_tools
from shared.contracts import (
ToolRuntimePublicationManifest,
get_generated_tool_publication_manifest_path,
get_generated_tools_runtime_dir,
)
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
def _ensure_generated_tools_runtime_package() -> Path:
package_dir = get_generated_tools_runtime_dir(_PROJECT_ROOT)
package_dir.mkdir(parents=True, exist_ok=True)
init_file = package_dir / "__init__.py"
if not init_file.exists():
init_file.write_text(
"\"\"\"Isolated runtime package for admin-governed generated tools.\"\"\"\\n",
encoding="utf-8",
)
manifest_path = get_generated_tool_publication_manifest_path(_PROJECT_ROOT)
if not manifest_path.exists():
manifest = ToolRuntimePublicationManifest(
emitted_at=datetime.now(UTC),
publications=(),
)
manifest_path.write_text(
json.dumps(manifest.model_dump(mode="json"), ensure_ascii=True, indent=2, sort_keys=True),
encoding="utf-8",
)
return package_dir
def _ensure_mock_schema_evolution() -> None:
@ -56,6 +92,13 @@ def bootstrap_databases(
print("Inicializando bancos...")
failures: list[str] = []
try:
generated_tools_dir = _ensure_generated_tools_runtime_package()
print(f"Diretorio isolado de tools geradas pronto em {generated_tools_dir}.")
except Exception as exc:
print(f"Aviso: falha ao preparar diretorio isolado de tools geradas: {exc}")
failures.append(f"generated_tools={exc}")
should_seed_tools = settings.auto_seed_tools if run_tools_seed is None else bool(run_tools_seed)
should_seed_mock = (
settings.auto_seed_mock and settings.mock_seed_enabled

@ -37,11 +37,11 @@ class LLMService:
)
LLMService._vertex_initialized = True
configured = settings.vertex_model_name.strip()
configured = settings.resolve_atendimento_model_name()
fallback_models = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash-001"]
self.model_names = self._build_model_sequence(configured, *fallback_models)
self.bundle_model_names = self._build_model_sequence(
settings.vertex_bundle_model_name.strip(),
settings.resolve_atendimento_bundle_model_name(),
*self.model_names,
)
@ -304,7 +304,7 @@ class LLMService:
)
if last_error:
raise RuntimeError(
f"Nenhum modelo Vertex disponivel. Verifique VERTEX_MODEL_NAME e acesso no projeto. Erro: {last_error}"
"Nenhum modelo Vertex disponivel. Verifique ATENDIMENTO_MODEL_NAME/VERTEX_MODEL_NAME e o acesso no projeto. " f"Erro: {last_error}"
) from last_error
raise RuntimeError("Falha ao gerar resposta no Vertex AI.")

@ -1,6 +1,7 @@
import logging
import json
from app.core.settings import settings
from app.services.ai.llm_service import LLMService
from app.services.orchestration.entity_normalizer import EntityNormalizer
from app.services.orchestration.turn_decision import TurnDecision
@ -123,8 +124,7 @@ class MessagePlanner:
preferred_models = getattr(self.llm, "bundle_model_names", None)
bundle_generation_config = {
"candidate_count": 1,
"temperature": 0,
"max_output_tokens": 768,
**settings.build_atendimento_generation_config(),
}
for attempt in range(2):

@ -1,4 +1,7 @@
import importlib
import inspect
import json
import logging
from typing import Callable, Dict, List
from fastapi import HTTPException
@ -22,7 +25,15 @@ from app.services.tools.handlers import (
realizar_pedido,
validar_cliente_venda,
)
from shared.contracts import (
GENERATED_TOOL_ENTRYPOINT,
GENERATED_TOOLS_PACKAGE,
ToolParameterType,
ToolRuntimePublicationManifest,
get_generated_tool_publication_manifest_path,
)
logger = logging.getLogger(__name__)
HANDLERS: Dict[str, Callable] = {
"consultar_estoque": consultar_estoque,
@ -41,12 +52,24 @@ HANDLERS: Dict[str, Callable] = {
"registrar_pagamento_aluguel": registrar_pagamento_aluguel,
}
_PARAMETER_SCHEMA_TYPE_MAPPING = {
ToolParameterType.STRING: "string",
ToolParameterType.INTEGER: "integer",
ToolParameterType.NUMBER: "number",
ToolParameterType.BOOLEAN: "boolean",
ToolParameterType.OBJECT: "object",
ToolParameterType.ARRAY: "array",
}
class GeneratedToolCoreBoundaryViolation(RuntimeError):
"""Raised when a generated tool attempts to reuse or point at core runtime code."""
# Registry em memoria das tools disponiveis para o orquestrador.
class ToolRegistry:
"""Registry em memoria das tools disponiveis para o orquestrador."""
def __init__(self, db: Session, extra_handlers: Dict[str, Callable] | None = None):
"""Carrega tools do banco e registra apenas as que possuem handler conhecido."""
self._tools = []
available_handlers = dict(HANDLERS)
if extra_handlers:
@ -63,9 +86,89 @@ class ToolRegistry:
parameters=db_tool.parameters,
handler=handler,
)
self._load_generated_tool_publications_from_snapshot()
def register_tool(self, name, description, parameters, handler):
"""Registra uma tool em memoria para uso pelo orquestrador."""
if self._is_generated_handler(handler):
self._ensure_generated_tool_boundary(name=name, handler=handler)
self._append_tool_definition(
name=name,
description=description,
parameters=parameters,
handler=handler,
)
def register_generated_tool(self, name, description, parameters, handler):
"""Registra uma tool gerada apenas quando ela respeita o pacote isolado do runtime."""
self._ensure_generated_tool_boundary(name=name, handler=handler)
self._append_tool_definition(
name=name,
description=description,
parameters=parameters,
handler=handler,
)
def _load_generated_tool_publications_from_snapshot(self) -> None:
manifest_path = get_generated_tool_publication_manifest_path()
if not manifest_path.exists():
return
try:
manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
manifest = ToolRuntimePublicationManifest.model_validate(manifest_payload)
except Exception as exc:
logger.warning(
"Falha ao carregar snapshot local de tools publicadas em %s: %s",
manifest_path,
exc,
)
return
for envelope in manifest.publications:
published_tool = envelope.published_tool
try:
importlib.invalidate_caches()
module = importlib.import_module(published_tool.implementation_module)
handler = getattr(module, published_tool.implementation_callable)
self.register_generated_tool(
name=published_tool.tool_name,
description=published_tool.description,
parameters=self._build_generated_parameter_schema(published_tool.parameters),
handler=handler,
)
except Exception as exc:
logger.warning(
"Falha ao registrar tool publicada '%s' a partir do snapshot local %s: %s",
published_tool.tool_name,
manifest_path,
exc,
)
@staticmethod
def _build_generated_parameter_schema(parameters) -> dict:
properties: dict[str, dict] = {}
required: list[str] = []
for parameter in parameters or ():
parameter_type = parameter.parameter_type
schema = {
"type": _PARAMETER_SCHEMA_TYPE_MAPPING[parameter_type],
"description": parameter.description,
}
if parameter_type == ToolParameterType.OBJECT:
schema["additionalProperties"] = True
elif parameter_type == ToolParameterType.ARRAY:
schema["items"] = {"type": "string"}
properties[parameter.name] = schema
if parameter.required:
required.append(parameter.name)
return {
"type": "object",
"properties": properties,
"required": required,
}
def _append_tool_definition(self, *, name, description, parameters, handler):
self._tools.append(
ToolDefinition(
name=name,
@ -75,6 +178,35 @@ class ToolRegistry:
)
)
@staticmethod
def _is_generated_handler(handler: Callable) -> bool:
module_name = str(getattr(handler, "__module__", "") or "").strip()
return module_name.startswith(f"{GENERATED_TOOLS_PACKAGE}.")
def _ensure_generated_tool_boundary(self, *, name: str, handler: Callable) -> None:
normalized_name = str(name or "").strip().lower()
if normalized_name in HANDLERS:
raise GeneratedToolCoreBoundaryViolation(
f"Tool gerada '{normalized_name}' nao pode sobrescrever um handler do catalogo core."
)
if any(str(tool.name or "").strip().lower() == normalized_name for tool in self._tools):
raise GeneratedToolCoreBoundaryViolation(
f"Tool gerada '{normalized_name}' nao pode sobrescrever uma tool ja registrada no runtime."
)
module_name = str(getattr(handler, "__module__", "") or "").strip()
if not module_name.startswith(f"{GENERATED_TOOLS_PACKAGE}."):
raise GeneratedToolCoreBoundaryViolation(
f"Tools geradas so podem ser carregadas do pacote isolado '{GENERATED_TOOLS_PACKAGE}.*'."
)
handler_name = str(getattr(handler, "__name__", "") or "").strip()
if handler_name != GENERATED_TOOL_ENTRYPOINT:
raise GeneratedToolCoreBoundaryViolation(
f"Tools geradas precisam expor o entrypoint governado '{GENERATED_TOOL_ENTRYPOINT}'."
)
def get_tools(self) -> List[ToolDefinition]:
"""Retorna a lista atual de tools registradas."""
return self._tools

@ -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 @@
"""Contrato compartilhado entre servicos do monorepo."""

@ -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,133 @@
"""Contratos compartilhados entre product e admin."""
from shared.contracts.access_control import (
AdminPermission,
StaffRole,
normalize_staff_role,
permissions_for_role,
role_has_permission,
role_includes,
)
from shared.contracts.bot_governed_configuration import (
BOT_GOVERNED_SETTINGS,
BotGovernanceArea,
BotGovernanceMutability,
BotGovernedSettingContract,
get_bot_governed_setting,
)
from shared.contracts.model_runtime_separation import (
MODEL_RUNTIME_PROFILES,
MODEL_RUNTIME_SEPARATION_RULES,
ModelRuntimePurpose,
ModelRuntimeSeparationContract,
ModelRuntimeSeparationRule,
ModelRuntimeTarget,
get_model_runtime_contract,
)
from shared.contracts.product_operational_data import (
PRODUCT_OPERATIONAL_DATASETS,
OperationalConsistencyModel,
OperationalDataDomain,
OperationalDataSensitivity,
OperationalDatasetContract,
OperationalFieldContract,
OperationalFreshnessTarget,
OperationalQuerySurface,
OperationalReadGranularity,
OperationalReadModel,
OperationalStorageShape,
OperationalSyncStrategy,
get_operational_dataset,
)
from shared.contracts.system_functional_configuration import (
SYSTEM_FUNCTIONAL_CONFIGURATIONS,
FunctionalConfigurationContract,
FunctionalConfigurationDomain,
FunctionalConfigurationFieldContract,
FunctionalConfigurationMutability,
FunctionalConfigurationPropagation,
FunctionalConfigurationSource,
get_functional_configuration,
)
from shared.contracts.tool_publication import (
GENERATED_TOOL_ENTRYPOINT,
GENERATED_TOOLS_PACKAGE,
GENERATED_TOOL_PUBLICATION_MANIFEST,
PublishedToolContract,
ServiceName,
TOOL_LIFECYCLE_STAGES,
TOOL_LIFECYCLE_STATUS_SEQUENCE,
ToolLifecycleStageContract,
ToolLifecycleStatus,
ToolParameterContract,
ToolParameterType,
ToolPublicationEnvelope,
ToolRuntimePublicationManifest,
build_generated_tool_file_path,
build_generated_tool_module_name,
build_generated_tool_module_path,
get_generated_tool_publication_manifest_path,
get_generated_tools_runtime_dir,
get_tool_lifecycle_stage,
)
__all__ = [
"AdminPermission",
"BOT_GOVERNED_SETTINGS",
"GENERATED_TOOL_ENTRYPOINT",
"GENERATED_TOOLS_PACKAGE",
"GENERATED_TOOL_PUBLICATION_MANIFEST",
"MODEL_RUNTIME_PROFILES",
"MODEL_RUNTIME_SEPARATION_RULES",
"PRODUCT_OPERATIONAL_DATASETS",
"PublishedToolContract",
"SYSTEM_FUNCTIONAL_CONFIGURATIONS",
"ServiceName",
"StaffRole",
"TOOL_LIFECYCLE_STAGES",
"TOOL_LIFECYCLE_STATUS_SEQUENCE",
"ToolLifecycleStageContract",
"ToolLifecycleStatus",
"ToolParameterContract",
"ToolParameterType",
"ToolPublicationEnvelope",
"ToolRuntimePublicationManifest",
"BotGovernanceArea",
"BotGovernanceMutability",
"BotGovernedSettingContract",
"ModelRuntimePurpose",
"ModelRuntimeSeparationContract",
"ModelRuntimeSeparationRule",
"ModelRuntimeTarget",
"OperationalConsistencyModel",
"OperationalDataDomain",
"OperationalDataSensitivity",
"OperationalDatasetContract",
"OperationalFieldContract",
"OperationalFreshnessTarget",
"OperationalQuerySurface",
"OperationalReadGranularity",
"OperationalReadModel",
"OperationalStorageShape",
"OperationalSyncStrategy",
"FunctionalConfigurationContract",
"FunctionalConfigurationDomain",
"FunctionalConfigurationFieldContract",
"FunctionalConfigurationMutability",
"FunctionalConfigurationPropagation",
"FunctionalConfigurationSource",
"build_generated_tool_file_path",
"build_generated_tool_module_name",
"build_generated_tool_module_path",
"get_bot_governed_setting",
"get_functional_configuration",
"get_generated_tool_publication_manifest_path",
"get_generated_tools_runtime_dir",
"get_model_runtime_contract",
"get_operational_dataset",
"get_tool_lifecycle_stage",
"normalize_staff_role",
"permissions_for_role",
"role_has_permission",
"role_includes",
]

@ -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,192 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from pathlib import Path
import re
from pydantic import BaseModel, Field
class ServiceName(str, Enum):
PRODUCT = "product"
ADMIN = "admin"
class ToolLifecycleStatus(str, Enum):
DRAFT = "draft"
GENERATED = "generated"
VALIDATED = "validated"
APPROVED = "approved"
ACTIVE = "active"
FAILED = "failed"
ARCHIVED = "archived"
class ToolLifecycleStageContract(BaseModel):
code: ToolLifecycleStatus
label: str
description: str
order: int = Field(ge=1)
terminal: bool = False
TOOL_LIFECYCLE_STAGES: tuple[ToolLifecycleStageContract, ...] = (
ToolLifecycleStageContract(
code=ToolLifecycleStatus.DRAFT,
label="Draft",
description="Estado inicial de uma tool ainda em definicao.",
order=1,
terminal=False,
),
ToolLifecycleStageContract(
code=ToolLifecycleStatus.GENERATED,
label="Generated",
description="Implementacao gerada e pronta para analise tecnica.",
order=2,
terminal=False,
),
ToolLifecycleStageContract(
code=ToolLifecycleStatus.VALIDATED,
label="Validated",
description="Tool validada automaticamente com verificacoes basicas.",
order=3,
terminal=False,
),
ToolLifecycleStageContract(
code=ToolLifecycleStatus.APPROVED,
label="Approved",
description="Versao revisada e aprovada para publicacao controlada.",
order=4,
terminal=False,
),
ToolLifecycleStageContract(
code=ToolLifecycleStatus.ACTIVE,
label="Active",
description="Tool publicada e apta a abastecer o runtime de produto.",
order=5,
terminal=False,
),
ToolLifecycleStageContract(
code=ToolLifecycleStatus.FAILED,
label="Failed",
description="Falha registrada na geracao, validacao ou ativacao.",
order=6,
terminal=True,
),
ToolLifecycleStageContract(
code=ToolLifecycleStatus.ARCHIVED,
label="Archived",
description="Versao retirada de circulacao e mantida apenas para historico.",
order=7,
terminal=True,
),
)
TOOL_LIFECYCLE_STATUS_SEQUENCE: tuple[ToolLifecycleStatus, ...] = tuple(
stage.code for stage in TOOL_LIFECYCLE_STAGES
)
_TOOL_LIFECYCLE_STAGE_BY_STATUS = {
stage.code: stage for stage in TOOL_LIFECYCLE_STAGES
}
def get_tool_lifecycle_stage(
status: ToolLifecycleStatus | str,
) -> ToolLifecycleStageContract:
normalized_status = (
status
if isinstance(status, ToolLifecycleStatus)
else ToolLifecycleStatus(str(status or "").strip().lower())
)
return _TOOL_LIFECYCLE_STAGE_BY_STATUS[normalized_status]
GENERATED_TOOLS_PACKAGE = "generated_tools"
GENERATED_TOOL_ENTRYPOINT = "run"
GENERATED_TOOL_PUBLICATION_MANIFEST = "published_runtime_tools.json"
_GENERATED_TOOL_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{2,63}$")
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
def _normalize_generated_tool_name(tool_name: str) -> str:
normalized = str(tool_name or "").strip().lower()
if not _GENERATED_TOOL_NAME_PATTERN.match(normalized):
raise ValueError("tool_name must use lowercase snake_case to build the generated module path.")
return normalized
def build_generated_tool_module_name(tool_name: str) -> str:
normalized = _normalize_generated_tool_name(tool_name)
return f"{GENERATED_TOOLS_PACKAGE}.{normalized}"
def build_generated_tool_module_path(tool_name: str) -> str:
normalized = _normalize_generated_tool_name(tool_name)
return f"{GENERATED_TOOLS_PACKAGE}/{normalized}.py"
def get_generated_tools_runtime_dir(project_root: Path | None = None) -> Path:
root = project_root or _PROJECT_ROOT
return root / GENERATED_TOOLS_PACKAGE
def build_generated_tool_file_path(
tool_name: str,
*,
project_root: Path | None = None,
) -> Path:
normalized = _normalize_generated_tool_name(tool_name)
return get_generated_tools_runtime_dir(project_root) / f"{normalized}.py"
def get_generated_tool_publication_manifest_path(
project_root: Path | None = None,
) -> Path:
return get_generated_tools_runtime_dir(project_root) / GENERATED_TOOL_PUBLICATION_MANIFEST
class ToolParameterType(str, Enum):
STRING = "string"
INTEGER = "integer"
NUMBER = "number"
BOOLEAN = "boolean"
OBJECT = "object"
ARRAY = "array"
class ToolParameterContract(BaseModel):
name: str
parameter_type: ToolParameterType
description: str
required: bool = True
class PublishedToolContract(BaseModel):
tool_name: str
display_name: str
description: str
version: int = Field(ge=1)
status: ToolLifecycleStatus
parameters: tuple[ToolParameterContract, ...] = ()
implementation_module: str
implementation_callable: str
checksum: str | None = None
published_at: datetime | None = None
published_by: str | None = None
class ToolPublicationEnvelope(BaseModel):
source_service: ServiceName = ServiceName.ADMIN
target_service: ServiceName = ServiceName.PRODUCT
publication_id: str
published_tool: PublishedToolContract
emitted_at: datetime
class ToolRuntimePublicationManifest(BaseModel):
source_service: ServiceName = ServiceName.ADMIN
target_service: ServiceName = ServiceName.PRODUCT
emitted_at: datetime
publications: tuple[ToolPublicationEnvelope, ...] = ()

@ -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…
Cancel
Save