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