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
414 lines
16 KiB
TypeScript
414 lines
16 KiB
TypeScript
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;
|