"""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 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"]) # ────────────────────────────────────────────────────────────────────────── # J-SIGNAL: Signal Collection # NOTE: /signals/collect must be defined before /signals to avoid route conflicts # ────────────────────────────────────────────────────────────────────────── @router.post("/signals/collect") async def collect( body: dict, 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"]) since_str = body.get("since") since = None if since_str: try: since = datetime.fromisoformat(since_str) except ValueError: raise HTTPException(400, detail={"detail": "Invalid since format", "code": "invalid_date"}) from None limit = min(body.get("limit", 100), 500) result = await collect_signals(db=db, tenant_id=tenant_id, since=since, limit=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: dict, 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"]) min_occurrences = body.get("min_occurrences", 2) result = await detect_patterns(db=db, tenant_id=tenant_id, min_occurrences=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: dict, 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 pattern_id = None if body.get("pattern_id"): try: pattern_id = uuid.UUID(body["pattern_id"]) except ValueError: raise HTTPException(400, detail={"detail": "Invalid pattern_id", "code": "invalid_id"}) from None target_ref_id = None if body.get("target_ref_id"): try: target_ref_id = uuid.UUID(body["target_ref_id"]) except ValueError: raise HTTPException(400, detail={"detail": "Invalid target_ref_id", "code": "invalid_id"}) from None if not body.get("title") or not body.get("target_type"): raise HTTPException(400, detail={"detail": "title and target_type required", "code": "missing_fields"}) proposal = await create_proposal( db=db, tenant_id=tenant_id, pattern_id=pattern_id, title=body["title"], description=body.get("description", ""), target_type=body["target_type"], target_ref_id=target_ref_id, target_name=body.get("target_name"), proposed_config=body.get("proposed_config", {}), rationale=body.get("rationale", ""), expected_benefit=body.get("expected_benefit", ""), risk_assessment=body.get("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: dict, 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 approver_id = None if body.get("approver_id"): try: approver_id = uuid.UUID(body["approver_id"]) except ValueError: raise HTTPException(400, detail={"detail": "Invalid approver_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=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: dict, 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 reason = body.get("reason", "") result = await rollback_proposal(db=db, tenant_id=tenant_id, proposal_id=pid, reason=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