Files
leocrm/app/plugins/builtins/self_improvement/routes.py
T

308 lines
13 KiB
Python

"""Self-improvement plugin routes — signals, patterns, proposals, evaluation, approval, activation, impact."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import require_permission
from app.plugins.builtins.self_improvement.services import (
activate_proposal,
collect_signals,
create_proposal,
detect_patterns,
evaluate_proposal,
get_proposal_detail,
list_patterns,
list_proposals,
list_signals,
measure_impact,
request_approval,
rollback_proposal,
)
router = APIRouter(prefix="/api/v1/improvement", tags=["improvement"])
class CollectSignalsRequest(BaseModel):
"""Collect improvement signals from existing system data."""
since: datetime | None = None
limit: int = Field(100, ge=1, le=500)
class DetectPatternsRequest(BaseModel):
"""Detect recurring patterns from collected signals."""
min_occurrences: int = Field(2, ge=2)
class CreateProposalRequest(BaseModel):
"""Create a new improvement proposal."""
pattern_id: uuid.UUID | None = None
title: str = Field(..., min_length=1)
description: str = ""
target_type: str = Field(..., min_length=1)
target_ref_id: uuid.UUID | None = None
target_name: str | None = None
proposed_config: dict[str, Any] = Field(default_factory=dict)
rationale: str = ""
expected_benefit: str = ""
risk_assessment: str = ""
class RequestApprovalRequest(BaseModel):
"""Request human approval for a proposal."""
approver_id: uuid.UUID | None = None
class RollbackProposalRequest(BaseModel):
"""Rollback an active proposal."""
reason: str = ""
# ──────────────────────────────────────────────────────────────────────────
# J-SIGNAL: Signal Collection
# NOTE: /signals/collect must be defined before /signals to avoid route conflicts
# ──────────────────────────────────────────────────────────────────────────
@router.post("/signals/collect")
async def collect(
body: CollectSignalsRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")),
):
"""Collect improvement signals from existing system data."""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await collect_signals(db=db, tenant_id=tenant_id, since=body.since, limit=body.limit)
await db.commit()
return result
@router.get("/signals")
async def signals(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
source_type: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")),
):
"""List improvement signals with pagination."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await list_signals(db=db, tenant_id=tenant_id, page=page, page_size=page_size, source_type=source_type)
# ──────────────────────────────────────────────────────────────────────────
# J-PATTERN: Pattern Detection
# ──────────────────────────────────────────────────────────────────────────
@router.post("/patterns/detect")
async def detect(
body: DetectPatternsRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")),
):
"""Detect recurring patterns from collected signals."""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await detect_patterns(db=db, tenant_id=tenant_id, min_occurrences=body.min_occurrences)
await db.commit()
return result
@router.get("/patterns")
async def patterns(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")),
):
"""List detected patterns with pagination."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await list_patterns(db=db, tenant_id=tenant_id, page=page, page_size=page_size)
# ──────────────────────────────────────────────────────────────────────────
# J-PROP: Improvement Proposals
# ──────────────────────────────────────────────────────────────────────────
@router.post("/proposals")
async def create(
body: CreateProposalRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:write")),
):
"""Create a new improvement proposal."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None
proposal = await create_proposal(
db=db, tenant_id=tenant_id,
pattern_id=body.pattern_id,
title=body.title,
description=body.description,
target_type=body.target_type,
target_ref_id=body.target_ref_id,
target_name=body.target_name,
proposed_config=body.proposed_config,
rationale=body.rationale,
expected_benefit=body.expected_benefit,
risk_assessment=body.risk_assessment,
user_id=user_id,
)
await db.commit()
return {"id": str(proposal.id), "status": proposal.status, "title": proposal.title}
@router.get("/proposals")
async def proposals(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")),
):
"""List improvement proposals with pagination."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await list_proposals(db=db, tenant_id=tenant_id, page=page, page_size=page_size, status=status)
@router.get("/proposals/{proposal_id}")
async def proposal_detail(
proposal_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")),
):
"""Get full proposal detail."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
pid = uuid.UUID(proposal_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
result = await get_proposal_detail(db=db, tenant_id=tenant_id, proposal_id=pid)
if "error" in result:
raise HTTPException(404, detail={"detail": result["error"], "code": "not_found"})
return result
# ──────────────────────────────────────────────────────────────────────────
# J-EVAL: Evaluation
# ──────────────────────────────────────────────────────────────────────────
@router.post("/proposals/{proposal_id}/evaluate")
async def evaluate(
proposal_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:write")),
):
"""Evaluate a proposal via LLM-based dry-run assessment."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
pid = uuid.UUID(proposal_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
result = await evaluate_proposal(db=db, tenant_id=tenant_id, proposal_id=pid)
if "error" in result:
raise HTTPException(404, detail={"detail": result["error"], "code": "not_found"})
await db.commit()
return result
# ──────────────────────────────────────────────────────────────────────────
# J-APPROVAL: Human Approval
# ──────────────────────────────────────────────────────────────────────────
@router.post("/proposals/{proposal_id}/request-approval")
async def req_approval(
proposal_id: str,
body: RequestApprovalRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:write")),
):
"""Request human approval for a proposal."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None
try:
pid = uuid.UUID(proposal_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
result = await request_approval(db=db, tenant_id=tenant_id, proposal_id=pid, requested_by=user_id, approver_id=body.approver_id)
if "error" in result:
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
await db.commit()
return result
# ──────────────────────────────────────────────────────────────────────────
# J-ACTIVATE: Controlled Activation + Rollback
# ──────────────────────────────────────────────────────────────────────────
@router.post("/proposals/{proposal_id}/activate")
async def activate(
proposal_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:admin")),
):
"""Activate an approved proposal."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) if current_user.get("user_id") else None
try:
pid = uuid.UUID(proposal_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
result = await activate_proposal(db=db, tenant_id=tenant_id, proposal_id=pid, approved_by=user_id)
if "error" in result:
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
await db.commit()
return result
@router.post("/proposals/{proposal_id}/rollback")
async def rollback(
proposal_id: str,
body: RollbackProposalRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:admin")),
):
"""Rollback an active proposal."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
pid = uuid.UUID(proposal_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
result = await rollback_proposal(db=db, tenant_id=tenant_id, proposal_id=pid, reason=body.reason)
if "error" in result:
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
await db.commit()
return result
# ──────────────────────────────────────────────────────────────────────────
# J-MEASURE: Impact Measurement
# ──────────────────────────────────────────────────────────────────────────
@router.post("/proposals/{proposal_id}/measure")
async def measure(
proposal_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("automation:read")),
):
"""Measure pre/post impact of an activated proposal."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
pid = uuid.UUID(proposal_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid proposal_id", "code": "invalid_id"}) from None
result = await measure_impact(db=db, tenant_id=tenant_id, proposal_id=pid)
if "error" in result:
raise HTTPException(400, detail={"detail": result["error"], "code": "invalid_state"})
await db.commit()
return result