feat(J): Phase J Self-Improvement Plugin — controlled improvement loop
Check Cross-Plugin Imports / check (push) Has been cancelled

- New self_improvement plugin: models, services, routes, plugin
- 4 SQLAlchemy models: ImprovementSignal, ImprovementPattern, ImprovementProposal, ImpactMeasurement
- Migration 0132: 4 tables with RLS
- Services: collect_signals, detect_patterns, create_proposal, evaluate_proposal, request_approval, activate_proposal, rollback_proposal, measure_impact
- 11 API routes under /api/v1/improvement/
- Frontend: improvement.ts API client, ImprovementPanel.tsx in AISidebar proactive tab
- 24/24 integration tests pass
- Fix: __init__.py imports Plugin class for discover_builtins() (self_improvement + knowledge)
- Fix: automation plugin register_plugin_contributions skips when no tenant exists
This commit is contained in:
Agent Zero
2026-08-21 01:34:48 +02:00
parent 6264ed4752
commit fbcfbbced6
14 changed files with 3109 additions and 3 deletions
@@ -0,0 +1,299 @@
"""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