308 lines
13 KiB
Python
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
|