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:
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Improvement Panel — shows signals, patterns, and proposals in the AISidebar proactive tab.
|
||||
* Builds on existing SuggestionList pattern.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useSignals, useCollectSignals, usePatterns, useDetectPatterns, useProposals, useEvaluateProposal, useActivateProposal, useRollbackProposal, useMeasureImpact } from '@/api/improvement';
|
||||
import { TrendingUp, AlertCircle, CheckCircle, RefreshCw, Play, RotateCcw, BarChart3 } from 'lucide-react';
|
||||
|
||||
type SubView = 'signals' | 'patterns' | 'proposals';
|
||||
|
||||
export function ImprovementPanel() {
|
||||
const [subView, setSubView] = useState<SubView>('signals');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="improvement-panel">
|
||||
{/* Sub-tab selector */}
|
||||
<div className="flex gap-1 px-3 py-2 border-b border-secondary-100">
|
||||
{([
|
||||
{ key: 'signals' as const, label: 'Signale', icon: AlertCircle },
|
||||
{ key: 'patterns' as const, label: 'Muster', icon: TrendingUp },
|
||||
{ key: 'proposals' as const, label: 'Vorschläge', icon: CheckCircle },
|
||||
]).map(opt => (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => setSubView(opt.key)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors flex items-center gap-1 ${
|
||||
subView === opt.key
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
|
||||
}`}
|
||||
aria-label={opt.label}
|
||||
>
|
||||
<opt.icon className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{subView === 'signals' && <SignalsView />}
|
||||
{subView === 'patterns' && <PatternsView />}
|
||||
{subView === 'proposals' && <ProposalsView />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignalsView() {
|
||||
const { data, isLoading } = useSignals(1, 20);
|
||||
const collectMut = useCollectSignals();
|
||||
const signals = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3" data-testid="improvement-signals">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-secondary-400">{data?.total ?? 0} Signale</span>
|
||||
<button
|
||||
onClick={() => collectMut.mutate({ limit: 100 })}
|
||||
disabled={collectMut.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-50 min-h-touch"
|
||||
aria-label="Signale sammeln"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${collectMut.isPending ? 'animate-spin' : ''}`} aria-hidden="true" strokeWidth={2} />
|
||||
Sammeln
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
||||
{!isLoading && signals.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Keine Signale. Klicken Sie auf „Sammeln" um zu starten.</p>
|
||||
)}
|
||||
|
||||
{signals.map(s => (
|
||||
<div key={s.id} className="px-3 py-2 rounded-lg border border-secondary-200 bg-white hover:border-secondary-300 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
s.severity === 'error' ? 'bg-danger-500' : s.severity === 'warning' ? 'bg-warning-500' : 'bg-secondary-300'
|
||||
}`} aria-hidden="true" />
|
||||
<span className="text-xs font-medium text-secondary-700 truncate">{s.source_type}</span>
|
||||
<span className="text-xs text-secondary-400 ml-auto">{s.signal_kind}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 leading-snug">{s.summary}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-secondary-400">Confidence: {(s.confidence * 100).toFixed(0)}%</span>
|
||||
{s.created_at && <span className="text-[10px] text-secondary-400 ml-auto">{new Date(s.created_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PatternsView() {
|
||||
const { data, isLoading } = usePatterns(1, 20);
|
||||
const detectMut = useDetectPatterns();
|
||||
const patterns = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3" data-testid="improvement-patterns">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-secondary-400">{data?.total ?? 0} Muster</span>
|
||||
<button
|
||||
onClick={() => detectMut.mutate({ min_occurrences: 2 })}
|
||||
disabled={detectMut.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-50 min-h-touch"
|
||||
aria-label="Muster erkennen"
|
||||
>
|
||||
<TrendingUp className={`w-3 h-3 ${detectMut.isPending ? 'animate-pulse' : ''}`} aria-hidden="true" strokeWidth={2} />
|
||||
Erkennen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
||||
{!isLoading && patterns.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Keine Muster. Sammeln Sie zuerst Signale und klicken Sie dann auf „Erkennen".</p>
|
||||
)}
|
||||
|
||||
{patterns.map(p => (
|
||||
<div key={p.id} className="px-3 py-2 rounded-lg border border-secondary-200 bg-white hover:border-secondary-300 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-semibold text-secondary-700 truncate flex-1">{p.title}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||
p.status === 'detected' ? 'bg-blue-100 text-blue-700' :
|
||||
p.status === 'proposal_created' ? 'bg-purple-100 text-purple-700' :
|
||||
p.status === 'resolved' ? 'bg-green-100 text-green-700' :
|
||||
'bg-secondary-100 text-secondary-500'
|
||||
}`}>{p.status}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 leading-snug mb-1">{p.description}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-secondary-400">{p.occurrence_count}x</span>
|
||||
<span className="text-[10px] text-secondary-400">Confidence: {(p.confidence * 100).toFixed(0)}%</span>
|
||||
<span className="text-[10px] text-secondary-400 ml-auto">{p.target_type}</span>
|
||||
</div>
|
||||
{p.proposed_action && (
|
||||
<p className="text-[11px] text-primary-600 mt-1 leading-snug">→ {p.proposed_action}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProposalsView() {
|
||||
const { data, isLoading } = useProposals(1, 20);
|
||||
const evalMut = useEvaluateProposal();
|
||||
const activateMut = useActivateProposal();
|
||||
const rollbackMut = useRollbackProposal();
|
||||
const measureMut = useMeasureImpact();
|
||||
const proposals = data?.items ?? [];
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'bg-secondary-100 text-secondary-600',
|
||||
evaluating: 'bg-yellow-100 text-yellow-700',
|
||||
evaluated: 'bg-blue-100 text-blue-700',
|
||||
pending_approval: 'bg-orange-100 text-orange-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
active: 'bg-emerald-100 text-emerald-700',
|
||||
rolled_back: 'bg-red-100 text-red-700',
|
||||
rejected: 'bg-danger-100 text-danger-700',
|
||||
expired: 'bg-secondary-100 text-secondary-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3" data-testid="improvement-proposals">
|
||||
<span className="text-xs text-secondary-400">{data?.total ?? 0} Vorschläge</span>
|
||||
|
||||
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
||||
{!isLoading && proposals.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 text-center py-4">Keine Vorschläge.</p>
|
||||
)}
|
||||
|
||||
{proposals.map(p => (
|
||||
<div key={p.id} className="px-3 py-2 rounded-lg border border-secondary-200 bg-white hover:border-secondary-300 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-semibold text-secondary-700 truncate flex-1">{p.title}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${statusColors[p.status] ?? statusColors.draft}`}>{p.status}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-600 leading-snug mb-1">{p.description}</p>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[10px] text-secondary-400">{p.target_type}</span>
|
||||
{p.target_name && <span className="text-[10px] text-secondary-400">{p.target_name}</span>}
|
||||
{p.evaluation_score > 0 && <span className="text-[10px] text-secondary-400">Score: {(p.evaluation_score * 100).toFixed(0)}%</span>}
|
||||
</div>
|
||||
|
||||
{/* Action buttons based on status */}
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(p.status === 'draft' || p.status === 'evaluated') && (
|
||||
<button
|
||||
onClick={() => evalMut.mutate(p.id)}
|
||||
disabled={evalMut.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-blue-600 hover:bg-blue-50 disabled:opacity-50 min-h-touch"
|
||||
aria-label="Evaluieren"
|
||||
>
|
||||
<Play className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
Evaluieren
|
||||
</button>
|
||||
)}
|
||||
{(p.status === 'approved' || p.status === 'evaluated') && (
|
||||
<button
|
||||
onClick={() => activateMut.mutate(p.id)}
|
||||
disabled={activateMut.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-green-600 hover:bg-green-50 disabled:opacity-50 min-h-touch"
|
||||
aria-label="Aktivieren"
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
Aktivieren
|
||||
</button>
|
||||
)}
|
||||
{p.status === 'active' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => rollbackMut.mutate({ proposalId: p.id, reason: 'Manual rollback' })}
|
||||
disabled={rollbackMut.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-danger-600 hover:bg-danger-50 disabled:opacity-50 min-h-touch"
|
||||
aria-label="Rollback"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
Rollback
|
||||
</button>
|
||||
<button
|
||||
onClick={() => measureMut.mutate(p.id)}
|
||||
disabled={measureMut.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-purple-600 hover:bg-purple-50 disabled:opacity-50 min-h-touch"
|
||||
aria-label="Impact messen"
|
||||
>
|
||||
<BarChart3 className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||
Messen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { ChatWindow } from '@/components/ai/ChatWindow';
|
||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||
import { SuggestionList } from '@/components/ai/SuggestionSidebar';
|
||||
import { ImprovementPanel } from '@/components/ai/ImprovementPanel';
|
||||
import { createSession, fetchSessions } from '@/api/ai';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -172,7 +173,14 @@ export function AISidebar() {
|
||||
|
||||
const renderTabContent = () => {
|
||||
if (aiSidebarTab === 'proactive') {
|
||||
return <SuggestionList />;
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<SuggestionList />
|
||||
<div className="border-t border-secondary-200">
|
||||
<ImprovementPanel />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (aiSidebarTab === 'notifications') {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user