feat(J): Phase J Self-Improvement Plugin — controlled improvement loop
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
+1
-1
@@ -251,7 +251,7 @@ Phase J muss neu gebaut werden.
|
||||
|------|-------|--------|
|
||||
| Phase H Knowledge | knowledge_sources/extraction/lifecycle gelöscht | Neu aufbauend auf graph_rag + unified_search |
|
||||
| Phase I Integration | Komplett gelöscht | Neu aufbauend auf kommunikation Plugin |
|
||||
| Phase J Self-Improvement | Komplett gelöscht | Neu bauen |
|
||||
| Phase J Self-Improvement | ✅ Done | 24/24 Tests, self_improvement Plugin, Migration 0132, RLS, Frontend |
|
||||
| F-WORK (agent_workstream) | Gelöscht | Neu aufbauend auf kommunikation Plugin |
|
||||
| G-WORK (workflow workstream) | Gelöscht | Neu aufbauend auf kommunikation Plugin |
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""self-improvement tables: signals, patterns, proposals, impact measurements
|
||||
|
||||
Revision ID: 0132
|
||||
Revises: 0131
|
||||
Create Date: 2026-08-21
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "0132"
|
||||
down_revision = "0131"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. improvement_patterns (created first because signals has FK to it)
|
||||
op.create_table(
|
||||
"improvement_patterns",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("pattern_kind", sa.String(40), nullable=False),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("target_type", sa.String(30), nullable=False),
|
||||
sa.Column("target_name", sa.String(200), nullable=False, server_default=sa.text("'unknown'")),
|
||||
sa.Column("evidence_refs", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("occurrence_count", sa.Integer, nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("confidence", sa.Float, nullable=False, server_default=sa.text("0.5")),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'detected'")),
|
||||
sa.Column("proposed_action", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_impr_patterns_tenant_status", "improvement_patterns", ["tenant_id", "status"])
|
||||
op.create_index("ix_impr_patterns_tenant_target", "improvement_patterns", ["tenant_id", "target_type"])
|
||||
|
||||
# 2. improvement_signals
|
||||
op.create_table(
|
||||
"improvement_signals",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("source_type", sa.String(50), nullable=False),
|
||||
sa.Column("source_ref_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("source_metadata", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("summary", sa.Text, nullable=False),
|
||||
sa.Column("signal_kind", sa.String(30), nullable=False),
|
||||
sa.Column("severity", sa.String(20), nullable=False, server_default=sa.text("'info'")),
|
||||
sa.Column("confidence", sa.Float, nullable=False, server_default=sa.text("0.5")),
|
||||
sa.Column("pattern_id", UUID(as_uuid=True), sa.ForeignKey("improvement_patterns.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_impr_signals_tenant_kind", "improvement_signals", ["tenant_id", "signal_kind"])
|
||||
op.create_index("ix_impr_signals_tenant_source", "improvement_signals", ["tenant_id", "source_type"])
|
||||
op.create_index("ix_impr_signals_tenant_pattern", "improvement_signals", ["tenant_id", "pattern_id"])
|
||||
|
||||
# 3. improvement_proposals
|
||||
op.create_table(
|
||||
"improvement_proposals",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("pattern_id", UUID(as_uuid=True), sa.ForeignKey("improvement_patterns.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("title", sa.String(300), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("target_type", sa.String(30), nullable=False),
|
||||
sa.Column("target_ref_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("target_name", sa.String(200), nullable=True),
|
||||
sa.Column("version_number", sa.Integer, nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("proposed_config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("previous_config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("evidence_refs", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("rationale", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("expected_benefit", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("risk_assessment", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("evaluation_result", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("evaluated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("approval_request_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("approved_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rolled_back_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("rollback_reason", sa.Text, nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default=sa.text("'draft'")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_impr_proposals_tenant_status", "improvement_proposals", ["tenant_id", "status"])
|
||||
op.create_index("ix_impr_proposals_tenant_target", "improvement_proposals", ["tenant_id", "target_type"])
|
||||
op.create_index("ix_impr_proposals_tenant_pattern", "improvement_proposals", ["tenant_id", "pattern_id"])
|
||||
|
||||
# 4. improvement_impact_measurements
|
||||
op.create_table(
|
||||
"improvement_impact_measurements",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("proposal_id", UUID(as_uuid=True), sa.ForeignKey("improvement_proposals.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("pre_metrics", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("post_metrics", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("delta", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("assessment", sa.Text, nullable=False, server_default=sa.text("''")),
|
||||
sa.Column("is_positive", sa.String(20), nullable=False, server_default=sa.text("'neutral'")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_impr_impact_tenant_proposal", "improvement_impact_measurements", ["tenant_id", "proposal_id"])
|
||||
|
||||
# RLS for all 4 tables
|
||||
for table in ["improvement_patterns", "improvement_signals", "improvement_proposals", "improvement_impact_measurements"]:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute(f"CREATE POLICY {table}_tenant_isolation ON {table} USING (tenant_id::text = current_setting('app.current_tenant_id', true));")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ["improvement_impact_measurements", "improvement_proposals", "improvement_signals", "improvement_patterns"]:
|
||||
op.drop_table(table)
|
||||
@@ -248,7 +248,8 @@ class AutomationPlugin(BasePlugin):
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
# Get first tenant + admin user for seeding
|
||||
from app.models.user import User, Tenant
|
||||
from app.models.user import User
|
||||
from app.models.tenant import Tenant
|
||||
tenant_result = await db.execute(sa_select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
if tenant:
|
||||
@@ -331,6 +332,9 @@ class AutomationPlugin(BasePlugin):
|
||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
default_tenant_id = tenant.id if tenant else None
|
||||
if default_tenant_id is None:
|
||||
logger.warning("No tenant found — skipping plugin contributions registration")
|
||||
return
|
||||
|
||||
# Register agent definitions
|
||||
agent_names: list[str] = []
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
"""Knowledge plugin — LLM-based entity/relationship extraction, ask-knowledge, review queue."""
|
||||
|
||||
from app.plugins.builtins.knowledge.plugin import KnowledgePlugin
|
||||
|
||||
__all__ = ["KnowledgePlugin"]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Self-improvement plugin — controlled improvement loop on real usage signals."""
|
||||
|
||||
from app.plugins.builtins.self_improvement.plugin import SelfImprovementPlugin
|
||||
|
||||
__all__ = ["SelfImprovementPlugin"]
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Self-improvement models — signals, patterns, proposals, impact measurements.
|
||||
|
||||
All models are real SQLAlchemy models with TenantMixin (no dataclasses).
|
||||
Tables get RLS via migration.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
from app.models.owned_mixin import OwnedMixin
|
||||
|
||||
# Constants
|
||||
SIGNAL_KINDS = ("failure", "retry", "dismissal", "correction", "observation", "handoff")
|
||||
SEVERITY_LEVELS = ("info", "warning", "error")
|
||||
PATTERN_KINDS = ("retry_bottleneck", "manual_correction", "suggestion_dismissal", "repetitive_handoff")
|
||||
PATTERN_STATUSES = ("detected", "proposal_created", "resolved", "ignored")
|
||||
PROPOSAL_STATUSES = (
|
||||
"draft", "evaluating", "evaluated", "pending_approval",
|
||||
"approved", "active", "rolled_back", "rejected", "expired",
|
||||
)
|
||||
PROPOSAL_TARGET_TYPES = ("agent", "skill", "trigger", "workflow", "miniapp_template", "plugin_patch")
|
||||
|
||||
|
||||
class ImprovementSignal(Base, TenantMixin):
|
||||
"""A single improvement signal collected from real system usage.
|
||||
|
||||
References source data (AgentRun, WorkflowInstance, ProactiveSuggestion,
|
||||
AuditLog) by ID — stores no personal data copies.
|
||||
"""
|
||||
__tablename__ = "improvement_signals"
|
||||
__table_args__ = (
|
||||
Index("ix_impr_signals_tenant_kind", "tenant_id", "signal_kind"),
|
||||
Index("ix_impr_signals_tenant_source", "tenant_id", "source_type"),
|
||||
Index("ix_impr_signals_tenant_pattern", "tenant_id", "pattern_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
source_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
source_ref_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||
source_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
signal_kind: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="info")
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
pattern_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("improvement_patterns.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
|
||||
class ImprovementPattern(Base, TenantMixin):
|
||||
"""A detected pattern from grouped improvement signals."""
|
||||
__tablename__ = "improvement_patterns"
|
||||
__table_args__ = (
|
||||
Index("ix_impr_patterns_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_impr_patterns_tenant_target", "tenant_id", "target_type"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
pattern_kind: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
target_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
target_name: Mapped[str] = mapped_column(String(200), nullable=False, default="unknown")
|
||||
evidence_refs: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
occurrence_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0.5)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="detected")
|
||||
proposed_action: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ImprovementProposal(Base, TenantMixin, OwnedMixin):
|
||||
"""A versioned improvement proposal with lifecycle.
|
||||
|
||||
Status flow:
|
||||
draft -> evaluating -> evaluated -> pending_approval -> approved -> active
|
||||
-> rolled_back
|
||||
rejected <- pending_approval
|
||||
expired <- pending_approval
|
||||
"""
|
||||
__tablename__ = "improvement_proposals"
|
||||
__table_args__ = (
|
||||
Index("ix_impr_proposals_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_impr_proposals_tenant_target", "tenant_id", "target_type"),
|
||||
Index("ix_impr_proposals_tenant_pattern", "tenant_id", "pattern_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
pattern_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("improvement_patterns.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
target_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
target_ref_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||
target_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
proposed_config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
previous_config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
evidence_refs: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
rationale: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
expected_benefit: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
risk_assessment: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
evaluation_result: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
evaluated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
approval_request_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||
approved_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
rolled_back_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
rollback_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ImpactMeasurement(Base, TenantMixin):
|
||||
"""Pre/post impact measurement for an activated proposal."""
|
||||
__tablename__ = "improvement_impact_measurements"
|
||||
__table_args__ = (
|
||||
Index("ix_impr_impact_tenant_proposal", "tenant_id", "proposal_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
proposal_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("improvement_proposals.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
pre_metrics: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
post_metrics: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
delta: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
assessment: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
is_positive: Mapped[str] = mapped_column(String(20), nullable=False, default="neutral")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Self-improvement plugin — controlled improvement loop.
|
||||
|
||||
Builds on existing systems:
|
||||
- ai_proactive: ContextLog, ProactiveSuggestion (signals)
|
||||
- automation: AgentRun, AgentVersion (versioning + runs)
|
||||
- app.core.approval: ApprovalRequest (human approval)
|
||||
- app.ai.oversight: DecisionRecord (audit trail)
|
||||
- app.ai.llm_client: llm_complete (evaluation)
|
||||
- AuditLog, WorkflowInstance (usage data)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SelfImprovementPlugin(BasePlugin):
|
||||
manifest = PluginManifest(
|
||||
name="self_improvement",
|
||||
version="1.0.0",
|
||||
display_name="Self-Improvement",
|
||||
description="Controlled self-improvement loop: signals, patterns, proposals, evaluation, approval, activation, rollback, impact measurement.",
|
||||
dependencies=["permissions", "automation", "ai_proactive"],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/improvement",
|
||||
module="app.plugins.builtins.self_improvement.routes",
|
||||
router_attr="router",
|
||||
),
|
||||
],
|
||||
permissions=["improvement:read", "improvement:write", "improvement:admin"],
|
||||
)
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Register self-improvement hooks on activation."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
logger.info("Self-improvement plugin activated")
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
"""Clean up on deactivation."""
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
logger.info("Self-improvement plugin deactivated")
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* React Query hooks for Self-Improvement API.
|
||||
* Follows the pattern from automation.ts.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost } from './client';
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface ImprovementSignal {
|
||||
id: string;
|
||||
source_type: string;
|
||||
signal_kind: string;
|
||||
severity: string;
|
||||
summary: string;
|
||||
confidence: number;
|
||||
pattern_id: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface ImprovementPattern {
|
||||
id: string;
|
||||
pattern_kind: string;
|
||||
title: string;
|
||||
description: string;
|
||||
target_type: string;
|
||||
target_name: string;
|
||||
occurrence_count: number;
|
||||
confidence: number;
|
||||
status: string;
|
||||
proposed_action: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface ImprovementProposal {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
target_type: string;
|
||||
target_name: string | null;
|
||||
status: string;
|
||||
version_number: number;
|
||||
rationale: string;
|
||||
expected_benefit: string;
|
||||
risk_assessment: string;
|
||||
evaluation_score: number;
|
||||
pattern_id: string | null;
|
||||
created_at: string | null;
|
||||
activated_at: string | null;
|
||||
}
|
||||
|
||||
export interface ProposalDetail extends ImprovementProposal {
|
||||
target_ref_id: string | null;
|
||||
proposed_config: Record<string, any>;
|
||||
previous_config: Record<string, any>;
|
||||
evidence_refs: any[];
|
||||
evaluation_result: Record<string, any>;
|
||||
evaluated_at: string | null;
|
||||
approval_request_id: string | null;
|
||||
approved_by: string | null;
|
||||
approved_at: string | null;
|
||||
rolled_back_at: string | null;
|
||||
rollback_reason: string | null;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
// ── Signal Hooks ──
|
||||
|
||||
export function useSignals(page = 1, pageSize = 20, sourceType?: string) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (sourceType) params.set('source_type', sourceType);
|
||||
return useQuery({
|
||||
queryKey: ['improvement-signals', page, pageSize, sourceType],
|
||||
queryFn: () => apiGet<PaginatedResponse<ImprovementSignal>>(`/improvement/signals?${params}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCollectSignals() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { since?: string; limit?: number }) => apiPost('/improvement/signals/collect', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['improvement-signals'] });
|
||||
qc.invalidateQueries({ queryKey: ['improvement-patterns'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Pattern Hooks ──
|
||||
|
||||
export function usePatterns(page = 1, pageSize = 20) {
|
||||
return useQuery({
|
||||
queryKey: ['improvement-patterns', page, pageSize],
|
||||
queryFn: () => apiGet<PaginatedResponse<ImprovementPattern>>(`/improvement/patterns?page=${page}&page_size=${pageSize}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDetectPatterns() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { min_occurrences?: number }) => apiPost('/improvement/patterns/detect', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['improvement-patterns'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Proposal Hooks ──
|
||||
|
||||
export function useProposals(page = 1, pageSize = 20, status?: string) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (status) params.set('status', status);
|
||||
return useQuery({
|
||||
queryKey: ['improvement-proposals', page, pageSize, status],
|
||||
queryFn: () => apiGet<PaginatedResponse<ImprovementProposal>>(`/improvement/proposals?${params}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProposal(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['improvement-proposal', id],
|
||||
queryFn: () => apiGet<ProposalDetail>(`/improvement/proposals/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProposal() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: Record<string, any>) => apiPost('/improvement/proposals', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['improvement-proposals'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvaluateProposal() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (proposalId: string) => apiPost(`/improvement/proposals/${proposalId}/evaluate`, {}),
|
||||
onSuccess: (_data, proposalId) => {
|
||||
qc.invalidateQueries({ queryKey: ['improvement-proposals'] });
|
||||
qc.invalidateQueries({ queryKey: ['improvement-proposal', proposalId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestApproval() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ proposalId, body }: { proposalId: string; body: { approver_id?: string } }) =>
|
||||
apiPost(`/improvement/proposals/${proposalId}/request-approval`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['improvement-proposals'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivateProposal() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (proposalId: string) => apiPost(`/improvement/proposals/${proposalId}/activate`, {}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['improvement-proposals'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRollbackProposal() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ proposalId, reason }: { proposalId: string; reason?: string }) =>
|
||||
apiPost(`/improvement/proposals/${proposalId}/rollback`, { reason: reason || '' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['improvement-proposals'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMeasureImpact() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (proposalId: string) => apiPost(`/improvement/proposals/${proposalId}/measure`, {}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['improvement-proposals'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Improvement Panel — shows signals, patterns, and proposals in the AISidebar proactive tab.
|
||||
* Builds on existing SuggestionList pattern.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useSignals, useCollectSignals, usePatterns, useDetectPatterns, useProposals, useEvaluateProposal, useActivateProposal, useRollbackProposal, useMeasureImpact } from '@/api/improvement';
|
||||
import { TrendingUp, AlertCircle, CheckCircle, RefreshCw, Play, RotateCcw, BarChart3 } from 'lucide-react';
|
||||
|
||||
type SubView = 'signals' | 'patterns' | 'proposals';
|
||||
|
||||
export function ImprovementPanel() {
|
||||
const [subView, setSubView] = useState<SubView>('signals');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="improvement-panel">
|
||||
{/* Sub-tab selector */}
|
||||
<div className="flex gap-1 px-3 py-2 border-b border-secondary-100">
|
||||
{([
|
||||
{ key: 'signals' as const, label: 'Signale', icon: AlertCircle },
|
||||
{ key: 'patterns' as const, label: 'Muster', icon: TrendingUp },
|
||||
{ key: 'proposals' as const, label: 'Vorschläge', icon: CheckCircle },
|
||||
]).map(opt => (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => setSubView(opt.key)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors flex items-center gap-1 ${
|
||||
subView === opt.key
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
|
||||
}`}
|
||||
aria-label={opt.label}
|
||||
>
|
||||
<opt.icon className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{subView === 'signals' && <SignalsView />}
|
||||
{subView === 'patterns' && <PatternsView />}
|
||||
{subView === 'proposals' && <ProposalsView />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignalsView() {
|
||||
const { data, isLoading } = useSignals(1, 20);
|
||||
const collectMut = useCollectSignals();
|
||||
const signals = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3" data-testid="improvement-signals">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-secondary-400">{data?.total ?? 0} Signale</span>
|
||||
<button
|
||||
onClick={() => collectMut.mutate({ limit: 100 })}
|
||||
disabled={collectMut.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-50 min-h-touch"
|
||||
aria-label="Signale sammeln"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${collectMut.isPending ? 'animate-spin' : ''}`} aria-hidden="true" strokeWidth={2} />
|
||||
Sammeln
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
||||
{!isLoading && signals.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Keine Signale. Klicken Sie auf „Sammeln" um zu starten.</p>
|
||||
)}
|
||||
|
||||
{signals.map(s => (
|
||||
<div key={s.id} className="px-3 py-2 rounded-lg border border-secondary-200 bg-white hover:border-secondary-300 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
s.severity === 'error' ? 'bg-danger-500' : s.severity === 'warning' ? 'bg-warning-500' : 'bg-secondary-300'
|
||||
}`} aria-hidden="true" />
|
||||
<span className="text-xs font-medium text-secondary-700 truncate">{s.source_type}</span>
|
||||
<span className="text-xs text-secondary-400 ml-auto">{s.signal_kind}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 leading-snug">{s.summary}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-secondary-400">Confidence: {(s.confidence * 100).toFixed(0)}%</span>
|
||||
{s.created_at && <span className="text-[10px] text-secondary-400 ml-auto">{new Date(s.created_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PatternsView() {
|
||||
const { data, isLoading } = usePatterns(1, 20);
|
||||
const detectMut = useDetectPatterns();
|
||||
const patterns = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3" data-testid="improvement-patterns">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-secondary-400">{data?.total ?? 0} Muster</span>
|
||||
<button
|
||||
onClick={() => detectMut.mutate({ min_occurrences: 2 })}
|
||||
disabled={detectMut.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-50 min-h-touch"
|
||||
aria-label="Muster erkennen"
|
||||
>
|
||||
<TrendingUp className={`w-3 h-3 ${detectMut.isPending ? 'animate-pulse' : ''}`} aria-hidden="true" strokeWidth={2} />
|
||||
Erkennen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
||||
{!isLoading && patterns.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Keine Muster. Sammeln Sie zuerst Signale und klicken Sie dann auf „Erkennen".</p>
|
||||
)}
|
||||
|
||||
{patterns.map(p => (
|
||||
<div key={p.id} className="px-3 py-2 rounded-lg border border-secondary-200 bg-white hover:border-secondary-300 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-semibold text-secondary-700 truncate flex-1">{p.title}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||
p.status === 'detected' ? 'bg-blue-100 text-blue-700' :
|
||||
p.status === 'proposal_created' ? 'bg-purple-100 text-purple-700' :
|
||||
p.status === 'resolved' ? 'bg-green-100 text-green-700' :
|
||||
'bg-secondary-100 text-secondary-500'
|
||||
}`}>{p.status}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 leading-snug mb-1">{p.description}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-secondary-400">{p.occurrence_count}x</span>
|
||||
<span className="text-[10px] text-secondary-400">Confidence: {(p.confidence * 100).toFixed(0)}%</span>
|
||||
<span className="text-[10px] text-secondary-400 ml-auto">{p.target_type}</span>
|
||||
</div>
|
||||
{p.proposed_action && (
|
||||
<p className="text-[11px] text-primary-600 mt-1 leading-snug">→ {p.proposed_action}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProposalsView() {
|
||||
const { data, isLoading } = useProposals(1, 20);
|
||||
const evalMut = useEvaluateProposal();
|
||||
const activateMut = useActivateProposal();
|
||||
const rollbackMut = useRollbackProposal();
|
||||
const measureMut = useMeasureImpact();
|
||||
const proposals = data?.items ?? [];
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'bg-secondary-100 text-secondary-600',
|
||||
evaluating: 'bg-yellow-100 text-yellow-700',
|
||||
evaluated: 'bg-blue-100 text-blue-700',
|
||||
pending_approval: 'bg-orange-100 text-orange-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
active: 'bg-emerald-100 text-emerald-700',
|
||||
rolled_back: 'bg-red-100 text-red-700',
|
||||
rejected: 'bg-danger-100 text-danger-700',
|
||||
expired: 'bg-secondary-100 text-secondary-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3" data-testid="improvement-proposals">
|
||||
<span className="text-xs text-secondary-400">{data?.total ?? 0} Vorschläge</span>
|
||||
|
||||
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
||||
{!isLoading && proposals.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Keine Vorschläge.</p>
|
||||
)}
|
||||
|
||||
{proposals.map(p => (
|
||||
<div key={p.id} className="px-3 py-2 rounded-lg border border-secondary-200 bg-white hover:border-secondary-300 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-semibold text-secondary-700 truncate flex-1">{p.title}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${statusColors[p.status] ?? statusColors.draft}`}>{p.status}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 leading-snug mb-1">{p.description}</p>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] text-secondary-400">{p.target_type}</span>
|
||||
{p.target_name && <span className="text-[10px] text-secondary-400">{p.target_name}</span>}
|
||||
{p.evaluation_score > 0 && <span className="text-[10px] text-secondary-400">Score: {(p.evaluation_score * 100).toFixed(0)}%</span>}
|
||||
</div>
|
||||
|
||||
{/* Action buttons based on status */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(p.status === 'draft' || p.status === 'evaluated') && (
|
||||
<button
|
||||
onClick={() => evalMut.mutate(p.id)}
|
||||
disabled={evalMut.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-blue-600 hover:bg-blue-50 disabled:opacity-50 min-h-touch"
|
||||
aria-label="Evaluieren"
|
||||
>
|
||||
<Play className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
Evaluieren
|
||||
</button>
|
||||
)}
|
||||
{(p.status === 'approved' || p.status === 'evaluated') && (
|
||||
<button
|
||||
onClick={() => activateMut.mutate(p.id)}
|
||||
disabled={activateMut.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-green-600 hover:bg-green-50 disabled:opacity-50 min-h-touch"
|
||||
aria-label="Aktivieren"
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
Aktivieren
|
||||
</button>
|
||||
)}
|
||||
{p.status === 'active' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => rollbackMut.mutate({ proposalId: p.id, reason: 'Manual rollback' })}
|
||||
disabled={rollbackMut.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-danger-600 hover:bg-danger-50 disabled:opacity-50 min-h-touch"
|
||||
aria-label="Rollback"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
Rollback
|
||||
</button>
|
||||
<button
|
||||
onClick={() => measureMut.mutate(p.id)}
|
||||
disabled={measureMut.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-purple-600 hover:bg-purple-50 disabled:opacity-50 min-h-touch"
|
||||
aria-label="Impact messen"
|
||||
>
|
||||
<BarChart3 className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
Messen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { ChatWindow } from '@/components/ai/ChatWindow';
|
||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||
import { SuggestionList } from '@/components/ai/SuggestionSidebar';
|
||||
import { ImprovementPanel } from '@/components/ai/ImprovementPanel';
|
||||
import { createSession, fetchSessions } from '@/api/ai';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -172,7 +173,14 @@ export function AISidebar() {
|
||||
|
||||
const renderTabContent = () => {
|
||||
if (aiSidebarTab === 'proactive') {
|
||||
return <SuggestionList />;
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<SuggestionList />
|
||||
<div className="border-t border-secondary-200">
|
||||
<ImprovementPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (aiSidebarTab === 'notifications') {
|
||||
return (
|
||||
|
||||
@@ -82,6 +82,13 @@ from app.models.plugin_allowlist import PluginAllowlist # noqa: F401
|
||||
from app.plugins.builtins.wiki.models import WikiArticle, WikiArticleVersion, WikiCategory # noqa: F401
|
||||
# Knowledge plugin models — new plugin, ensure table is created in test-DB
|
||||
from app.plugins.builtins.knowledge.models import KnowledgeExtraction # noqa: F401
|
||||
# Self-improvement plugin models — new plugin, ensure tables are created in test-DB
|
||||
from app.plugins.builtins.self_improvement.models import ( # noqa: F401
|
||||
ImprovementSignal,
|
||||
ImprovementPattern,
|
||||
ImprovementProposal,
|
||||
ImpactMeasurement,
|
||||
)
|
||||
|
||||
from app.plugins.registry import reset_registry_for_testing # noqa: F401
|
||||
from app.core.permission_registry import init_permission_registry # noqa: F401
|
||||
|
||||
@@ -0,0 +1,976 @@
|
||||
"""Phase J Self-Improvement Plugin Tests — integration tests with real DB.
|
||||
|
||||
Covers the controlled self-improvement loop:
|
||||
- Signal collection from AgentRun / ProactiveSuggestion / AuditLog
|
||||
- Pattern detection from grouped signals
|
||||
- Proposal creation (draft) and evaluation (LLM mocked)
|
||||
- Activation + rollback of agent config
|
||||
- Tenant isolation
|
||||
- API routes under /api/v1/improvement/
|
||||
|
||||
Uses the real PostgreSQL test DB (conftest fixtures) and mocks only the
|
||||
LLM (llm_complete) for deterministic evaluation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
|
||||
from app.core.db import close_engine, reset_engine_for_testing
|
||||
from app.core.permission_registry import init_permission_registry
|
||||
from app.core.service_container import get_container
|
||||
from app.main import create_app
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User
|
||||
from app.models.workflow import WorkflowInstance
|
||||
from app.plugins.builtins.ai_proactive.models import ProactiveSuggestion
|
||||
from app.plugins.builtins.automation.models import AgentDefinition, AgentRun, AgentRunStep, AgentVersion
|
||||
from app.plugins.builtins.self_improvement.models import (
|
||||
ImpactMeasurement,
|
||||
ImprovementPattern,
|
||||
ImprovementProposal,
|
||||
ImprovementSignal,
|
||||
)
|
||||
from app.plugins.builtins.self_improvement.services import (
|
||||
activate_proposal,
|
||||
collect_signals,
|
||||
create_proposal,
|
||||
detect_patterns,
|
||||
evaluate_proposal,
|
||||
measure_impact,
|
||||
request_approval,
|
||||
rollback_proposal,
|
||||
)
|
||||
from app.plugins.registry import reset_registry_for_testing
|
||||
from app.services.plugin_service import reset_plugin_service_for_testing
|
||||
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||
async def _ensure_improvement_tables(engine: AsyncEngine):
|
||||
"""Create the self_improvement tables if they don't exist yet.
|
||||
|
||||
conftest.db_setup only runs Base.metadata.create_all when the test DB is
|
||||
empty. Since the DB already has tables, new plugin tables (self_improvement)
|
||||
are never created. This fixture runs create_all (idempotent, checkfirst=True)
|
||||
so the improvement_* tables exist before any test runs.
|
||||
"""
|
||||
from app.core.db import Base
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Shared seed fixture (service-level tests)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed(db_session):
|
||||
"""Create tenant + admin user for service-level tests."""
|
||||
tenant = Tenant(name="Test Tenant", slug="test-tenant")
|
||||
db_session.add(tenant)
|
||||
await db_session.flush()
|
||||
admin = User(
|
||||
email="admin@test.local",
|
||||
name="Admin",
|
||||
password_hash="$2b$12$placeholder",
|
||||
is_active=True,
|
||||
is_system_admin=True,
|
||||
preferences={},
|
||||
)
|
||||
db_session.add(admin)
|
||||
await db_session.flush()
|
||||
return {"tenant": tenant, "admin": admin}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _init_perms():
|
||||
"""Ensure permission registry is initialized for every test."""
|
||||
init_permission_registry(
|
||||
active_plugin_names={
|
||||
"permissions",
|
||||
"automation",
|
||||
"ai_proactive",
|
||||
"self_improvement",
|
||||
}
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# API fixtures (self_improvement plugin active)
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def improvement_app(engine: AsyncEngine, redis_client):
|
||||
"""FastAPI app with self_improvement + dependencies registered and active."""
|
||||
reset_engine_for_testing(engine)
|
||||
app = create_app()
|
||||
registry = reset_registry_for_testing()
|
||||
registry.initialize(engine, app)
|
||||
init_permission_registry(
|
||||
active_plugin_names={
|
||||
"permissions",
|
||||
"automation",
|
||||
"ai_assistant",
|
||||
"unified_search",
|
||||
"kommunikation",
|
||||
"dms",
|
||||
"ai_proactive",
|
||||
"self_improvement",
|
||||
}
|
||||
)
|
||||
container = get_container()
|
||||
await container.initialize()
|
||||
|
||||
from app.plugins.builtins.permissions.plugin import PermissionsPlugin
|
||||
from app.plugins.builtins.automation.plugin import AutomationPlugin
|
||||
from app.plugins.builtins.ai_assistant.plugin import AIAssistantPlugin
|
||||
from app.plugins.builtins.unified_search.plugin import UnifiedSearchPlugin
|
||||
from app.plugins.builtins.kommunikation.plugin import KommunikationPlugin
|
||||
from app.plugins.builtins.dms.plugin import DmsPlugin
|
||||
from app.plugins.builtins.ai_proactive.plugin import AIProactivePlugin
|
||||
from app.plugins.builtins.self_improvement.plugin import SelfImprovementPlugin
|
||||
|
||||
registry.register_plugin(PermissionsPlugin())
|
||||
registry.register_plugin(AutomationPlugin())
|
||||
registry.register_plugin(AIAssistantPlugin())
|
||||
registry.register_plugin(UnifiedSearchPlugin())
|
||||
registry.register_plugin(KommunikationPlugin())
|
||||
registry.register_plugin(DmsPlugin())
|
||||
registry.register_plugin(AIProactivePlugin())
|
||||
registry.register_plugin(SelfImprovementPlugin())
|
||||
reset_plugin_service_for_testing(registry)
|
||||
|
||||
sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||||
async with sf() as session:
|
||||
await registry.install(session, "permissions")
|
||||
await registry.activate(session, "permissions")
|
||||
await registry.install(session, "dms")
|
||||
await registry.activate(session, "dms")
|
||||
await registry.install(session, "kommunikation")
|
||||
await registry.activate(session, "kommunikation")
|
||||
await registry.install(session, "automation")
|
||||
await registry.activate(session, "automation")
|
||||
await registry.install(session, "unified_search")
|
||||
await registry.activate(session, "unified_search")
|
||||
await registry.install(session, "ai_assistant")
|
||||
await registry.activate(session, "ai_assistant")
|
||||
await registry.install(session, "ai_proactive")
|
||||
await registry.activate(session, "ai_proactive")
|
||||
await registry.install(session, "self_improvement")
|
||||
await registry.activate(session, "self_improvement")
|
||||
await session.commit()
|
||||
|
||||
yield app
|
||||
await close_engine()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def improvement_client(improvement_app) -> AsyncClient:
|
||||
"""HTTP test client with self_improvement plugin active."""
|
||||
transport = ASGITransport(app=improvement_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def improvement_authed_client(
|
||||
improvement_client: AsyncClient, db_session: AsyncSession
|
||||
) -> tuple[AsyncClient, dict]:
|
||||
"""Authenticated admin client with seeded data and self_improvement active."""
|
||||
seed = await seed_tenant_and_users(db_session)
|
||||
# Grant is_system_admin so require_permission(automation:*) passes
|
||||
await db_session.execute(
|
||||
update(User).where(User.id == seed["admin_a"].id).values(is_system_admin=True)
|
||||
)
|
||||
await db_session.commit()
|
||||
await login_client(improvement_client, "admin@tenanta.com")
|
||||
return improvement_client, seed
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mock_llm_eval(
|
||||
content: str = (
|
||||
'{"score": 0.8, "assessment": "Sound proposal", '
|
||||
'"risks_identified": ["minor"], "recommendation": "approve", '
|
||||
'"test_scenarios": ["run once"]}'
|
||||
)
|
||||
):
|
||||
"""Return an AsyncMock for llm_complete returning a JSON evaluation."""
|
||||
return AsyncMock(return_value={"content": content, "cost_usd": 0.001})
|
||||
|
||||
|
||||
async def _create_agent(db, tenant_id, user_id, **overrides):
|
||||
"""Create a real AgentDefinition in the DB and return it."""
|
||||
agent = AgentDefinition(
|
||||
tenant_id=tenant_id,
|
||||
name=overrides.get("name", "Test Agent"),
|
||||
description=overrides.get("description", "A test agent"),
|
||||
system_prompt=overrides.get("system_prompt", "You are a helpful assistant."),
|
||||
tool_ids=overrides.get("tool_ids", []),
|
||||
temperature=overrides.get("temperature", 0.3),
|
||||
max_tokens=overrides.get("max_tokens", 1000),
|
||||
max_steps=overrides.get("max_steps", 20),
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
return agent
|
||||
|
||||
|
||||
async def _create_failed_run(db, tenant_id, agent_id, status="failed"):
|
||||
"""Create a real AgentRun with a failure status."""
|
||||
run = AgentRun(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
status=status,
|
||||
started_at=datetime.now(UTC),
|
||||
duration_seconds=5.0,
|
||||
error="boom",
|
||||
)
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
return run
|
||||
|
||||
|
||||
async def _create_dismissed_suggestion(db, tenant_id, user_id):
|
||||
"""Create a real dismissed ProactiveSuggestion."""
|
||||
sug = ProactiveSuggestion(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
entity_type="contact",
|
||||
suggestion_type="follow_up",
|
||||
title="Follow up",
|
||||
content="Consider following up",
|
||||
confidence=0.7,
|
||||
is_dismissed=True,
|
||||
)
|
||||
db.add(sug)
|
||||
await db.flush()
|
||||
return sug
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# J-SIGNAL: Signal Collection
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSignalCollection:
|
||||
async def test_signal_collection_from_agent_run(self, db_session, seed):
|
||||
"""collect_signals creates ImprovementSignals from failed AgentRuns."""
|
||||
agent = await _create_agent(db_session, seed["tenant"].id, seed["admin"].id)
|
||||
run = await _create_failed_run(db_session, seed["tenant"].id, agent.id, status="failed")
|
||||
await db_session.flush()
|
||||
|
||||
result = await collect_signals(
|
||||
db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1)
|
||||
)
|
||||
|
||||
assert result["collected"] >= 1
|
||||
kinds = {s["signal_kind"] for s in result["signals"]}
|
||||
assert "failure" in kinds
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
select(ImprovementSignal).where(
|
||||
ImprovementSignal.tenant_id == seed["tenant"].id,
|
||||
ImprovementSignal.source_type == "agent_run",
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows) >= 1
|
||||
assert rows[0].signal_kind == "failure"
|
||||
assert rows[0].severity == "error"
|
||||
assert rows[0].source_ref_id == run.id
|
||||
|
||||
async def test_signal_collection_from_proactive_suggestion(self, db_session, seed):
|
||||
"""collect_signals creates signals from dismissed ProactiveSuggestions."""
|
||||
await _create_dismissed_suggestion(db_session, seed["tenant"].id, seed["admin"].id)
|
||||
await db_session.flush()
|
||||
|
||||
result = await collect_signals(
|
||||
db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1)
|
||||
)
|
||||
|
||||
kinds = {s["signal_kind"] for s in result["signals"]}
|
||||
assert "dismissal" in kinds
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
select(ImprovementSignal).where(
|
||||
ImprovementSignal.tenant_id == seed["tenant"].id,
|
||||
ImprovementSignal.source_type == "proactive_suggestion",
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows) >= 1
|
||||
assert rows[0].signal_kind == "dismissal"
|
||||
|
||||
async def test_signal_collection_from_audit_log(self, db_session, seed):
|
||||
"""collect_signals creates correction signals from repeated audit updates."""
|
||||
for _ in range(3):
|
||||
db_session.add(
|
||||
AuditLog(
|
||||
tenant_id=seed["tenant"].id,
|
||||
user_id=seed["admin"].id,
|
||||
action="update",
|
||||
entity_type="contact",
|
||||
entity_id=uuid.uuid4(),
|
||||
changes={"name": "x"},
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
result = await collect_signals(
|
||||
db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1)
|
||||
)
|
||||
|
||||
kinds = {s["signal_kind"] for s in result["signals"]}
|
||||
assert "correction" in kinds
|
||||
|
||||
async def test_signal_collection_workflow(self, db_session, seed):
|
||||
"""collect_signals creates signals from failed WorkflowInstances."""
|
||||
from app.models.workflow import Workflow
|
||||
|
||||
wf = Workflow(
|
||||
tenant_id=seed["tenant"].id,
|
||||
name="Test WF",
|
||||
description="",
|
||||
steps=[],
|
||||
is_active=True,
|
||||
created_by=seed["admin"].id,
|
||||
)
|
||||
db_session.add(wf)
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
WorkflowInstance(
|
||||
tenant_id=seed["tenant"].id,
|
||||
workflow_id=wf.id,
|
||||
status="failed",
|
||||
initiated_by=seed["admin"].id,
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
result = await collect_signals(
|
||||
db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1)
|
||||
)
|
||||
|
||||
kinds = {s["signal_kind"] for s in result["signals"]}
|
||||
assert "failure" in kinds
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# J-PATTERN: Pattern Detection
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPatternDetection:
|
||||
async def test_detect_patterns_groups_signals(self, db_session, seed):
|
||||
"""detect_patterns groups signals and creates an ImprovementPattern."""
|
||||
agent = await _create_agent(db_session, seed["tenant"].id, seed["admin"].id)
|
||||
for _ in range(2):
|
||||
await _create_failed_run(db_session, seed["tenant"].id, agent.id, status="failed")
|
||||
await db_session.flush()
|
||||
|
||||
await collect_signals(
|
||||
db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1)
|
||||
)
|
||||
result = await detect_patterns(db=db_session, tenant_id=seed["tenant"].id, min_occurrences=2)
|
||||
|
||||
assert result["patterns_created"] >= 1
|
||||
pattern = result["patterns"][0]
|
||||
assert pattern["pattern_kind"] == "retry_bottleneck"
|
||||
assert pattern["target_type"] == "agent"
|
||||
assert pattern["occurrence_count"] >= 2
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
select(ImprovementPattern).where(
|
||||
ImprovementPattern.tenant_id == seed["tenant"].id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows) >= 1
|
||||
assert rows[0].status == "detected"
|
||||
|
||||
async def test_detect_patterns_links_signals(self, db_session, seed):
|
||||
"""detect_patterns links signals to the created pattern."""
|
||||
agent = await _create_agent(db_session, seed["tenant"].id, seed["admin"].id)
|
||||
for _ in range(2):
|
||||
await _create_failed_run(db_session, seed["tenant"].id, agent.id, status="failed")
|
||||
await db_session.flush()
|
||||
|
||||
await collect_signals(
|
||||
db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1)
|
||||
)
|
||||
await detect_patterns(db=db_session, tenant_id=seed["tenant"].id, min_occurrences=2)
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
select(ImprovementSignal).where(
|
||||
ImprovementSignal.tenant_id == seed["tenant"].id,
|
||||
ImprovementSignal.pattern_id.is_not(None),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows) >= 2
|
||||
|
||||
async def test_detect_patterns_no_signals(self, db_session, seed):
|
||||
"""detect_patterns returns empty when no signals exist."""
|
||||
result = await detect_patterns(db=db_session, tenant_id=seed["tenant"].id)
|
||||
assert result["patterns_created"] == 0
|
||||
assert result["patterns"] == []
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# J-PROP: Proposal Creation
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestProposalCreation:
|
||||
async def test_create_proposal_draft(self, db_session, seed):
|
||||
"""create_proposal creates an ImprovementProposal in draft status."""
|
||||
proposal = await create_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
title="Improve agent prompt",
|
||||
description="Tune the system prompt",
|
||||
target_type="agent",
|
||||
target_name="Test Agent",
|
||||
proposed_config={"temperature": 0.1},
|
||||
rationale="Reduce failures",
|
||||
expected_benefit="Fewer retries",
|
||||
risk_assessment="Low",
|
||||
user_id=seed["admin"].id,
|
||||
)
|
||||
|
||||
assert proposal.status == "draft"
|
||||
assert proposal.tenant_id == seed["tenant"].id
|
||||
assert proposal.owner_id == seed["admin"].id
|
||||
assert proposal.proposed_config == {"temperature": 0.1}
|
||||
|
||||
row = (
|
||||
await db_session.execute(
|
||||
select(ImprovementProposal).where(ImprovementProposal.id == proposal.id)
|
||||
)
|
||||
).scalar_one()
|
||||
assert row.status == "draft"
|
||||
|
||||
async def test_create_proposal_captures_previous_config(self, db_session, seed):
|
||||
"""create_proposal captures previous agent config for rollback."""
|
||||
agent = await _create_agent(
|
||||
db_session, seed["tenant"].id, seed["admin"].id, temperature=0.3
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
proposal = await create_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
title="Tune temp",
|
||||
description="Lower temperature",
|
||||
target_type="agent",
|
||||
target_ref_id=agent.id,
|
||||
target_name=agent.name,
|
||||
proposed_config={"temperature": 0.1},
|
||||
user_id=seed["admin"].id,
|
||||
)
|
||||
|
||||
assert proposal.previous_config.get("temperature") == 0.3
|
||||
assert proposal.previous_config.get("system_prompt") == agent.system_prompt
|
||||
|
||||
async def test_create_proposal_links_pattern(self, db_session, seed):
|
||||
"""create_proposal links to a pattern and updates its status."""
|
||||
agent = await _create_agent(db_session, seed["tenant"].id, seed["admin"].id)
|
||||
for _ in range(2):
|
||||
await _create_failed_run(db_session, seed["tenant"].id, agent.id, status="failed")
|
||||
await db_session.flush()
|
||||
await collect_signals(
|
||||
db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1)
|
||||
)
|
||||
pat_result = await detect_patterns(db=db_session, tenant_id=seed["tenant"].id, min_occurrences=2)
|
||||
pattern_id = uuid.UUID(pat_result["patterns"][0]["id"])
|
||||
|
||||
proposal = await create_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
pattern_id=pattern_id,
|
||||
title="Fix agent",
|
||||
description="Fix the agent",
|
||||
target_type="agent",
|
||||
target_ref_id=agent.id,
|
||||
target_name=agent.name,
|
||||
proposed_config={"max_steps": 30},
|
||||
user_id=seed["admin"].id,
|
||||
)
|
||||
|
||||
assert proposal.pattern_id == pattern_id
|
||||
assert len(proposal.evidence_refs) >= 1
|
||||
pattern = (
|
||||
await db_session.execute(
|
||||
select(ImprovementPattern).where(ImprovementPattern.id == pattern_id)
|
||||
)
|
||||
).scalar_one()
|
||||
assert pattern.status == "proposal_created"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# J-EVAL: Evaluation
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestProposalEvaluation:
|
||||
async def test_evaluate_proposal_sets_evaluated(self, db_session, seed):
|
||||
"""evaluate_proposal sets status to evaluated with mocked LLM."""
|
||||
proposal = await create_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
title="Evaluate me",
|
||||
description="Assess this change",
|
||||
target_type="agent",
|
||||
target_name="Test Agent",
|
||||
proposed_config={"temperature": 0.1},
|
||||
rationale="Reduce failures",
|
||||
expected_benefit="Fewer retries",
|
||||
risk_assessment="Low",
|
||||
user_id=seed["admin"].id,
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.self_improvement.services.llm_complete",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"content": json.dumps(
|
||||
{
|
||||
"score": 0.8,
|
||||
"assessment": "Sound",
|
||||
"risks_identified": [],
|
||||
"recommendation": "approve",
|
||||
"test_scenarios": ["run"],
|
||||
}
|
||||
),
|
||||
"cost_usd": 0.001,
|
||||
},
|
||||
):
|
||||
result = await evaluate_proposal(
|
||||
db=db_session, tenant_id=seed["tenant"].id, proposal_id=proposal.id
|
||||
)
|
||||
|
||||
assert result["status"] == "evaluated"
|
||||
assert result["evaluation"]["score"] == 0.8
|
||||
assert result["evaluation"]["recommendation"] == "approve"
|
||||
|
||||
await db_session.refresh(proposal)
|
||||
assert proposal.status == "evaluated"
|
||||
assert proposal.evaluated_at is not None
|
||||
assert proposal.evaluation_result["score"] == 0.8
|
||||
|
||||
async def test_evaluate_proposal_nonexistent(self, db_session, seed):
|
||||
"""evaluate_proposal returns error for nonexistent proposal."""
|
||||
result = await evaluate_proposal(
|
||||
db=db_session, tenant_id=seed["tenant"].id, proposal_id=uuid.uuid4()
|
||||
)
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# J-ACTIVATE: Activation + Rollback
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestActivationAndRollback:
|
||||
async def test_activate_proposal_applies_config(self, db_session, seed):
|
||||
"""activate_proposal applies proposed config to the agent."""
|
||||
agent = await _create_agent(
|
||||
db_session, seed["tenant"].id, seed["admin"].id, temperature=0.3, max_steps=20
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
proposal = await create_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
title="Tune agent",
|
||||
description="Lower temperature",
|
||||
target_type="agent",
|
||||
target_ref_id=agent.id,
|
||||
target_name=agent.name,
|
||||
proposed_config={"temperature": 0.1, "max_steps": 30},
|
||||
user_id=seed["admin"].id,
|
||||
)
|
||||
proposal.status = "approved"
|
||||
await db_session.flush()
|
||||
|
||||
result = await activate_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
proposal_id=proposal.id,
|
||||
approved_by=seed["admin"].id,
|
||||
)
|
||||
|
||||
assert result["status"] == "active"
|
||||
assert result["applied"] is True
|
||||
|
||||
await db_session.refresh(agent)
|
||||
assert agent.temperature == 0.1
|
||||
assert agent.max_steps == 30
|
||||
|
||||
# A version snapshot should have been created
|
||||
versions = (
|
||||
await db_session.execute(
|
||||
select(AgentVersion).where(
|
||||
AgentVersion.tenant_id == seed["tenant"].id,
|
||||
AgentVersion.agent_id == agent.id,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(versions) >= 1
|
||||
assert versions[0].snapshot["temperature"] == 0.3
|
||||
|
||||
async def test_rollback_proposal_restores_config(self, db_session, seed):
|
||||
"""rollback_proposal restores the previous agent config."""
|
||||
agent = await _create_agent(
|
||||
db_session, seed["tenant"].id, seed["admin"].id, temperature=0.3, max_steps=20
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
proposal = await create_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
title="Tune agent",
|
||||
description="Lower temperature",
|
||||
target_type="agent",
|
||||
target_ref_id=agent.id,
|
||||
target_name=agent.name,
|
||||
proposed_config={"temperature": 0.1},
|
||||
user_id=seed["admin"].id,
|
||||
)
|
||||
proposal.status = "approved"
|
||||
await db_session.flush()
|
||||
await activate_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
proposal_id=proposal.id,
|
||||
approved_by=seed["admin"].id,
|
||||
)
|
||||
await db_session.refresh(agent)
|
||||
assert agent.temperature == 0.1
|
||||
|
||||
result = await rollback_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
proposal_id=proposal.id,
|
||||
reason="Regression",
|
||||
)
|
||||
|
||||
assert result["status"] == "rolled_back"
|
||||
assert result["restored"] is True
|
||||
|
||||
await db_session.refresh(agent)
|
||||
assert agent.temperature == 0.3
|
||||
await db_session.refresh(proposal)
|
||||
assert proposal.rollback_reason == "Regression"
|
||||
|
||||
async def test_rollback_requires_active(self, db_session, seed):
|
||||
"""rollback_proposal rejects a non-active proposal."""
|
||||
proposal = await create_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
title="Draft",
|
||||
description="Not active",
|
||||
target_type="agent",
|
||||
target_name="x",
|
||||
user_id=seed["admin"].id,
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
result = await rollback_proposal(
|
||||
db=db_session, tenant_id=seed["tenant"].id, proposal_id=proposal.id
|
||||
)
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# J-MEASURE: Impact Measurement
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestImpactMeasurement:
|
||||
async def test_measure_impact_creates_measurement(self, db_session, seed):
|
||||
"""measure_impact creates an ImpactMeasurement for an active proposal."""
|
||||
agent = await _create_agent(db_session, seed["tenant"].id, seed["admin"].id)
|
||||
await db_session.flush()
|
||||
|
||||
proposal = await create_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
title="Measure",
|
||||
description="Measure impact",
|
||||
target_type="agent",
|
||||
target_ref_id=agent.id,
|
||||
target_name=agent.name,
|
||||
proposed_config={"temperature": 0.1},
|
||||
user_id=seed["admin"].id,
|
||||
)
|
||||
proposal.status = "approved"
|
||||
await db_session.flush()
|
||||
await activate_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
proposal_id=proposal.id,
|
||||
approved_by=seed["admin"].id,
|
||||
)
|
||||
|
||||
result = await measure_impact(
|
||||
db=db_session, tenant_id=seed["tenant"].id, proposal_id=proposal.id
|
||||
)
|
||||
|
||||
assert "measurement_id" in result
|
||||
assert "pre_metrics" in result
|
||||
assert "post_metrics" in result
|
||||
assert "delta" in result
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
select(ImpactMeasurement).where(
|
||||
ImpactMeasurement.tenant_id == seed["tenant"].id,
|
||||
ImpactMeasurement.proposal_id == proposal.id,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# J-ISOLATION: Tenant Isolation
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTenantIsolation:
|
||||
async def test_signals_are_tenant_isolated(self, db_session, seed):
|
||||
"""Signals created for one tenant are not visible to another."""
|
||||
tenant_b = Tenant(name="Tenant B", slug="tenant-b")
|
||||
db_session.add(tenant_b)
|
||||
await db_session.flush()
|
||||
|
||||
agent = await _create_agent(db_session, seed["tenant"].id, seed["admin"].id)
|
||||
await _create_failed_run(db_session, seed["tenant"].id, agent.id, status="failed")
|
||||
await db_session.flush()
|
||||
await collect_signals(
|
||||
db=db_session, tenant_id=seed["tenant"].id, since=datetime.now(UTC) - timedelta(days=1)
|
||||
)
|
||||
|
||||
# Tenant B sees no signals
|
||||
result_b = await collect_signals(
|
||||
db=db_session, tenant_id=tenant_b.id, since=datetime.now(UTC) - timedelta(days=1)
|
||||
)
|
||||
assert result_b["collected"] == 0
|
||||
|
||||
rows_b = (
|
||||
await db_session.execute(
|
||||
select(ImprovementSignal).where(ImprovementSignal.tenant_id == tenant_b.id)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows_b) == 0
|
||||
|
||||
async def test_proposals_are_tenant_isolated(self, db_session, seed):
|
||||
"""Proposals created for one tenant are not visible to another."""
|
||||
tenant_b = Tenant(name="Tenant B", slug="tenant-b")
|
||||
db_session.add(tenant_b)
|
||||
await db_session.flush()
|
||||
|
||||
await create_proposal(
|
||||
db=db_session,
|
||||
tenant_id=seed["tenant"].id,
|
||||
title="Tenant A proposal",
|
||||
description="Only for A",
|
||||
target_type="agent",
|
||||
target_name="x",
|
||||
user_id=seed["admin"].id,
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
rows_b = (
|
||||
await db_session.execute(
|
||||
select(ImprovementProposal).where(ImprovementProposal.tenant_id == tenant_b.id)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows_b) == 0
|
||||
|
||||
rows_a = (
|
||||
await db_session.execute(
|
||||
select(ImprovementProposal).where(
|
||||
ImprovementProposal.tenant_id == seed["tenant"].id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows_a) == 1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# J-API: API Routes
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApiRoutes:
|
||||
async def test_api_signal_collect(self, improvement_authed_client):
|
||||
"""POST /api/v1/improvement/signals/collect works."""
|
||||
client, seed = improvement_authed_client
|
||||
resp = await client.post(
|
||||
"/api/v1/improvement/signals/collect",
|
||||
json={"limit": 10},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert "collected" in data
|
||||
assert "signals" in data
|
||||
|
||||
async def test_api_proposal_create(self, improvement_authed_client):
|
||||
"""POST /api/v1/improvement/proposals works."""
|
||||
client, seed = improvement_authed_client
|
||||
resp = await client.post(
|
||||
"/api/v1/improvement/proposals",
|
||||
json={
|
||||
"title": "API proposal",
|
||||
"description": "Created via API",
|
||||
"target_type": "agent",
|
||||
"target_name": "Test Agent",
|
||||
"proposed_config": {"temperature": 0.1},
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["status"] == "draft"
|
||||
assert data["title"] == "API proposal"
|
||||
assert "id" in data
|
||||
|
||||
async def test_api_proposal_create_missing_fields(self, improvement_authed_client):
|
||||
"""POST /api/v1/improvement/proposals rejects missing title/target_type."""
|
||||
client, _ = improvement_authed_client
|
||||
resp = await client.post(
|
||||
"/api/v1/improvement/proposals",
|
||||
json={"description": "no title"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
async def test_api_proposal_list(self, improvement_authed_client):
|
||||
"""GET /api/v1/improvement/proposals lists proposals."""
|
||||
client, _ = improvement_authed_client
|
||||
resp = await client.get("/api/v1/improvement/proposals", headers=ORIGIN_HEADER)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
|
||||
async def test_api_proposal_evaluate(self, improvement_authed_client):
|
||||
"""POST /api/v1/improvement/proposals/{id}/evaluate works with mocked LLM."""
|
||||
client, seed = improvement_authed_client
|
||||
create_resp = await client.post(
|
||||
"/api/v1/improvement/proposals",
|
||||
json={
|
||||
"title": "Evaluate via API",
|
||||
"description": "Assess",
|
||||
"target_type": "agent",
|
||||
"target_name": "Test Agent",
|
||||
"proposed_config": {"temperature": 0.1},
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert create_resp.status_code == 200, create_resp.text
|
||||
proposal_id = create_resp.json()["id"]
|
||||
|
||||
with patch(
|
||||
"app.plugins.builtins.self_improvement.services.llm_complete",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"content": json.dumps(
|
||||
{
|
||||
"score": 0.7,
|
||||
"assessment": "OK",
|
||||
"risks_identified": [],
|
||||
"recommendation": "approve",
|
||||
"test_scenarios": [],
|
||||
}
|
||||
),
|
||||
"cost_usd": 0.001,
|
||||
},
|
||||
):
|
||||
resp = await client.post(
|
||||
f"/api/v1/improvement/proposals/{proposal_id}/evaluate",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "evaluated"
|
||||
|
||||
async def test_api_proposal_activate_and_rollback(self, improvement_authed_client, db_session):
|
||||
"""POST activate + rollback via API works end-to-end."""
|
||||
client, seed = improvement_authed_client
|
||||
agent = await _create_agent(db_session, seed["tenant_a"].id, seed["admin_a"].id)
|
||||
await db_session.commit()
|
||||
|
||||
create_resp = await client.post(
|
||||
"/api/v1/improvement/proposals",
|
||||
json={
|
||||
"title": "Activate via API",
|
||||
"description": "Apply config",
|
||||
"target_type": "agent",
|
||||
"target_ref_id": str(agent.id),
|
||||
"target_name": agent.name,
|
||||
"proposed_config": {"temperature": 0.1},
|
||||
},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert create_resp.status_code == 200, create_resp.text
|
||||
proposal_id = create_resp.json()["id"]
|
||||
|
||||
# Set to approved directly (approval flow is covered by service tests)
|
||||
await db_session.execute(
|
||||
update(ImprovementProposal)
|
||||
.where(ImprovementProposal.id == uuid.UUID(proposal_id))
|
||||
.values(status="approved")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
act_resp = await client.post(
|
||||
f"/api/v1/improvement/proposals/{proposal_id}/activate",
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert act_resp.status_code == 200, act_resp.text
|
||||
assert act_resp.json()["status"] == "active"
|
||||
|
||||
await db_session.refresh(agent)
|
||||
assert agent.temperature == 0.1
|
||||
|
||||
roll_resp = await client.post(
|
||||
f"/api/v1/improvement/proposals/{proposal_id}/rollback",
|
||||
json={"reason": "test"},
|
||||
headers=ORIGIN_HEADER,
|
||||
)
|
||||
assert roll_resp.status_code == 200, roll_resp.text
|
||||
assert roll_resp.json()["status"] == "rolled_back"
|
||||
|
||||
await db_session.refresh(agent)
|
||||
assert agent.temperature == 0.3
|
||||
Reference in New Issue
Block a user