feat(J): J-SIGNAL/J-PATTERN/J-PROP/J-DRAFT/J-EVAL/J-APPROVAL/J-ACTIVATE/J-MEASURE — controlled self-improvement backend (signals, patterns, proposals, drafts, evaluation, approval, activation, rollback, impact measurement), 26 tests passing
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Controlled Self-Improvement API client (Phase J).
|
||||
*
|
||||
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target
|
||||
* the self-improvement routes under `/improvement/...`.
|
||||
*/
|
||||
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from './client';
|
||||
|
||||
// ─── Types ───
|
||||
|
||||
export type ProposalType =
|
||||
| 'agent'
|
||||
| 'skill'
|
||||
| 'trigger'
|
||||
| 'workflow'
|
||||
| 'miniapp_template'
|
||||
| 'plugin_patch';
|
||||
|
||||
export type ProposalStatus =
|
||||
| 'draft'
|
||||
| 'evaluating'
|
||||
| 'pending_approval'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'active'
|
||||
| 'rolled_back'
|
||||
| 'expired';
|
||||
|
||||
export type SignalType =
|
||||
| 'agent_run'
|
||||
| 'workflow_run'
|
||||
| 'proactive_suggestion'
|
||||
| 'audit_log'
|
||||
| 'entity_history'
|
||||
| 'user_correction'
|
||||
| 'handoff'
|
||||
| 'error_retry';
|
||||
|
||||
export interface ImprovementSignal {
|
||||
id: string;
|
||||
signal_type: SignalType;
|
||||
source_ref: string;
|
||||
tenant_id: string;
|
||||
user_id?: string | null;
|
||||
timestamp: string;
|
||||
outcome: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DetectedPattern {
|
||||
id: string;
|
||||
pattern_type: string;
|
||||
description: string;
|
||||
confidence: number;
|
||||
occurrence_count: number;
|
||||
evidence_refs: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EvaluationResult {
|
||||
proposal_id?: string;
|
||||
draft_id?: string;
|
||||
evaluated_at?: string;
|
||||
test_cases: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
score: number;
|
||||
recommendation: string;
|
||||
details?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ImprovementProposal {
|
||||
id: string;
|
||||
proposal_type: ProposalType;
|
||||
title: string;
|
||||
description: string;
|
||||
rationale: string;
|
||||
expected_benefit: string;
|
||||
risk_assessment: string;
|
||||
status: ProposalStatus;
|
||||
evidence_refs: string[];
|
||||
pattern_refs: string[];
|
||||
draft_config: Record<string, unknown>;
|
||||
evaluation_result: EvaluationResult;
|
||||
measurement_before: Record<string, unknown>;
|
||||
measurement_after: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
approved_by?: string | null;
|
||||
activated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface VersionedDraft {
|
||||
id: string;
|
||||
proposal_id: string;
|
||||
version: number;
|
||||
config: Record<string, unknown>;
|
||||
previous_version_id?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProposalActionResponse {
|
||||
proposal_id: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
approval_id?: string;
|
||||
activated_at?: string;
|
||||
rolled_back_at?: string;
|
||||
rollback_available?: boolean;
|
||||
previous_version_id?: string | null;
|
||||
evaluation?: EvaluationResult;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Proposals ───
|
||||
|
||||
export const fetchProposals = (status?: ProposalStatus) =>
|
||||
apiGet<ImprovementProposal[]>('/improvement/proposals', {
|
||||
params: status ? { status } : {},
|
||||
});
|
||||
|
||||
export const fetchProposal = (id: string) =>
|
||||
apiGet<ImprovementProposal>(`/improvement/proposals/${id}`);
|
||||
|
||||
export const approveProposal = (id: string) =>
|
||||
apiPost<ProposalActionResponse>(`/improvement/proposals/${id}/approve`);
|
||||
|
||||
export const rejectProposal = (id: string) =>
|
||||
apiPost<ProposalActionResponse>(`/improvement/proposals/${id}/reject`);
|
||||
|
||||
export const rollbackProposal = (id: string) =>
|
||||
apiPost<ProposalActionResponse>(`/improvement/proposals/${id}/rollback`);
|
||||
|
||||
export const activateProposal = (id: string) =>
|
||||
apiPost<ProposalActionResponse>(`/improvement/proposals/${id}/activate`);
|
||||
|
||||
export const deleteProposal = (id: string) =>
|
||||
apiDelete<{ status: string }>(`/improvement/proposals/${id}`);
|
||||
|
||||
// ─── Patterns ───
|
||||
|
||||
export const fetchPatterns = () => apiGet<DetectedPattern[]>('/improvement/patterns');
|
||||
|
||||
// ─── Signals ───
|
||||
|
||||
export const fetchSignals = (days?: number) =>
|
||||
apiGet<ImprovementSignal[]>('/improvement/signals', {
|
||||
params: days ? { days } : {},
|
||||
});
|
||||
|
||||
// ─── Drafts ───
|
||||
|
||||
export const fetchDraft = (proposalId: string) =>
|
||||
apiGet<VersionedDraft>(`/improvement/proposals/${proposalId}/draft`);
|
||||
|
||||
export const updateDraft = (proposalId: string, config: Record<string, unknown>) =>
|
||||
apiPut<VersionedDraft>(`/improvement/proposals/${proposalId}/draft`, { config });
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TrendingUp, AlertTriangle, Lightbulb } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { DetectedPattern } from '@/api/improvement';
|
||||
|
||||
export interface PatternInsightProps {
|
||||
pattern: DetectedPattern;
|
||||
}
|
||||
|
||||
function formatConfidence(confidence: number): string {
|
||||
if (confidence === undefined || Number.isNaN(confidence)) return '—';
|
||||
return `${Math.round(confidence * 100)}%`;
|
||||
}
|
||||
|
||||
export function PatternInsight({ pattern }: PatternInsightProps) {
|
||||
const { t } = useTranslation();
|
||||
const confidence = pattern.confidence;
|
||||
const confidenceVariant =
|
||||
confidence >= 0.8 ? 'success' : confidence >= 0.6 ? 'warning' : 'secondary';
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col" data-testid={`pattern-insight-${pattern.id}`}>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<h3 className="flex items-center gap-2 text-base font-semibold text-secondary-900">
|
||||
<Lightbulb className="w-4 h-4 text-primary-500 flex-shrink-0" aria-hidden="true" />
|
||||
{t(`improvement.patternTypes.${pattern.pattern_type}`)}
|
||||
</h3>
|
||||
<Badge variant={confidenceVariant} dot>
|
||||
{t('improvement.confidence')}: {formatConfidence(confidence)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-secondary-700 mb-3">{pattern.description}</p>
|
||||
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-primary-600" aria-hidden="true" />
|
||||
<span className="text-sm text-secondary-700">
|
||||
{t('improvement.occurrences')}:{' '}
|
||||
<span className="font-semibold text-secondary-900">{pattern.occurrence_count}</span>
|
||||
</span>
|
||||
</div>
|
||||
{pattern.pattern_type === 'error_retries' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-warning-600" aria-hidden="true" />
|
||||
<span className="text-sm text-warning-700">{t('improvement.bottleneck')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pattern.evidence_refs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
|
||||
{t('improvement.evidence')}
|
||||
</h4>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{pattern.evidence_refs.map((ref) => (
|
||||
<li key={ref}>
|
||||
<code className="px-2 py-0.5 rounded bg-secondary-100 text-xs text-secondary-700">{ref}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TrendingUp, AlertTriangle, CheckCircle, XCircle, RotateCcw, Lightbulb } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { ImprovementProposal, ProposalStatus } from '@/api/improvement';
|
||||
|
||||
export interface ProposalCardProps {
|
||||
proposal: ImprovementProposal;
|
||||
onApprove?: (id: string) => void;
|
||||
onReject?: (id: string) => void;
|
||||
onRollback?: (id: string) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
const statusVariant: Record<ProposalStatus, 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'secondary'> = {
|
||||
draft: 'secondary',
|
||||
evaluating: 'info',
|
||||
pending_approval: 'warning',
|
||||
approved: 'primary',
|
||||
rejected: 'danger',
|
||||
active: 'success',
|
||||
rolled_back: 'default',
|
||||
expired: 'default',
|
||||
};
|
||||
|
||||
function formatScore(score: number | undefined): string {
|
||||
if (score === undefined || Number.isNaN(score)) return '—';
|
||||
return `${Math.round(score)}%`;
|
||||
}
|
||||
|
||||
export function ProposalCard({ proposal, onApprove, onReject, onRollback, busy = false }: ProposalCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const score = proposal.evaluation_result?.score;
|
||||
const canApprove = proposal.status === 'pending_approval' || proposal.status === 'draft' || proposal.status === 'evaluating';
|
||||
const canReject = proposal.status === 'pending_approval' || proposal.status === 'draft' || proposal.status === 'evaluating';
|
||||
const canRollback = proposal.status === 'active';
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col" data-testid={`proposal-card-${proposal.id}`}>
|
||||
<div className="flex items-center justify-between gap-2 mb-4">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold text-secondary-900">
|
||||
<Lightbulb className="w-4 h-4 text-primary-500 flex-shrink-0" aria-hidden="true" />
|
||||
{proposal.title}
|
||||
</h3>
|
||||
<Badge variant={statusVariant[proposal.status]} dot>
|
||||
{t(`improvement.status.${proposal.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-secondary-700">{proposal.description}</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
|
||||
{t('improvement.rationale')}
|
||||
</h4>
|
||||
<p className="text-sm text-secondary-700">{proposal.rationale}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
|
||||
{t('improvement.expectedBenefit')}
|
||||
</h4>
|
||||
<p className="text-sm text-secondary-700">{proposal.expected_benefit}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md bg-warning-50 border border-warning-200 p-3">
|
||||
<AlertTriangle className="w-4 h-4 text-warning-600 mt-0.5 flex-shrink-0" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-semibold text-warning-700 uppercase tracking-wide">
|
||||
{t('improvement.riskAssessment')}
|
||||
</h4>
|
||||
<p className="text-sm text-warning-800">{proposal.risk_assessment}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{proposal.evidence_refs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
|
||||
{t('improvement.evidence')}
|
||||
</h4>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{proposal.evidence_refs.map((ref) => (
|
||||
<li key={ref}>
|
||||
<code className="px-2 py-0.5 rounded bg-secondary-100 text-xs text-secondary-700">{ref}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between rounded-md bg-secondary-50 border border-secondary-200 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-primary-600" aria-hidden="true" />
|
||||
<span className="text-sm font-medium text-secondary-700">{t('improvement.score')}</span>
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-lg font-semibold',
|
||||
score !== undefined && score >= 80 ? 'text-success-600' : score !== undefined && score >= 60 ? 'text-warning-600' : 'text-secondary-600'
|
||||
)}
|
||||
>
|
||||
{formatScore(score)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-secondary-200 flex flex-wrap items-center gap-2">
|
||||
{canApprove && onApprove && (
|
||||
<Button size="sm" variant="primary" icon={<CheckCircle className="w-4 h-4" />} isLoading={busy} onClick={() => onApprove(proposal.id)}>
|
||||
{t('improvement.approve')}
|
||||
</Button>
|
||||
)}
|
||||
{canReject && onReject && (
|
||||
<Button size="sm" variant="danger" icon={<XCircle className="w-4 h-4" />} isLoading={busy} onClick={() => onReject(proposal.id)}>
|
||||
{t('improvement.reject')}
|
||||
</Button>
|
||||
)}
|
||||
{canRollback && onRollback && (
|
||||
<Button size="sm" variant="secondary" icon={<RotateCcw className="w-4 h-4" />} isLoading={busy} onClick={() => onRollback(proposal.id)}>
|
||||
{t('improvement.rollback')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user