Files
ALwrity/frontend/src/hooks/useBlogWriterState.ts
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

489 lines
17 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { BlogOutlineSection, BlogResearchResponse, BlogSEOMetadataResponse, BlogSEOAnalyzeResponse, SourceMappingStats, GroundingInsights, ResearchCoverage } from '../services/blogWriterApi';
import { researchCache } from '../services/researchCache';
import { blogWriterCache } from '../services/blogWriterCache';
const MINOR_TITLE_WORDS = new Set([
'a', 'an', 'and', 'or', 'but', 'the', 'for', 'nor', 'on', 'at', 'to', 'from', 'by',
'of', 'in', 'with', 'as', 'vs', 'vs.', 'into', 'over', 'under'
]);
// Helper: read and parse localStorage synchronously (safe for useState initializer)
const readLS = <T>(key: string, fallback: T): T => {
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
};
const readLSString = (key: string, fallback: string): string => {
try {
const raw = localStorage.getItem(key);
return raw !== null ? raw : fallback;
} catch {
return fallback;
}
};
const readLSBool = (key: string, fallback: boolean): boolean => {
try {
const raw = localStorage.getItem(key);
return raw !== null ? raw === 'true' : fallback;
} catch {
return fallback;
}
};
// Perform synchronous restoration from localStorage/caches so that
// phase-navigation hooks see real data on the very first render.
const restoreInitialState = () => {
let research: BlogResearchResponse | null = null;
let outline: BlogOutlineSection[] = [];
let titleOptions: string[] = [];
let selectedTitle: string = '';
let sections: Record<string, string> = {};
let seoAnalysis: BlogSEOAnalyzeResponse | null = null;
let seoMetadata: BlogSEOMetadataResponse | null = null;
let outlineConfirmed: boolean = false;
let contentConfirmed: boolean = false;
let sourceMappingStats: SourceMappingStats | null = null;
let groundingInsights: GroundingInsights | null = null;
let researchCoverage: ResearchCoverage | null = null;
let sectionImages: Record<string, string> = {};
try {
// Restore research from the research cache (synchronous localStorage reads)
const cachedEntries = researchCache.getAllCachedEntries();
if (cachedEntries.length > 0) {
research = cachedEntries[0].result;
}
// Restore outline from localStorage
const savedOutline = readLS<BlogOutlineSection[] | null>('blog_outline', null);
if (savedOutline && savedOutline.length > 0) {
outline = savedOutline;
// Restore content sections from cache
const outlineIds = savedOutline.map((s: any) => String(s.id));
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
if (cachedContent && Object.keys(cachedContent).length > 0) {
sections = cachedContent;
}
}
// Restore titles — strip any stale '...' truncation baked in by prior versions
titleOptions = readLS<string[]>('blog_title_options', []).map(t => t.replace(/\.\.\.$/, ''));
selectedTitle = readLSString('blog_selected_title', '').replace(/\.\.\.$/, '');
// Restore outline intelligence metadata
sourceMappingStats = readLS<SourceMappingStats | null>('blog_source_mapping_stats', null);
groundingInsights = readLS<GroundingInsights | null>('blog_grounding_insights', null);
researchCoverage = readLS<ResearchCoverage | null>('blog_research_coverage', null);
// Restore confirmation flags
outlineConfirmed = readLSBool('blog_outline_confirmed', false);
// Backward compatibility: if outline exists but confirmation wasn't saved, assume confirmed
if (!outlineConfirmed && outline.length > 0) {
outlineConfirmed = true;
}
contentConfirmed = readLSBool('blog_content_confirmed', false);
// Restore SEO data
seoAnalysis = readLS<BlogSEOAnalyzeResponse | null>('blog_seo_analysis', null);
seoMetadata = readLS<BlogSEOMetadataResponse | null>('blog_seo_metadata', null);
// Restore section images (log only once per session, not on every hook mount)
const savedSectionImages = readLS<Record<string, string> | null>('blog_section_images', null);
if (savedSectionImages && Object.keys(savedSectionImages).length > 0) {
sectionImages = savedSectionImages;
if (!(window as any).__sectionImagesLogged) {
console.log(`[SectionImages] Restored ${Object.keys(sectionImages).length} images from localStorage`);
(window as any).__sectionImagesLogged = true;
}
}
} catch (error) {
console.error('Error during initial state restoration:', error);
}
return {
research,
outline,
titleOptions,
selectedTitle,
sections,
seoAnalysis,
seoMetadata,
outlineConfirmed,
contentConfirmed,
sourceMappingStats,
groundingInsights,
researchCoverage,
sectionImages,
};
};
export const useBlogWriterState = () => {
// Restore initial state synchronously from localStorage (like StoryWriter pattern)
// This ensures phase-navigation hooks see real data on the first render,
// preventing unwanted redirects during the async restoration gap.
const initialState = restoreInitialState();
// Core state — initialized from localStorage when available
const [research, setResearch] = useState<BlogResearchResponse | null>(initialState.research);
const [outline, setOutline] = useState<BlogOutlineSection[]>(initialState.outline);
const [titleOptions, setTitleOptions] = useState<string[]>(initialState.titleOptions);
const [selectedTitle, setSelectedTitle] = useState<string>(initialState.selectedTitle);
const [sections, setSections] = useState<Record<string, string>>(initialState.sections);
const [seoAnalysis, setSeoAnalysis] = useState<BlogSEOAnalyzeResponse | null>(initialState.seoAnalysis);
const [genMode, setGenMode] = useState<'draft' | 'polished'>('polished');
const [seoMetadata, setSeoMetadata] = useState<BlogSEOMetadataResponse | null>(initialState.seoMetadata);
const [introduction, setIntroduction] = useState<string>(localStorage.getItem('blog_introduction') || '');
const [continuityRefresh, setContinuityRefresh] = useState<number>(0);
const [outlineTaskId, setOutlineTaskId] = useState<string | null>(null);
const [flowAnalysisCompleted, setFlowAnalysisCompleted] = useState<boolean>(false);
const [flowAnalysisResults, setFlowAnalysisResults] = useState<any>(null);
// Enhanced metadata state
const [sourceMappingStats, setSourceMappingStats] = useState<SourceMappingStats | null>(initialState.sourceMappingStats);
const [groundingInsights, setGroundingInsights] = useState<GroundingInsights | null>(initialState.groundingInsights);
const [researchCoverage, setResearchCoverage] = useState<ResearchCoverage | null>(initialState.researchCoverage);
// Separate research titles from AI-generated titles
const [researchTitles, setResearchTitles] = useState<string[]>([]);
const [aiGeneratedTitles, setAiGeneratedTitles] = useState<string[]>([]);
// Outline confirmation state
const [outlineConfirmed, setOutlineConfirmed] = useState<boolean>(initialState.outlineConfirmed);
// Content confirmation state
const [contentConfirmed, setContentConfirmed] = useState<boolean>(initialState.contentConfirmed);
// Section images state - persists images generated in outline phase to content phase
const [sectionImages, setSectionImages] = useState<Record<string, string>>(initialState.sectionImages);
const formatContentAngleToTitle = useCallback((angle: string): string => {
if (!angle || typeof angle !== 'string') {
return '';
}
const cleaned = angle.replace(/\s+/g, ' ').trim();
if (!cleaned) {
return '';
}
const words = cleaned.split(' ');
const formattedWords = words.map((word, index) => {
const lower = word.toLowerCase();
if (index !== 0 && MINOR_TITLE_WORDS.has(lower)) {
return lower;
}
if (!lower) {
return '';
}
return lower.charAt(0).toUpperCase() + lower.slice(1);
}).filter(Boolean);
let formatted = formattedWords.join(' ');
return formatted;
}, []);
const dedupeTitles = useCallback((titles: string[]): string[] => {
const seen = new Set<string>();
const result: string[] = [];
titles.forEach((title) => {
if (!title) {
return;
}
const normalized = title.replace(/\s+/g, ' ').trim();
if (!normalized) {
return;
}
const key = normalized.toLowerCase();
if (seen.has(key)) {
return;
}
seen.add(key);
result.push(normalized);
});
return result;
}, []);
const [restoreAttempted] = useState(true); // Always true — state is restored synchronously
// Persist contentConfirmed to localStorage whenever it changes
useEffect(() => {
try {
localStorage.setItem('blog_content_confirmed', String(contentConfirmed));
} catch {}
}, [contentConfirmed]);
// Persist outlineConfirmed to localStorage whenever it changes
useEffect(() => {
try {
localStorage.setItem('blog_outline_confirmed', String(outlineConfirmed));
} catch {}
}, [outlineConfirmed]);
// Persist seoAnalysis to localStorage whenever it changes
useEffect(() => {
try {
if (seoAnalysis) {
localStorage.setItem('blog_seo_analysis', JSON.stringify(seoAnalysis));
}
} catch {}
}, [seoAnalysis]);
// Persist seoMetadata to localStorage whenever it changes
useEffect(() => {
try {
if (seoMetadata) {
localStorage.setItem('blog_seo_metadata', JSON.stringify(seoMetadata));
}
} catch {}
}, [seoMetadata]);
// Persist sectionImages to localStorage whenever they change
useEffect(() => {
try {
if (Object.keys(sectionImages).length > 0) {
const serialized = JSON.stringify(sectionImages);
// Warn if approaching localStorage quota (~5MB)
if (serialized.length > 4_000_000) {
console.warn(`[SectionImages] Approaching localStorage quota: ${(serialized.length / 1024 / 1024).toFixed(1)}MB`);
}
localStorage.setItem('blog_section_images', serialized);
} else {
// Only remove if we have previously saved images (avoid clearing on transient empty state)
if (localStorage.getItem('blog_section_images')) {
localStorage.removeItem('blog_section_images');
}
}
} catch (e) {
console.warn('[SectionImages] Failed to persist to localStorage via effect:', e);
}
}, [sectionImages]);
// Persist introduction to localStorage whenever it changes
useEffect(() => {
try {
if (introduction) {
localStorage.setItem('blog_introduction', introduction);
}
} catch {}
}, [introduction]);
// Persist sections to blogWriterCache whenever they change
useEffect(() => {
const outlineIds = outline.map(s => String(s.id));
if (outlineIds.length > 0 && Object.keys(sections).length > 0) {
const normalized: Record<string, string> = {};
const values = Object.values(sections);
outline.forEach((s, idx) => {
const id = String(s.id);
normalized[id] = sections[id] ?? values[idx] ?? '';
});
blogWriterCache.cacheContent(normalized, outlineIds);
}
}, [sections, outline]);
// Handle research completion
const handleResearchComplete = useCallback((researchData: BlogResearchResponse) => {
setResearch(researchData);
const formattedAngles = dedupeTitles(
(researchData?.suggested_angles || []).map(formatContentAngleToTitle)
);
setResearchTitles(formattedAngles);
// Prefill title from research if no title is currently selected
if (!selectedTitle && formattedAngles.length > 0) {
const firstTitle = formattedAngles[0];
setSelectedTitle(firstTitle);
localStorage.setItem('blog_selected_title', firstTitle);
}
}, [dedupeTitles, formatContentAngleToTitle, selectedTitle]);
// Handle outline completion with enhanced metadata
const handleOutlineComplete = useCallback((result: any) => {
if (result?.outline) {
setOutline(result.outline);
const aiTitleOptions: string[] = result.title_options || [];
const formattedAngles = dedupeTitles(
(research?.suggested_angles || []).map(formatContentAngleToTitle)
);
const combinedTitleOptions = dedupeTitles([
...formattedAngles,
...aiTitleOptions
]);
setTitleOptions(combinedTitleOptions);
setResearchTitles(formattedAngles);
const aiTitlesList = dedupeTitles(
aiTitleOptions.filter((title: string) => !formattedAngles.some(angle => angle.toLowerCase() === (title || '').toLowerCase().trim()))
);
setAiGeneratedTitles(aiTitlesList);
const nextSelectedTitle = aiTitlesList[0] || formattedAngles[0] || combinedTitleOptions[0] || '';
if (nextSelectedTitle) {
setSelectedTitle(nextSelectedTitle);
}
// Store enhanced metadata
if (result.source_mapping_stats) {
setSourceMappingStats(result.source_mapping_stats);
}
if (result.grounding_insights) {
setGroundingInsights(result.grounding_insights);
}
if (result.research_coverage) {
setResearchCoverage(result.research_coverage);
}
// Save to localStorage for persistence (using shared cache utility)
try {
const { blogWriterCache } = require('../services/blogWriterCache');
blogWriterCache.cacheOutline(result.outline, combinedTitleOptions);
localStorage.setItem('blog_title_options', JSON.stringify(combinedTitleOptions));
localStorage.setItem('blog_selected_title', nextSelectedTitle || '');
localStorage.setItem('blog_source_mapping_stats', JSON.stringify(result.source_mapping_stats || null));
localStorage.setItem('blog_grounding_insights', JSON.stringify(result.grounding_insights || null));
localStorage.setItem('blog_research_coverage', JSON.stringify(result.research_coverage || null));
console.log('Saved outline data to localStorage');
} catch (error) {
console.error('Error saving outline data:', error);
}
}
setOutlineTaskId(null);
// Reset outline confirmation when new outline is generated
setOutlineConfirmed(false);
}, [research, dedupeTitles, formatContentAngleToTitle]);
// Handle outline error
const handleOutlineError = useCallback((error: any) => {
console.error('Outline generation error:', error);
setOutlineTaskId(null);
}, []);
// Handle section generation
const handleSectionGenerated = useCallback((sectionId: string, markdown: string) => {
setSections(prev => ({ ...prev, [sectionId]: markdown }));
}, []);
// Handle continuity refresh
const handleContinuityRefresh = useCallback(() => {
setContinuityRefresh(Date.now());
}, []);
// Handle title selection
const handleTitleSelect = useCallback((title: string) => {
setSelectedTitle(title);
localStorage.setItem('blog_selected_title', title);
}, []);
// Handle custom title
const handleCustomTitle = useCallback((title: string) => {
const newTitleOptions = [...titleOptions, title];
setTitleOptions(newTitleOptions);
setSelectedTitle(title);
localStorage.setItem('blog_title_options', JSON.stringify(newTitleOptions));
localStorage.setItem('blog_selected_title', title);
}, [titleOptions]);
// Handle outline confirmation
const handleOutlineConfirmed = useCallback(() => {
setOutlineConfirmed(true);
console.log('Outline confirmed by user');
}, []);
// Handle outline refinement
const handleOutlineRefined = useCallback((feedback: string) => {
console.log('Outline refinement requested with feedback:', feedback);
// The actual refinement will be handled by the copilot action
}, []);
// Handle content updates from WYSIWYG editor
const handleContentUpdate = useCallback((updatedSections: any[]) => {
console.log('Content updated:', updatedSections);
// Update sections state with new content
const newSections: { [key: string]: string } = {};
updatedSections.forEach(section => {
newSections[section.id] = section.content;
});
setSections(newSections);
}, [setSections]);
// Handle content saving
const handleContentSave = useCallback((content: any) => {
console.log('Content saved:', content);
// Here you could save to backend or local storage
// For now, just log the content
}, []);
return {
// State
research,
outline,
titleOptions,
selectedTitle,
sections,
introduction,
seoAnalysis,
genMode,
seoMetadata,
continuityRefresh,
outlineTaskId,
sourceMappingStats,
groundingInsights,
researchCoverage,
researchTitles,
aiGeneratedTitles,
outlineConfirmed,
contentConfirmed,
flowAnalysisCompleted,
flowAnalysisResults,
sectionImages,
restoreAttempted,
// Setters
setResearch,
setOutline,
setTitleOptions,
setSelectedTitle,
setSections,
setIntroduction,
setSeoAnalysis,
setGenMode,
setSeoMetadata,
setContinuityRefresh,
setOutlineTaskId,
setSourceMappingStats,
setGroundingInsights,
setResearchCoverage,
setResearchTitles,
setAiGeneratedTitles,
setOutlineConfirmed,
setContentConfirmed,
setFlowAnalysisCompleted,
setFlowAnalysisResults,
setSectionImages,
// Handlers
handleResearchComplete,
handleOutlineComplete,
handleOutlineError,
handleSectionGenerated,
handleContinuityRefresh,
handleTitleSelect,
handleCustomTitle,
handleOutlineConfirmed,
handleOutlineRefined,
handleContentUpdate,
handleContentSave
};
};