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:
ajaysi
2026-06-01 12:24:31 +05:30
parent 9b472f1c18
commit 923fa671fe
90 changed files with 8914 additions and 2731 deletions

View File

@@ -5,79 +5,71 @@ from typing import Any, Dict, List
def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
"""
Parse inline markdown formatting (bold, italic, links) into Ricos text nodes.
Parse inline markdown formatting (bold, italic, links, code, strikethrough) into Ricos text nodes.
Returns a list of text nodes with decorations.
Handles: **bold**, *italic*, [links](url), `code`, and combinations.
Handles: **bold**, *italic*, [links](url), `code`, ~strikethrough~, and combinations.
"""
if not text:
return [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'nodes': [],
'textData': {'text': '', 'decorations': []}
}]
nodes = []
# Process text character by character to handle nested/adjacent formatting
# This is more robust than regex for complex cases
i = 0
current_text = ''
current_decorations = []
def flush_text():
nonlocal current_text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [],
'textData': {'text': current_text, 'decorations': []}
})
current_text = ''
while i < len(text):
# Check for bold **text** (must come before single * check)
# Bold **text**
if i < len(text) - 1 and text[i:i+2] == '**':
# Save any accumulated text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
current_text = ''
# Find closing **
flush_text()
end_bold = text.find('**', i + 2)
if end_bold != -1:
bold_text = text[i + 2:end_bold]
# Recursively parse the bold text for nested formatting
bold_nodes = parse_markdown_inline(bold_text)
# Add BOLD decoration to all text nodes within
# Per Wix API: decorations are objects with 'type' field, not strings
for node in bold_nodes:
if node['type'] == 'TEXT':
node_decorations = node['textData'].get('decorations', []).copy()
# Check if BOLD decoration already exists
has_bold = any(d.get('type') == 'BOLD' for d in node_decorations if isinstance(d, dict))
if not has_bold:
node_decorations.append({'type': 'BOLD'})
node['textData']['decorations'] = node_decorations
decs = node['textData'].get('decorations', []).copy()
if not any(d.get('type') == 'BOLD' for d in decs if isinstance(d, dict)):
decs.append({'type': 'BOLD'})
node['textData']['decorations'] = decs
nodes.append(node)
i = end_bold + 2
continue
# Check for link [text](url)
# Strikethrough ~text~
elif text[i] == '~':
flush_text()
end_strike = text.find('~', i + 1)
if end_strike != -1:
strike_text = text[i + 1:end_strike]
strike_nodes = parse_markdown_inline(strike_text)
for node in strike_nodes:
if node['type'] == 'TEXT':
decs = node['textData'].get('decorations', []).copy()
if not any(d.get('type') == 'STRIKETHROUGH' for d in decs if isinstance(d, dict)):
decs.append({'type': 'STRIKETHROUGH'})
node['textData']['decorations'] = decs
nodes.append(node)
i = end_strike + 1
continue
# Link [text](url)
elif text[i] == '[':
# Save any accumulated text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
current_text = ''
current_decorations = []
# Find matching ]
flush_text()
link_end = text.find(']', i)
if link_end != -1 and link_end < len(text) - 1 and text[link_end + 1] == '(':
link_text = text[i + 1:link_end]
@@ -85,12 +77,10 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
url_end = text.find(')', url_start)
if url_end != -1:
url = text[url_start:url_end]
# Per Wix API: Links are decorations on TEXT nodes, not separate node types
# Create TEXT node with LINK decoration
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'nodes': [],
'textData': {
'text': link_text,
'decorations': [{
@@ -98,7 +88,7 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
'linkData': {
'link': {
'url': url,
'target': 'BLANK' # Wix API uses 'BLANK', not '_blank'
'target': 'BLANK'
}
}
}]
@@ -107,33 +97,17 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
i = url_end + 1
continue
# Check for code `text`
# Inline code `text`
elif text[i] == '`':
# Save any accumulated text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
current_text = ''
current_decorations = []
# Find closing `
flush_text()
code_end = text.find('`', i + 1)
if code_end != -1:
code_text = text[i + 1:code_end]
# Per Wix API: CODE is not a valid decoration type, but we'll keep the structure
# Note: Wix uses CODE_BLOCK nodes for code, not CODE decorations
# For inline code, we'll just use plain text for now
# Wix doesn't have a CODE decoration, but we can preserve the text
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'nodes': [],
'textData': {
'text': code_text,
'decorations': [] # CODE is not a valid decoration in Wix API
@@ -142,39 +116,21 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
i = code_end + 1
continue
# Check for italic *text* (only if not part of **)
# Italic *text* (must come after ** check)
elif text[i] == '*' and (i == 0 or text[i-1] != '*') and (i == len(text) - 1 or text[i+1] != '*'):
# Save any accumulated text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
current_text = ''
current_decorations = []
# Find closing * (but not **)
flush_text()
italic_end = text.find('*', i + 1)
if italic_end != -1:
# Make sure it's not part of **
if italic_end == len(text) - 1 or text[italic_end + 1] != '*':
italic_text = text[i + 1:italic_end]
italic_nodes = parse_markdown_inline(italic_text)
# Add ITALIC decoration
# Per Wix API: decorations are objects with 'type' field
for node in italic_nodes:
if node['type'] == 'TEXT':
node_decorations = node['textData'].get('decorations', []).copy()
# Check if ITALIC decoration already exists
has_italic = any(d.get('type') == 'ITALIC' for d in node_decorations if isinstance(d, dict))
if not has_italic:
node_decorations.append({'type': 'ITALIC'})
node['textData']['decorations'] = node_decorations
decs = node['textData'].get('decorations', []).copy()
if not any(d.get('type') == 'ITALIC' for d in decs if isinstance(d, dict)):
decs.append({'type': 'ITALIC'})
node['textData']['decorations'] = decs
nodes.append(node)
i = italic_end + 1
continue
@@ -183,58 +139,116 @@ def parse_markdown_inline(text: str) -> List[Dict[str, Any]]:
current_text += text[i]
i += 1
# Add any remaining text
if current_text:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': current_text,
'decorations': current_decorations.copy()
}
})
flush_text()
# If no nodes created, return single plain text node
if not nodes:
nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'textData': {
'text': text,
'decorations': []
}
'nodes': [],
'textData': {'text': text, 'decorations': []}
})
return nodes
def _make_code_block_node(code_text: str, language: str = '') -> Dict[str, Any]:
"""Create a Ricos CODE_BLOCK node."""
lines = code_text.split('\n')
text_nodes = []
for line in lines:
text_nodes.append({
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [],
'textData': {'text': line, 'decorations': []}
})
return {
'id': str(uuid.uuid4()),
'type': 'CODE_BLOCK',
'nodes': text_nodes,
'codeBlockData': {
'language': language or 'text',
'textWrap': True
}
}
def _make_horizontal_rule_node() -> Dict[str, Any]:
"""Create a Ricos DIVIDER node."""
return {
'id': str(uuid.uuid4()),
'type': 'DIVIDER',
'nodes': [],
'dividerData': {
'type': 'LINE',
'lineStyle': {
'width': 'LARGE',
'alignment': 'CENTER'
}
}
}
def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str, Any]:
"""
Convert markdown content into valid Ricos JSON format.
Supports headings, paragraphs, lists, bold, italic, links, and images.
Supports:
- Headings (# to ######)
- Paragraphs with inline formatting
- Unordered lists (-, *)
- Ordered lists (1., 2.)
- Blockquotes (>)
- Code blocks (```language ... ```)
- Inline images (![alt](url))
- Horizontal rules (---, ***, ___)
"""
if not content:
content = "This is a post from ALwrity."
nodes = []
lines = content.split('\n')
i = 0
while i < len(lines):
line = lines[i].strip()
line = lines[i]
stripped = line.strip()
if not line:
if not stripped:
i += 1
continue
node_id = str(uuid.uuid4())
# Check for headings
if line.startswith('#'):
level = len(line) - len(line.lstrip('#'))
heading_text = line.lstrip('# ').strip()
# Code blocks (```language ... ```)
if stripped.startswith('```'):
language = stripped[3:].strip() or ''
code_lines = []
i += 1
while i < len(lines):
if lines[i].strip() == '```':
i += 1
break
code_lines.append(lines[i])
i += 1
code_text = '\n'.join(code_lines)
if code_text.strip():
nodes.append(_make_code_block_node(code_text, language))
continue
# Horizontal rules
if re.match(r'^(---+|\*\*\*|___+)$', stripped):
nodes.append(_make_horizontal_rule_node())
i += 1
continue
# Headings
if stripped.startswith('#'):
level = len(stripped) - len(stripped.lstrip('#'))
heading_text = stripped.lstrip('# ').strip()
text_nodes = parse_markdown_inline(heading_text)
nodes.append({
'id': node_id,
@@ -243,42 +257,38 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
'headingData': {'level': min(level, 6)}
})
i += 1
continue
# Check for blockquotes
elif line.startswith('>'):
quote_text = line.lstrip('> ').strip()
# Continue reading consecutive blockquote lines
quote_lines = [quote_text]
# Blockquotes
if stripped.startswith('>'):
quote_lines = [stripped.lstrip('> ').strip()]
i += 1
while i < len(lines) and lines[i].strip().startswith('>'):
quote_lines.append(lines[i].strip().lstrip('> ').strip())
i += 1
quote_content = ' '.join(quote_lines)
text_nodes = parse_markdown_inline(quote_content)
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within BLOCKQUOTE
# Wix API: omit empty data objects, don't include them as {}
paragraph_node = {
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
}
blockquote_node = {
nodes.append({
'id': node_id,
'type': 'BLOCKQUOTE',
'nodes': [paragraph_node],
}
nodes.append(blockquote_node)
})
continue
# Check for unordered lists (handle both '- ' and '* ' markers)
elif (line.startswith('- ') or line.startswith('* ') or
(line.startswith('-') and len(line) > 1 and line[1] != '-') or
(line.startswith('*') and len(line) > 1 and line[1] != '*')):
# Unordered lists
if (stripped.startswith('- ') or stripped.startswith('* ') or
(stripped.startswith('-') and len(stripped) > 1 and stripped[1] != '-') or
(stripped.startswith('*') and len(stripped) > 1 and stripped[1] != '*')):
list_items = []
list_marker = '- ' if line.startswith('-') else '* '
# Process list items
list_marker = '- ' if stripped.startswith('-') else '* '
while i < len(lines):
current_line = lines[i].strip()
# Check if this is a list item
is_list_item = (current_line.startswith('- ') or current_line.startswith('* ') or
(current_line.startswith('-') and len(current_line) > 1 and current_line[1] != '-') or
(current_line.startswith('*') and len(current_line) > 1 and current_line[1] != '*'))
@@ -286,12 +296,9 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
if not is_list_item:
break
# Extract item text (handle both '- ' and '-item' formats)
if current_line.startswith('- ') or current_line.startswith('* '):
item_text = current_line[2:].strip()
elif current_line.startswith('-'):
item_text = current_line[1:].strip()
elif current_line.startswith('*'):
elif current_line.startswith('-') or current_line.startswith('*'):
item_text = current_line[1:].strip()
else:
item_text = current_line
@@ -302,52 +309,41 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
# Check for nested items (indented with 2+ spaces)
while i < len(lines):
next_line = lines[i]
# Must be indented and be a list marker
if next_line.startswith(' ') and (next_line.strip().startswith('- ') or
next_line.strip().startswith('* ') or
(next_line.strip().startswith('-') and len(next_line.strip()) > 1) or
(next_line.strip().startswith('*') and len(next_line.strip()) > 1)):
if (next_line.startswith(' ') and
(next_line.strip().startswith('- ') or next_line.strip().startswith('* '))):
nested_text = next_line.strip()
if nested_text.startswith('- ') or nested_text.startswith('* '):
nested_text = nested_text[2:].strip()
elif nested_text.startswith('-'):
nested_text = nested_text[1:].strip()
elif nested_text.startswith('*'):
elif nested_text.startswith('-') or nested_text.startswith('*'):
nested_text = nested_text[1:].strip()
list_items.append(nested_text)
i += 1
else:
break
# Build list items with proper formatting
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within LIST_ITEM
# NOTE: LIST_ITEM nodes do NOT have a data field per Wix API schema
# Wix API: omit empty data objects, don't include them as {}
list_node_items = []
for item_text in list_items:
item_node_id = str(uuid.uuid4())
text_nodes = parse_markdown_inline(item_text)
paragraph_node = {
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
}
list_item_node = {
'id': item_node_id,
list_node_items.append({
'id': str(uuid.uuid4()),
'type': 'LIST_ITEM',
'nodes': [paragraph_node]
}
list_node_items.append(list_item_node)
})
bulleted_list_node = {
nodes.append({
'id': node_id,
'type': 'BULLETED_LIST',
'nodes': list_node_items,
}
nodes.append(bulleted_list_node)
})
continue
# Check for ordered lists
elif re.match(r'^\d+\.\s+', line):
# Ordered lists
if re.match(r'^\d+\.\s+', stripped):
list_items = []
while i < len(lines) and re.match(r'^\d+\.\s+', lines[i].strip()):
item_text = re.sub(r'^\d+\.\s+', '', lines[i].strip())
@@ -359,35 +355,30 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
list_items.append(nested_text)
i += 1
# CRITICAL: TEXT nodes must be wrapped in PARAGRAPH nodes within LIST_ITEM
# NOTE: LIST_ITEM nodes do NOT have a data field per Wix API schema
# Wix API: omit empty data objects, don't include them as {}
list_node_items = []
for item_text in list_items:
item_node_id = str(uuid.uuid4())
text_nodes = parse_markdown_inline(item_text)
paragraph_node = {
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': text_nodes,
}
list_item_node = {
'id': item_node_id,
list_node_items.append({
'id': str(uuid.uuid4()),
'type': 'LIST_ITEM',
'nodes': [paragraph_node]
}
list_node_items.append(list_item_node)
})
ordered_list_node = {
nodes.append({
'id': node_id,
'type': 'ORDERED_LIST',
'nodes': list_node_items,
}
nodes.append(ordered_list_node)
})
continue
# Check for images
elif line.startswith('!['):
img_match = re.match(r'!\[([^\]]*)\]\(([^)]+)\)', line)
# Images
if stripped.startswith('!['):
img_match = re.match(r'!\[([^\]]*)\]\(([^)]+)\)', stripped)
if img_match:
alt_text = img_match.group(1)
img_url = img_match.group(2)
@@ -407,62 +398,52 @@ def convert_content_to_ricos(content: str, images: List[str] = None) -> Dict[str
}
})
i += 1
continue
# Regular paragraph
else:
# Collect consecutive non-empty lines as paragraph content
para_lines = [line]
para_lines = [stripped]
i += 1
while i < len(lines):
next_line = lines[i].strip()
if not next_line:
break
# Stop if next line is a special markdown element
if (next_line.startswith('#') or
next_line.startswith('- ') or
next_line.startswith('* ') or
next_line.startswith('>') or
next_line.startswith('![') or
next_line.startswith('```') or
re.match(r'^(---+|\*\*\*|___+)$', next_line) or
re.match(r'^\d+\.\s+', next_line)):
break
para_lines.append(next_line)
i += 1
while i < len(lines):
next_line = lines[i].strip()
if not next_line:
break
# Stop if next line is a special markdown element
if (next_line.startswith('#') or
next_line.startswith('- ') or
next_line.startswith('* ') or
next_line.startswith('>') or
next_line.startswith('![') or
re.match(r'^\d+\.\s+', next_line)):
break
para_lines.append(next_line)
i += 1
para_text = ' '.join(para_lines)
text_nodes = parse_markdown_inline(para_text)
# Only add paragraph if there are text nodes
if text_nodes:
paragraph_node = {
'id': node_id,
'type': 'PARAGRAPH',
'nodes': text_nodes,
}
nodes.append(paragraph_node)
para_text = ' '.join(para_lines)
text_nodes = parse_markdown_inline(para_text)
if text_nodes:
nodes.append({
'id': node_id,
'type': 'PARAGRAPH',
'nodes': text_nodes,
})
# Ensure at least one node exists
# Wix API: omit empty data objects, don't include them as {}
if not nodes:
fallback_paragraph = {
nodes.append({
'id': str(uuid.uuid4()),
'type': 'PARAGRAPH',
'nodes': [{
'id': str(uuid.uuid4()),
'type': 'TEXT',
'nodes': [], # TEXT nodes must have empty nodes array per Wix API
'nodes': [],
'textData': {
'text': content[:500] if content else "This is a post from ALwrity.",
'decorations': []
}
}],
}
nodes.append(fallback_paragraph)
})
# Per Wix Blog API documentation: richContent should ONLY contain 'nodes'
# Do NOT include 'type', 'id', 'metadata', or 'documentStyle' at root level
# These fields are for Ricos Document format, but Blog API expects just the nodes structure
return {
'nodes': nodes
}
return {'nodes': nodes}