Files
ALwrity/frontend/src/components/TeamActivity/CommitteeSummary.tsx
ajaysi 923fa671fe feat: ContentGuardianAgent, onboarding UX, Team Activity action wiring, docs, agent help modal
ContentGuardianAgent consolidation:
- Merge 3 duplicate classes into single source in specialized/content_guardian.py
- Watchdog audit_committee() with heuristic scoring, coverage gaps, overlaps, alerts
- Remove misleading rejection_rate() helper; use acceptance_rate directly
- Integrate audit + alerts + trend signals into today_workflow_service.py

Team Activity page:
- QualityAuditPanel: health ring, per-agent critiques, coverage gaps, overlaps
- TrendSignalsPanel: opportunity cards with urgency/impact/coverage bars
- AlertBanner: persistent dismiss via POST /alerts/{id}/mark-read
- AgentHelpModal: dialog showing all 8 agents with descriptions, tools, schedule
- QualityAuditPanel action buttons: Fill gap -> /content-planning, Resolve overlap, View CTA on alerts/issues
- TrendSignalsPanel action buttons: Create content from this trend -> /blog-writer with trend context state

Onboarding system:
- Step 4 validation: no auto-pass via basic_ready; requires persona data or explicit progression
- Step 5 validation: logs warning on auto-pass without integration data
- OnboardingCompletionService: single DB session, transactional task creation, upsert pattern
- Business-without-website: nullable website_url on SIFIndexingTask and MarketTrendsTask
- DeepCompetitorAnalysisExecutor: 5-min timeout, 10-competitor cap, asyncio.wait_for
- Persona generation: async with 30s timeout, falls back to scheduler
- OnboardingProgressService.reset_onboarding(): resets session + pauses all DB tasks
- OnboardingControlService.reset_onboarding(): also cancels APScheduler jobs
- FinalStep TaskSchedulingPanel: shows scheduled/failed tasks after completion, 8s auto-redirect
- onboarding_completed agent activity event logged to feed

Documentation:
- docs-site/features/onboarding/: overview, steps, scheduler-tasks, technical-reference (4 pages)
- docs-site/mkdocs.yml: added Onboarding System nav section
- docs-site/features/sif-agents/: overview, agent-directory, committee-system, content-guardian (4 pages)
- docs-site/features/team-activity/: overview, quality-audit, trend-signals, alert-system (4 pages)
- docs-site/features/todays-workflow/: updated overview, technical-architecture, workflow-guide, api-reference
2026-06-01 12:24:31 +05:30

494 lines
20 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import {
Box,
Typography,
Chip,
LinearProgress,
Collapse,
Button,
} from '@mui/material';
import {
CheckCircle as CheckCircleIcon,
WarningAmber as WarningAmberIcon,
Error as ErrorIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
ArrowForward as ArrowForwardIcon,
InfoOutlined as InfoIcon,
} from '@mui/icons-material';
import { AgentEventItem } from '../../hooks/useAgentHuddleFeed';
interface CommitteeProposal {
agent: string;
title: string;
pillar_id: string;
priority: string;
valid: boolean;
accepted: boolean;
reasoning?: string;
rejected_reason?: string | null;
estimated_time?: number;
action_type?: string;
}
interface CommitteePayload {
agents_polled: number;
total_proposals: number;
accepted_count: number;
rejected_count: number;
proposals: CommitteeProposal[];
}
const PILLAR_ORDER = ['plan', 'generate', 'publish', 'analyze', 'engage', 'remarket'];
const PILLAR_INFO: Record<string, { label: string; short: string; desc: string }> = {
plan: { label: 'Plan', short: 'Plan', desc: 'Strategy & planning' },
generate: { label: 'Generate', short: 'Create', desc: 'Content creation' },
publish: { label: 'Publish', short: 'Pub.', desc: 'Publishing & scheduling' },
analyze: { label: 'Analyze', short: 'Audit', desc: 'Performance review' },
engage: { label: 'Engage', short: 'Share', desc: 'Social engagement' },
remarket: { label: 'Remarket', short: 'ReMkt', desc: 'Repurpose & promote' },
};
// ─── Status Banner ──────────────────────────────────
const statusMeta = (accepted: number, total: number) => {
const pct = total > 0 ? accepted / total : 0;
if (pct >= 0.8) return { color: '#4caf50', bg: 'rgba(76,175,80,0.12)', icon: <CheckCircleIcon sx={{ fontSize: 20, color: '#4caf50' }} />, text: 'All systems good' };
if (pct >= 0.5) return { color: '#ff9800', bg: 'rgba(255,152,0,0.12)', icon: <WarningAmberIcon sx={{ fontSize: 20, color: '#ff9800' }} />, text: 'Needs review' };
return { color: '#f44336', bg: 'rgba(244,67,54,0.12)', icon: <ErrorIcon sx={{ fontSize: 20, color: '#f44336' }} />, text: 'Attention needed' };
};
const StatusBanner: React.FC<{ accepted: number; total: number; agents: number }> = ({ accepted, total, agents }) => {
const meta = statusMeta(accepted, total);
const pct = total > 0 ? Math.round(accepted / total * 100) : 0;
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 1.5,
py: 1,
borderRadius: 2,
bgcolor: meta.bg,
mb: 1.5,
}}
>
{meta.icon}
<Typography variant="body2" sx={{ color: meta.color, fontWeight: 600, flex: 1 }}>
{meta.text} <Box component="span" sx={{ fontWeight: 400 }}>{accepted} of {total} proposals adopted from {agents} areas</Box>
</Typography>
<Typography variant="h6" sx={{ color: meta.color, fontWeight: 800, fontSize: '1.1rem' }}>
{pct}%
</Typography>
</Box>
);
};
// ─── Adoption Bar ───────────────────────────────────
const AdoptionBar: React.FC<{ accepted: number; total: number }> = ({ accepted, total }) => {
const pct = total > 0 ? accepted / total * 100 : 0;
const color = pct >= 80 ? '#4caf50' : pct >= 50 ? '#ff9800' : '#f44336';
return (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.5 }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
Adoption
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.6)', fontWeight: 600 }}>
Adopted <Box component="span" sx={{ color }}>{accepted}</Box> of {total} proposals
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={pct}
sx={{
height: 8,
borderRadius: 4,
bgcolor: 'rgba(255,255,255,0.08)',
'& .MuiLinearProgress-bar': {
background: `linear-gradient(90deg, rgba(102,126,234,0.8), ${color})`,
borderRadius: 4,
},
}}
/>
</Box>
);
};
// ─── Coverage Flow ──────────────────────────────────
const coverageHealth = (count: number): { color: string; label: string; dot: string } => {
if (count >= 3) return { color: '#4caf50', label: 'covered', dot: '●' };
if (count >= 1) return { color: '#ff9800', label: 'light', dot: '◕' };
return { color: '#f44336', label: 'missing', dot: '○' };
};
const CoverageFlow: React.FC<{ proposals: CommitteeProposal[] }> = ({ proposals }) => {
const counts = PILLAR_ORDER.map((p) => ({
...PILLAR_INFO[p],
key: p,
count: proposals.filter((pr) => pr.pillar_id === p).length,
}));
return (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', mb: 1, display: 'block', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
Today's Coverage
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0, flexWrap: 'nowrap', overflow: 'auto', pb: 0.5 }}>
{counts.map((p, i) => {
const health = coverageHealth(p.count);
return (
<React.Fragment key={p.key}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 0.25,
minWidth: 56,
py: 1,
px: 0.75,
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.04)',
border: `1px solid ${health.color}22`,
}}
>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontWeight: 700, fontSize: 11, lineHeight: 1.2 }}>
{p.short}
</Typography>
<Typography variant="h6" sx={{ color: 'rgba(255,255,255,0.9)', fontWeight: 800, fontSize: '1rem', lineHeight: 1.2 }}>
{p.count}
</Typography>
<Typography variant="caption" sx={{ color: health.color, fontSize: 9, fontWeight: 600 }}>
{health.label}
</Typography>
</Box>
{i < counts.length - 1 && (
<ArrowForwardIcon sx={{ mx: 0.25, color: 'rgba(255,255,255,0.15)', fontSize: 16, flexShrink: 0 }} />
)}
</React.Fragment>
);
})}
</Box>
</Box>
);
};
// ─── Rejected List (redesigned) ─────────────────────
const plainReason = (p: CommitteeProposal): string => {
if (p.rejected_reason) return p.rejected_reason;
if (!p.valid) return `"${p.pillar_id}" isn't a valid workflow phase this is a system configuration issue.`;
return 'This suggestion was similar to an existing task or had lower priority.';
};
const actionForProposal = (p: CommitteeProposal): { label: string; icon?: React.ReactNode } | null => {
const title = p.title.toLowerCase();
if (title.includes('twitter') || title.includes('tweet')) {
return { label: 'Connect Twitter' };
}
if (title.includes('linkedin')) {
return { label: 'Connect LinkedIn' };
}
if (title.includes('facebook') || title.includes('instagram')) {
return { label: 'Connect Social' };
}
return null;
};
const RejectedList: React.FC<{ proposals: CommitteeProposal[] }> = ({ proposals }) => {
const rejected = proposals.filter((p) => !p.accepted);
const [open, setOpen] = useState(false);
if (rejected.length === 0) return null;
return (
<Box sx={{ pt: 1.5, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<Box
onClick={() => setOpen(!open)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
py: 0.5,
borderRadius: 1,
'&:hover': { bgcolor: 'rgba(255,255,255,0.04)' },
}}
>
<Chip
label={rejected.length}
size="small"
sx={{
height: 20,
minWidth: 20,
fontSize: 11,
fontWeight: 700,
bgcolor: 'rgba(244,67,54,0.2)',
color: '#f44336',
}}
/>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>
suggestion{rejected.length > 1 ? 's' : ''} not included
</Typography>
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.25)', mr: 0.5 }}>
{open ? 'hide' : 'view'}
</Typography>
{open ? <ExpandLessIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} /> : <ExpandMoreIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.3)' }} />}
</Box>
</Box>
<Collapse in={open}>
<Box sx={{ pt: 0.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{rejected.map((p, i) => {
const action = actionForProposal(p);
return (
<Box
key={i}
sx={{
p: 1.25,
borderRadius: 2,
bgcolor: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.06)',
'&:hover': { bgcolor: 'rgba(255,255,255,0.06)' },
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<InfoIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.25)', mt: 0.25, flexShrink: 0 }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, mb: 0.25 }}>
&ldquo;{p.title}&rdquo;
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', display: 'block', lineHeight: 1.4 }}>
{plainReason(p)}
</Typography>
{action && (
<Button
size="small"
variant="outlined"
sx={{
mt: 0.75,
height: 24,
fontSize: 11,
fontWeight: 600,
textTransform: 'none',
color: '#8b9cf7',
borderColor: 'rgba(102,126,234,0.3)',
'&:hover': { borderColor: 'rgba(102,126,234,0.6)', bgcolor: 'rgba(102,126,234,0.1)' },
}}
>
{action.label}
</Button>
)}
</Box>
<Chip
label={p.agent}
size="small"
sx={{
height: 18,
fontSize: 9,
fontWeight: 600,
bgcolor: 'rgba(255,255,255,0.06)',
color: 'rgba(255,255,255,0.4)',
flexShrink: 0,
}}
/>
</Box>
</Box>
);
})}
</Box>
</Collapse>
</Box>
);
};
// ─── Agent Row (details section) ────────────────────
type AgentStatus = 'all_accepted' | 'partial' | 'all_rejected';
interface AgentSummary {
name: string;
total: number;
accepted: number;
status: AgentStatus;
proposals: CommitteeProposal[];
}
const agentStatusIcon = (s: AgentStatus) => {
if (s === 'all_accepted') return <CheckCircleIcon sx={{ fontSize: 18, color: '#4caf50' }} />;
if (s === 'partial') return <WarningAmberIcon sx={{ fontSize: 18, color: '#ff9800' }} />;
return <ErrorIcon sx={{ fontSize: 18, color: '#f44336' }} />;
};
const agentStatusColor = (s: AgentStatus): 'success' | 'warning' | 'error' => {
if (s === 'all_accepted') return 'success';
if (s === 'partial') return 'warning';
return 'error';
};
const AgentRow: React.FC<{ agent: AgentSummary; expanded: boolean; onToggle: () => void }> = ({ agent, expanded, onToggle }) => {
const pct = agent.total > 0 ? agent.accepted / agent.total : 0;
return (
<Box>
<Box
onClick={onToggle}
sx={{
display: 'flex', alignItems: 'center', gap: 1.5, py: 0.6, px: 1.5, borderRadius: 2,
cursor: 'pointer', transition: 'background 0.2s',
'&:hover': { bgcolor: 'rgba(255,255,255,0.06)' },
}}
>
{agentStatusIcon(agent.status)}
<Typography variant="body2" sx={{ fontWeight: 600, minWidth: 140, color: 'rgba(255,255,255,0.9)' }}>
{agent.name}
</Typography>
<Box sx={{ flex: 1, maxWidth: 140 }}>
<LinearProgress
variant="determinate"
value={pct * 100}
color={agentStatusColor(agent.status)}
sx={{ height: 5, borderRadius: 2.5, bgcolor: 'rgba(255,255,255,0.08)' }}
/>
</Box>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.5)', minWidth: 80, textAlign: 'right' }}>
{agent.accepted}/{agent.total}
</Typography>
{expanded ? <ExpandLessIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.4)' }} /> : <ExpandMoreIcon sx={{ fontSize: 18, color: 'rgba(255,255,255,0.4)' }} />}
</Box>
<Collapse in={expanded}>
<Box sx={{ ml: 5, mr: 1.5, mb: 0.5 }}>
{agent.proposals.map((p, i) => (
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.35, px: 1, borderRadius: 1, bgcolor: p.accepted ? 'rgba(76,175,80,0.06)' : 'transparent' }}>
<Typography variant="caption" sx={{ flex: 1, color: 'rgba(255,255,255,0.8)' }}>
{p.title}
</Typography>
<Chip label={PILLAR_INFO[p.pillar_id]?.label || p.pillar_id} size="small" sx={{
height: 20, fontSize: 10, fontWeight: 600,
bgcolor: p.valid ? 'rgba(102,126,234,0.2)' : 'rgba(244,67,54,0.2)',
color: p.valid ? '#8b9cf7' : '#f44336',
border: `1px solid ${p.valid ? 'rgba(102,126,234,0.3)' : 'rgba(244,67,54,0.3)'}`,
}} />
<Chip label={p.priority} size="small" sx={{
height: 20, fontSize: 10, fontWeight: 600, textTransform: 'capitalize',
bgcolor: p.priority === 'high' ? 'rgba(76,175,80,0.15)' : p.priority === 'medium' ? 'rgba(255,152,0,0.15)' : 'rgba(158,158,158,0.15)',
color: p.priority === 'high' ? '#4caf50' : p.priority === 'medium' ? '#ff9800' : '#9e9e9e',
}} />
<Chip label={p.accepted ? 'Accepted' : 'Skipped'} size="small" sx={{
height: 20, fontSize: 10, fontWeight: 600,
bgcolor: p.accepted ? 'rgba(76,175,80,0.15)' : 'rgba(158,158,158,0.15)',
color: p.accepted ? '#4caf50' : '#9e9e9e',
}} />
</Box>
))}
</Box>
</Collapse>
</Box>
);
};
// ─── Main Component ─────────────────────────────────
const CommitteeSummary: React.FC<{ events: AgentEventItem[] }> = ({ events }) => {
const [showDetails, setShowDetails] = useState(false);
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
const meeting = useMemo<CommitteePayload | null>(() => {
const last = events.find((e) => e.event_type === 'committee_meeting');
if (!last?.payload) return null;
return (typeof last.payload === 'string' ? JSON.parse(last.payload) : last.payload) as CommitteePayload;
}, [events]);
const agents = useMemo<AgentSummary[]>(() => {
if (!meeting) return [];
const map = new Map<string, CommitteeProposal[]>();
for (const p of meeting.proposals) {
if (!map.has(p.agent)) map.set(p.agent, []);
map.get(p.agent)!.push(p);
}
return Array.from(map.entries()).map(([name, proposals]) => {
const accepted = proposals.filter((p) => p.accepted).length;
const total = proposals.length;
let status: AgentStatus = 'all_accepted';
if (accepted === 0) status = 'all_rejected';
else if (accepted < total) status = 'partial';
return { name, total, accepted, status, proposals };
});
}, [meeting]);
if (!meeting) return null;
const summaryLine = `ALwrity reviewed ${meeting.total_proposals} suggestions across ${meeting.agents_polled} areas of your content workflow and built today's plan from ${meeting.accepted_count} of them.`;
return (
<Box
sx={{
background: 'linear-gradient(180deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.06) 100%)',
backdropFilter: 'blur(22px)',
WebkitBackdropFilter: 'blur(22px)',
border: '1px solid rgba(255,255,255,0.16)',
borderRadius: 3.5,
boxShadow: '0 18px 50px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.25)',
p: 2.5,
mb: 2,
}}
>
{/* Header + summary line */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1.5 }}>
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)', fontSize: '0.95rem', mb: 0.25 }}>
Daily Committee Brief
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.45)', lineHeight: 1.4, display: 'block', maxWidth: 480 }}>
{summaryLine}
</Typography>
</Box>
<Button
size="small"
variant="text"
onClick={() => setShowDetails(!showDetails)}
endIcon={showDetails ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{
fontSize: 11,
fontWeight: 600,
textTransform: 'none',
color: 'rgba(255,255,255,0.5)',
flexShrink: 0,
'&:hover': { color: 'rgba(255,255,255,0.8)', bgcolor: 'rgba(255,255,255,0.05)' },
}}
>
{showDetails ? 'Hide details' : 'Show details'}
</Button>
</Box>
{/* Status banner */}
<StatusBanner accepted={meeting.accepted_count} total={meeting.total_proposals} agents={meeting.agents_polled} />
{/* Adoption bar */}
<AdoptionBar accepted={meeting.accepted_count} total={meeting.total_proposals} />
{/* Coverage flow */}
<CoverageFlow proposals={meeting.proposals} />
{/* Rejected proposals */}
<RejectedList proposals={meeting.proposals} />
{/* Details section: agent-level breakdown */}
<Collapse in={showDetails}>
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.5)', mb: 0.5, display: 'block', textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
Agent Breakdown
</Typography>
{agents.map((agent) => (
<AgentRow
key={agent.name}
agent={agent}
expanded={expandedAgent === agent.name}
onToggle={() => setExpandedAgent(expandedAgent === agent.name ? null : agent.name)}
/>
))}
</Box>
</Collapse>
</Box>
);
};
export default CommitteeSummary;