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

- New self_improvement plugin: models, services, routes, plugin
- 4 SQLAlchemy models: ImprovementSignal, ImprovementPattern, ImprovementProposal, ImpactMeasurement
- Migration 0132: 4 tables with RLS
- Services: collect_signals, detect_patterns, create_proposal, evaluate_proposal, request_approval, activate_proposal, rollback_proposal, measure_impact
- 11 API routes under /api/v1/improvement/
- Frontend: improvement.ts API client, ImprovementPanel.tsx in AISidebar proactive tab
- 24/24 integration tests pass
- Fix: __init__.py imports Plugin class for discover_builtins() (self_improvement + knowledge)
- Fix: automation plugin register_plugin_contributions skips when no tenant exists
This commit is contained in:
Agent Zero
2026-08-21 01:34:48 +02:00
parent 6264ed4752
commit fbcfbbced6
14 changed files with 3109 additions and 3 deletions
@@ -0,0 +1,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>
);
}
+9 -1
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { ChatWindow } from '@/components/ai/ChatWindow';
import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { SuggestionList } from '@/components/ai/SuggestionSidebar';
import { ImprovementPanel } from '@/components/ai/ImprovementPanel';
import { createSession, fetchSessions } from '@/api/ai';
import { useUIStore } from '@/store/uiStore';
import { useTranslation } from 'react-i18next';
@@ -172,7 +173,14 @@ export function AISidebar() {
const renderTabContent = () => {
if (aiSidebarTab === 'proactive') {
return <SuggestionList />;
return (
<div className="flex flex-col h-full">
<SuggestionList />
<div className="border-t border-secondary-200">
<ImprovementPanel />
</div>
</div>
);
}
if (aiSidebarTab === 'notifications') {
return (