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
This commit is contained in:
413
frontend/src/components/TeamActivity/CommitteeAuditTable.tsx
Normal file
413
frontend/src/components/TeamActivity/CommitteeAuditTable.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableSortLabel,
|
||||
Collapse,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Button,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
FileDownload as FileDownloadIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
} 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_LABELS: Record<string, string> = {
|
||||
plan: 'Plan',
|
||||
generate: 'Generate',
|
||||
publish: 'Publish',
|
||||
analyze: 'Analyze',
|
||||
engage: 'Engage',
|
||||
remarket: 'Remarket',
|
||||
};
|
||||
|
||||
type SortKey = 'agent' | 'title' | 'pillar_id' | 'priority' | 'valid' | 'accepted';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
type FilterStatus = 'all' | 'accepted' | 'rejected' | 'invalid';
|
||||
|
||||
const sortProposals = (props: CommitteeProposal[], key: SortKey, dir: SortDir): CommitteeProposal[] => {
|
||||
return [...props].sort((a, b) => {
|
||||
const aVal = String(a[key] ?? '');
|
||||
const bVal = String(b[key] ?? '');
|
||||
const cmp = aVal.localeCompare(bVal);
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
};
|
||||
|
||||
const CommitteeAuditTable: React.FC<{ events: AgentEventItem[] }> = ({ events }) => {
|
||||
const [sortKey, setSortKey] = useState<SortKey>('agent');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<FilterStatus>('all');
|
||||
const [filterAgent, setFilterAgent] = useState<string | null>(null);
|
||||
const [expandedRow, setExpandedRow] = useState<number | 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 allAgents = useMemo<string[]>(() => {
|
||||
if (!meeting) return [];
|
||||
return Array.from(new Set(meeting.proposals.map((p) => p.agent)));
|
||||
}, [meeting]);
|
||||
|
||||
const filtered = useMemo<CommitteeProposal[]>(() => {
|
||||
if (!meeting) return [];
|
||||
let list = meeting.proposals;
|
||||
|
||||
if (filterStatus === 'accepted') list = list.filter((p) => p.accepted);
|
||||
else if (filterStatus === 'rejected') list = list.filter((p) => !p.accepted);
|
||||
else if (filterStatus === 'invalid') list = list.filter((p) => !p.valid);
|
||||
|
||||
if (filterAgent) list = list.filter((p) => p.agent === filterAgent);
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
list = list.filter((p) => p.title.toLowerCase().includes(q) || p.agent.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
return sortProposals(list, sortKey, sortDir);
|
||||
}, [meeting, filterStatus, filterAgent, search, sortKey, sortDir]);
|
||||
|
||||
const handleSort = (key: SortKey) => () => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const exportCsv = () => {
|
||||
if (!meeting) return;
|
||||
const headers = ['Agent', 'Title', 'Pillar', 'Priority', 'Valid', 'Accepted', 'Rejected Reason', 'Reasoning', 'Est. Time', 'Action Type'];
|
||||
const rows = meeting.proposals.map((p) => [
|
||||
p.agent,
|
||||
`"${p.title.replace(/"/g, '""')}"`,
|
||||
p.pillar_id,
|
||||
p.priority,
|
||||
p.valid ? 'Yes' : 'No',
|
||||
p.accepted ? 'Yes' : 'No',
|
||||
p.rejected_reason ? `"${p.rejected_reason.replace(/"/g, '""')}"` : '',
|
||||
p.reasoning ? `"${p.reasoning.replace(/"/g, '""')}"` : '',
|
||||
p.estimated_time ?? '',
|
||||
p.action_type ?? '',
|
||||
].join(','));
|
||||
|
||||
const csv = [headers.join(','), ...rows].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `committee_audit_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (!meeting) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.04) 100%)',
|
||||
backdropFilter: 'blur(22px)',
|
||||
WebkitBackdropFilter: 'blur(22px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: 3.5,
|
||||
boxShadow: '0 18px 50px rgba(0,0,0,0.25)',
|
||||
p: 2.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, flexWrap: 'wrap', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'rgba(255,255,255,0.95)', fontSize: '0.95rem' }}>
|
||||
Committee Audit — {meeting.total_proposals} proposals
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<FileDownloadIcon />}
|
||||
onClick={exportCsv}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
borderColor: 'rgba(255,255,255,0.2)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
'&:hover': { borderColor: 'rgba(255,255,255,0.4)', bgcolor: 'rgba(255,255,255,0.05)' },
|
||||
}}
|
||||
>
|
||||
CSV
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Filters */}
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search proposals..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ fontSize: 16, color: 'rgba(255,255,255,0.3)' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: 'rgba(255,255,255,0.05)',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 13,
|
||||
'& fieldset': { borderColor: 'rgba(255,255,255,0.12)' },
|
||||
'&:hover fieldset': { borderColor: 'rgba(255,255,255,0.25)' },
|
||||
'&.Mui-focused fieldset': { borderColor: 'rgba(102,126,234,0.5)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{(['all', 'accepted', 'rejected', 'invalid'] as FilterStatus[]).map((s) => (
|
||||
<Chip
|
||||
key={s}
|
||||
label={s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
size="small"
|
||||
onClick={() => setFilterStatus(s)}
|
||||
sx={{
|
||||
height: 26,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
bgcolor: filterStatus === s ? 'rgba(102,126,234,0.25)' : 'rgba(255,255,255,0.06)',
|
||||
color: filterStatus === s ? '#8b9cf7' : 'rgba(255,255,255,0.5)',
|
||||
border: `1px solid ${filterStatus === s ? 'rgba(102,126,234,0.4)' : 'transparent'}`,
|
||||
'&:hover': { bgcolor: filterStatus === s ? 'rgba(102,126,234,0.3)' : 'rgba(255,255,255,0.1)' },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{allAgents.map((a) => (
|
||||
<Chip
|
||||
key={a}
|
||||
label={a}
|
||||
size="small"
|
||||
onClick={() => setFilterAgent(filterAgent === a ? null : a)}
|
||||
sx={{
|
||||
height: 26,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
bgcolor: filterAgent === a ? 'rgba(102,126,234,0.25)' : 'rgba(255,255,255,0.06)',
|
||||
color: filterAgent === a ? '#8b9cf7' : 'rgba(255,255,255,0.5)',
|
||||
border: `1px solid ${filterAgent === a ? 'rgba(102,126,234,0.4)' : 'transparent'}`,
|
||||
'&:hover': { bgcolor: filterAgent === a ? 'rgba(102,126,234,0.3)' : 'rgba(255,255,255,0.1)' },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Table */}
|
||||
<TableContainer sx={{ maxHeight: 420, '&::-webkit-scrollbar': { width: 6 }, '&::-webkit-scrollbar-thumb': { bgcolor: 'rgba(255,255,255,0.15)', borderRadius: 3 } }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{([{ key: 'agent', label: 'Agent' }, { key: 'title', label: 'Title' }, { key: 'pillar_id', label: 'Pillar' }, { key: 'priority', label: 'Priority' }, { key: 'valid', label: 'Valid' }, { key: 'accepted', label: 'Accepted' }] as { key: SortKey; label: string }[]).map(({ key, label }) => (
|
||||
<TableCell
|
||||
key={key}
|
||||
sx={{
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
bgcolor: 'rgba(0,0,0,0.3)',
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={sortKey === key}
|
||||
direction={sortKey === key ? sortDir : 'asc'}
|
||||
onClick={handleSort(key)}
|
||||
sx={{ color: 'inherit !important', '& .MuiTableSortLabel-icon': { color: 'rgba(255,255,255,0.5) !important' } }}
|
||||
>
|
||||
{label}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell sx={{ color: 'rgba(255,255,255,0.5)', fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, borderBottom: '1px solid rgba(255,255,255,0.08)', bgcolor: 'rgba(0,0,0,0.3)', py: 1 }}>
|
||||
Reason
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map((p, i) => {
|
||||
const isExpanded = expandedRow === i;
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
<TableRow
|
||||
hover
|
||||
onClick={() => setExpandedRow(isExpanded ? null : i)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.04)' },
|
||||
'& td': { borderBottom: '1px solid rgba(255,255,255,0.04)' },
|
||||
opacity: p.accepted ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
<TableCell sx={{ color: 'rgba(255,255,255,0.8)', fontSize: 12, fontWeight: 600, py: 0.75 }}>
|
||||
{p.agent}
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'rgba(255,255,255,0.8)', fontSize: 12, py: 0.75 }}>
|
||||
{p.title}
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75 }}>
|
||||
<Chip
|
||||
label={PILLAR_LABELS[p.pillar_id] || 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',
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75 }}>
|
||||
<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',
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75 }}>
|
||||
<Chip
|
||||
label={p.valid ? 'Yes' : 'No'}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
bgcolor: p.valid ? 'rgba(76,175,80,0.15)' : 'rgba(244,67,54,0.15)',
|
||||
color: p.valid ? '#4caf50' : '#f44336',
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75 }}>
|
||||
<Chip
|
||||
label={p.accepted ? 'Yes' : 'No'}
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ color: 'rgba(255,255,255,0.4)', fontSize: 11, py: 0.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{p.rejected_reason || (p.accepted ? '—' : 'Duplicate / lower priority')}
|
||||
{isExpanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} sx={{ py: 0, borderBottom: 'none', bgcolor: 'rgba(0,0,0,0.2)' }}>
|
||||
<Collapse in={isExpanded}>
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.4)', fontWeight: 700, display: 'block', mb: 0.5, textTransform: 'uppercase', letterSpacing: 1, fontSize: 10 }}>
|
||||
Reasoning
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, lineHeight: 1.6, mb: 1.5 }}>
|
||||
{p.reasoning || 'No reasoning provided.'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 10, fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
Est. Time
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, display: 'block' }}>
|
||||
{p.estimated_time ? `${p.estimated_time} min` : '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.3)', fontSize: 10, fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
Action Type
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, display: 'block' }}>
|
||||
{p.action_type || '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} sx={{ textAlign: 'center', color: 'rgba(255,255,255,0.3)', fontSize: 13, py: 4 }}>
|
||||
No proposals match current filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommitteeAuditTable;
|
||||
Reference in New Issue
Block a user