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 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 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 |
|
| F-WORK (agent_workstream) | Gelöscht | Neu aufbauend auf kommunikation Plugin |
|
||||||
| G-WORK (workflow 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
|
from sqlalchemy import select as sa_select
|
||||||
|
|
||||||
# Get first tenant + admin user for seeding
|
# 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_result = await db.execute(sa_select(Tenant).limit(1))
|
||||||
tenant = tenant_result.scalar_one_or_none()
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
if tenant:
|
if tenant:
|
||||||
@@ -331,6 +332,9 @@ class AutomationPlugin(BasePlugin):
|
|||||||
tenant_result = await db.execute(select(Tenant).limit(1))
|
tenant_result = await db.execute(select(Tenant).limit(1))
|
||||||
tenant = tenant_result.scalar_one_or_none()
|
tenant = tenant_result.scalar_one_or_none()
|
||||||
default_tenant_id = tenant.id if tenant else 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
|
# Register agent definitions
|
||||||
agent_names: list[str] = []
|
agent_names: list[str] = []
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
"""Knowledge plugin — LLM-based entity/relationship extraction, ask-knowledge, review queue."""
|
"""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 { ChatWindow } from '@/components/ai/ChatWindow';
|
||||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||||
import { SuggestionList } from '@/components/ai/SuggestionSidebar';
|
import { SuggestionList } from '@/components/ai/SuggestionSidebar';
|
||||||
|
import { ImprovementPanel } from '@/components/ai/ImprovementPanel';
|
||||||
import { createSession, fetchSessions } from '@/api/ai';
|
import { createSession, fetchSessions } from '@/api/ai';
|
||||||
import { useUIStore } from '@/store/uiStore';
|
import { useUIStore } from '@/store/uiStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -172,7 +173,14 @@ export function AISidebar() {
|
|||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
if (aiSidebarTab === 'proactive') {
|
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') {
|
if (aiSidebarTab === 'notifications') {
|
||||||
return (
|
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
|
from app.plugins.builtins.wiki.models import WikiArticle, WikiArticleVersion, WikiCategory # noqa: F401
|
||||||
# Knowledge plugin models — new plugin, ensure table is created in test-DB
|
# Knowledge plugin models — new plugin, ensure table is created in test-DB
|
||||||
from app.plugins.builtins.knowledge.models import KnowledgeExtraction # noqa: F401
|
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.plugins.registry import reset_registry_for_testing # noqa: F401
|
||||||
from app.core.permission_registry import init_permission_registry # 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