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