portal-backend-api
Create or modify FastAPI routers, endpoints, and SSE handlers for the Portal Centralizado de Datos Financieros. Use when adding endpoints (/api/auth/token, /api/users, /api/catalog/search, /api/credit
SKILL.md.claude/skills/portal-backend-api/SKILL.md
GitHubPortal Backend API Skill
Current branch: ! git branch --show-current
Existing routers: ! ls backend/app/api/ 2>/dev/null || echo "no api dir found"
Rules — NON-NEGOTIABLE
- Todo endpoint de datos exige
Security(get_current_user, scopes=[...]); 401 sin token (WWW-Authenticate: Bearer), 403 autenticado sin permiso (skillportal-auth-jwt). - Router recibe → Service procesa → Model persiste. Cero lógica de negocio en routers.
- Respuestas SIEMPRE con modelos Pydantic (
response_model=); nunca SQLModel crudo ni DataFrames serializados a mano. - Errores tipificados: métrica/dimensión inexistente → 422 con sugerencias fuzzy del catálogo;
username/emailduplicado → 409; silo caído → 503 parcial tipificado. /api/chatdevuelveStreamingResponse(media_type="text/event-stream")con eventostool_call,token,error,done; desconexión del cliente cancela la llamada LLM.- Contraseñas y prompts crudos jamás en respuestas ni logs; logging con
structlog.get_logger(), nuncaprint(). - Type hints obligatorios; docstrings Google-style en inglés.
Endpoints Canónicos
| Method | Path | Scope mínimo | Skill complementaria |
|---|---|---|---|
| GET | /healthz |
público | — |
| POST | /api/auth/token |
público (OAuth2 password) | portal-auth-jwt |
| GET/POST | /api/users |
admin |
portal-auth-jwt |
| GET/PATCH/DELETE | /api/users/{id} |
admin |
portal-auth-jwt |
| GET | /api/catalog/search |
autenticado (cualquier rol) | portal-catalog-rag |
| POST | /api/creditos |
operativo+ (puntual) / analista+ (agregación) |
portal-semantic-layer |
| POST | /api/liquidez |
operativo+ / analista+ |
portal-semantic-layer |
| POST | /api/derivados |
operativo+ / analista+ |
portal-semantic-layer |
| POST | /api/export |
analista+ |
portal-export-jobs |
| GET | /api/export/{job_id} |
analista+ (dueño del job) |
portal-export-jobs |
| POST | /api/chat SSE |
autenticado; tools heredan el Bearer | portal-auth-jwt |
Router de dominio (capa semántica)
# backend/app/api/creditos.py
from fastapi import APIRouter, Security
from app.core.auth import get_current_user
from app.models.user import UserOut
from app.models.semantic import SemanticQuery, SemanticResult
from app.services.semantic_service import SemanticService
router = APIRouter(prefix="/creditos", tags=["creditos"])
@router.post("", response_model=SemanticResult)
async def query_creditos(
body: SemanticQuery,
current_user: UserOut = Security(get_current_user, scopes=["operativo"]),
) -> SemanticResult:
"""Run a governed semantic query against the creditos silo."""
return await SemanticService.run(silo="creditos", query=body, user=current_user)
Agregaciones y cruces exigen scope superior dentro del service (no duplicar routers):
SemanticService eleva a 403 si query.is_aggregation y el rol es solo operativo.
Chat SSE con cancelacion real
# backend/app/api/chat.py
from fastapi import APIRouter, Request, Security
from fastapi.responses import StreamingResponse
from app.core.auth import get_current_user, oauth2_scheme
from app.services.chat_service import ChatService
router = APIRouter(prefix="/chat", tags=["chat"])
@router.post("", response_class=StreamingResponse)
async def chat(
request: Request,
body: ChatRequest,
current_user: UserOut = Security(get_current_user, scopes=[]),
) -> StreamingResponse:
"""Stream ADK agent events; client disconnect cancels the LLM call."""
bearer = request.headers["authorization"].removeprefix("Bearer ").strip()
async def event_stream():
async for event in ChatService.run(body=body, user=current_user, bearer=bearer):
if await request.is_disconnected():
await ChatService.cancel(body.session_id)
break
yield f"event: {event.type}\ndata: {event.model_dump_json()}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
Errores tipificados 422 (fuzzy match del catálogo)
# backend/app/services/semantic_service.py (extracto)
from app.utils.errors import UnknownMetricError
try:
plan = compile_query(query, catalog)
except UnknownMetricError as exc:
raise HTTPException(
status_code=422,
detail={
"error": "unknown_metric",
"metric": exc.metric,
"suggestions": catalog.fuzzy_match(exc.metric, top_k=3),
},
) from exc
Registro de routers
# backend/app/main.py
from app.api import auth, users, catalog, creditos, liquidez, derivados, export, chat
for r in (auth.router, users.router, catalog.router, creditos.router,
liquidez.router, derivados.router, export.router, chat.router):
app.include_router(r, prefix="/api")
Logging estructurado
import structlog
logger = structlog.get_logger()
logger.info("semantic_query", silo="creditos", user_id=current_user.id,
metric=body.metric, row_count=result.row_count)
# NUNCA loggear body completo del chat ni contraseñas; prompts solo como llm.prompt_hash