Merge branch 'main' into codex/implement-central-visibility-for-seo-onboarding-tasks

This commit is contained in:
ي
2026-03-08 23:13:08 +05:30
committed by GitHub
13 changed files with 557 additions and 194 deletions

View File

@@ -1,9 +1,13 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from datetime import datetime from datetime import datetime
import json
from enum import Enum
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import SQLAlchemyError
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
from services.database import get_db from services.database import get_db
@@ -15,6 +19,37 @@ from services.intelligence.txtai_service import TxtaiIntelligenceService
router = APIRouter(prefix="/api/today-workflow", tags=["Today Workflow"]) router = APIRouter(prefix="/api/today-workflow", tags=["Today Workflow"])
def _normalize_dependencies(dependencies: Any) -> list:
if dependencies is None:
return []
if isinstance(dependencies, list):
return dependencies
if isinstance(dependencies, str):
try:
parsed = json.loads(dependencies)
return parsed if isinstance(parsed, list) else []
except json.JSONDecodeError:
return []
return []
class TaskStatusEnum(str, Enum):
pending = "pending"
in_progress = "in_progress"
completed = "completed"
skipped = "skipped"
dismissed = "dismissed"
class TaskStatusUpdateRequest(BaseModel):
status: TaskStatusEnum = Field(..., description="New task status")
completion_notes: Optional[str] = Field(
None,
max_length=4000,
description="Optional notes about task completion or outcome",
)
async def _index_tasks_to_sif(user_id: str, date: str, tasks: list[dict], label: str): async def _index_tasks_to_sif(user_id: str, date: str, tasks: list[dict], label: str):
svc = TxtaiIntelligenceService(user_id) svc = TxtaiIntelligenceService(user_id)
items = [] items = []
@@ -73,7 +108,7 @@ async def get_today_workflow(
"status": "skipped" if t.status == "dismissed" else t.status, "status": "skipped" if t.status == "dismissed" else t.status,
"priority": t.priority, "priority": t.priority,
"estimatedTime": t.estimated_time, "estimatedTime": t.estimated_time,
"dependencies": t.dependencies or [], "dependencies": _normalize_dependencies(t.dependencies),
"actionUrl": t.action_url, "actionUrl": t.action_url,
"actionType": t.action_type, "actionType": t.action_type,
"metadata": t.metadata_json or {}, "metadata": t.metadata_json or {},
@@ -100,10 +135,20 @@ async def get_today_workflow(
if created: if created:
asyncio.create_task(_index_tasks_to_sif(user_id, plan.date, response_tasks, label="today")) asyncio.create_task(_index_tasks_to_sif(user_id, plan.date, response_tasks, label="today"))
try: from datetime import date as date_type, timedelta
from datetime import date as date_type, timedelta
y_str = (date_type.fromisoformat(plan.date) - timedelta(days=1)).isoformat() try:
parsed_plan_date = date_type.fromisoformat(plan.date)
except ValueError:
logger.warning(
"Invalid plan.date format; skipping yesterday indexing plan_id={} user_id={} plan_date={} reason={}",
plan.id,
user_id,
plan.date,
"plan.date is not in ISO format YYYY-MM-DD",
)
else:
y_str = (parsed_plan_date - timedelta(days=1)).isoformat()
def _fetch_yesterday(): def _fetch_yesterday():
y_plan = ( y_plan = (
@@ -121,23 +166,33 @@ async def get_today_workflow(
return y_tasks return y_tasks
return [] return []
y_tasks = await run_in_threadpool(_fetch_yesterday) try:
y_tasks = await run_in_threadpool(_fetch_yesterday)
if y_tasks: except SQLAlchemyError as db_error:
y_response = [] logger.warning(
for t in y_tasks: "Failed to fetch yesterday tasks; skipping yesterday indexing plan_id={} user_id={} plan_date={} yesterday_date={} error_class={} error_message={}",
y_response.append( plan.id,
{ user_id,
"id": str(t.id), plan.date,
"pillarId": t.pillar_id, y_str,
"title": t.title, type(db_error).__name__,
"description": t.description, str(db_error),
"status": "skipped" if t.status == "dismissed" else t.status, )
} else:
) if y_tasks:
asyncio.create_task(_index_tasks_to_sif(user_id, y_str, y_response, label="yesterday")) y_response = []
except Exception: for t in y_tasks:
pass y_response.append(
{
"id": str(t.id),
"pillarId": t.pillar_id,
"title": t.title,
"description": t.description,
"status": "skipped" if t.status == "dismissed" else t.status,
"dependencies": _normalize_dependencies(t.dependencies),
}
)
asyncio.create_task(_index_tasks_to_sif(user_id, y_str, y_response, label="yesterday"))
return { return {
"success": True, "success": True,
@@ -158,6 +213,8 @@ async def get_today_workflow(
"id": plan.id, "id": plan.id,
"date": plan.date, "date": plan.date,
"source": plan.source, "source": plan.source,
"quality_status": (plan.plan_json or {}).get("quality_status", "contextual"),
"contextuality_validation": (plan.plan_json or {}).get("contextuality_validation"),
"created_at": plan.created_at.isoformat() if plan.created_at else None, "created_at": plan.created_at.isoformat() if plan.created_at else None,
"updated_at": plan.updated_at.isoformat() if plan.updated_at else None, "updated_at": plan.updated_at.isoformat() if plan.updated_at else None,
}, },
@@ -172,15 +229,13 @@ from services.task_memory_service import TaskMemoryService
@router.post("/tasks/{task_id}/status") @router.post("/tasks/{task_id}/status")
async def set_task_status( async def set_task_status(
task_id: int, task_id: int,
body: Dict[str, Any], body: TaskStatusUpdateRequest,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> Dict[str, Any]: ) -> Dict[str, Any]:
user_id = str(current_user.get("id")) user_id = str(current_user.get("id"))
status = body.get("status") status = body.status.value
if not status: completion_notes = body.completion_notes
raise HTTPException(status_code=400, detail="status is required")
completion_notes = body.get("completion_notes")
task = update_task_status(db, user_id, task_id, status=status, completion_notes=completion_notes) task = update_task_status(db, user_id, task_id, status=status, completion_notes=completion_notes)
if not task: if not task:
@@ -189,10 +244,18 @@ async def set_task_status(
# Record outcome in memory for self-learning # Record outcome in memory for self-learning
try: try:
memory = TaskMemoryService(user_id, db) memory = TaskMemoryService(user_id, db)
normalized_status = (task.status or "").lower()
if normalized_status == "completed":
feedback_score = 1
elif normalized_status in {"skipped", "dismissed", "rejected"}:
feedback_score = -1
else:
feedback_score = 0
await memory.record_task_outcome( await memory.record_task_outcome(
task, task,
feedback_score=1 if status == "completed" else -1 if status == "dismissed" else 0, feedback_score=feedback_score,
feedback_text=completion_notes feedback_text=completion_notes,
) )
except Exception as e: except Exception as e:
logger.warning( logger.warning(

View File

@@ -13,6 +13,9 @@ class DailyWorkflowPlan(Base):
user_id = Column(String(255), nullable=False, index=True) user_id = Column(String(255), nullable=False, index=True)
date = Column(String(10), nullable=False, index=True) date = Column(String(10), nullable=False, index=True)
source = Column(String(30), nullable=False, default="agent") source = Column(String(30), nullable=False, default="agent")
generation_mode = Column(String(30), nullable=False, default="llm_generation", index=True)
committee_agent_count = Column(Integer, nullable=False, default=0)
fallback_used = Column(Boolean, nullable=False, default=False)
plan_json = Column(JSON, nullable=True) plan_json = Column(JSON, nullable=True)
generation_run_id = Column(Integer, nullable=True, index=True) generation_run_id = Column(Integer, nullable=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True) created_at = Column(DateTime, default=datetime.utcnow, index=True)

View File

@@ -1,5 +1,7 @@
# Core dependencies # Core dependencies
fastapi>=0.104.0 fastapi>=0.115.14
starlette>=0.40.0,<0.47.0
sse-starlette<3.0.0
uvicorn>=0.24.0 uvicorn>=0.24.0
python-multipart>=0.0.6 python-multipart>=0.0.6
python-dotenv>=1.0.0 python-dotenv>=1.0.0

View File

@@ -464,6 +464,7 @@ class AgentOrchestrationService:
async def get_or_create_orchestrator(self, user_id: str) -> ALwrityAgentOrchestrator: async def get_or_create_orchestrator(self, user_id: str) -> ALwrityAgentOrchestrator:
"""Get or create an orchestrator for a user""" """Get or create an orchestrator for a user"""
onboarding_gated_initialization = False
if user_id not in self.orchestrators: if user_id not in self.orchestrators:
config = AgentTeamConfiguration(user_id=user_id) config = AgentTeamConfiguration(user_id=user_id)
self.orchestrators[user_id] = ALwrityAgentOrchestrator(config) self.orchestrators[user_id] = ALwrityAgentOrchestrator(config)
@@ -475,6 +476,25 @@ class AgentOrchestrationService:
logger.info(f"Orchestrator for {user_id} has no agents. Attempting re-initialization.") logger.info(f"Orchestrator for {user_id} has no agents. Attempting re-initialization.")
orchestrator._create_specialized_agents() orchestrator._create_specialized_agents()
last_system_check = next(
(
entry
for entry in reversed(orchestrator.execution_history)
if entry.get("action") == "system_check"
),
None,
)
if last_system_check and last_system_check.get("status") == "pending":
details = str(last_system_check.get("details") or "").lower()
onboarding_gated_initialization = "onboarding" in details
orchestrator.onboarding_gated_initialization = onboarding_gated_initialization
orchestrator.initialization_state = {
"onboarding_gated_initialization": onboarding_gated_initialization,
"active_agent_count": len(orchestrator.agents),
"active_agent_keys": sorted(orchestrator.agents.keys()),
}
return orchestrator return orchestrator
async def execute_marketing_strategy(self, user_id: str, market_context: Dict[str, Any]) -> Dict[str, Any]: async def execute_marketing_strategy(self, user_id: str, market_context: Dict[str, Any]) -> Dict[str, Any]:

View File

@@ -81,7 +81,7 @@ class OnboardingFullWebsiteAnalysisExecutor(TaskExecutor):
task.last_executed = datetime.utcnow() task.last_executed = datetime.utcnow()
task.last_success = datetime.utcnow() task.last_success = datetime.utcnow()
task.status = 'paused' task.status = 'completed' # Explicitly mark as completed instead of paused
task.next_execution = None task.next_execution = None
task.consecutive_failures = 0 task.consecutive_failures = 0
task.failure_pattern = None task.failure_pattern = None

View File

@@ -15,7 +15,7 @@ from services.intelligence.txtai_service import TxtaiIntelligenceService
EXACT_DUPLICATE_LOOKBACK_DAYS = 7 EXACT_DUPLICATE_LOOKBACK_DAYS = 7
SEMANTIC_SUPPRESSION_SCORE_THRESHOLD = 0.85 SEMANTIC_SUPPRESSION_SCORE_THRESHOLD = 0.85
SUPPRESSED_STATUSES = {"dismissed", "rejected"} SUPPRESSED_STATUSES = {"dismissed", "rejected", "skipped"}
class TaskMemoryService: class TaskMemoryService:
""" """
@@ -72,7 +72,7 @@ class TaskMemoryService:
self.db.commit() self.db.commit()
# 2. Index into txtai (if status is meaningful) # 2. Index into txtai (if status is meaningful)
if task.status in ["completed", "dismissed", "rejected"]: if task.status in ["completed", "dismissed", "rejected", "skipped"]:
# We index the task text with metadata about its outcome # We index the task text with metadata about its outcome
# This allows us to search: "Has the user rejected similar tasks?" # This allows us to search: "Has the user rejected similar tasks?"
doc = { doc = {

View File

@@ -11,6 +11,8 @@ from services.llm_providers.main_text_generation import llm_text_gen
from loguru import logger from loguru import logger
PILLAR_IDS = ["plan", "generate", "publish", "analyze", "engage", "remarket"] PILLAR_IDS = ["plan", "generate", "publish", "analyze", "engage", "remarket"]
MIN_TASK_EVIDENCE_LINKS = 1
PLAN_CONTEXT_THRESHOLD = 0.65
def _today_date_str() -> str: def _today_date_str() -> str:
@@ -139,6 +141,116 @@ def _sanitize_task(task: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return sanitized return sanitized
def _derive_onboarding_evidence_links(onboarding_data: Dict[str, Any], limit: int = 2) -> List[str]:
if not isinstance(onboarding_data, dict):
return []
links: List[str] = []
for key, value in onboarding_data.items():
if key == "workflow_config":
continue
if value in (None, "", [], {}):
continue
links.append(f"onboarding:{key}")
if len(links) >= limit:
break
return links
def _valid_evidence_links(evidence_links: Any, grounding: Dict[str, Any]) -> List[str]:
if not isinstance(evidence_links, list):
return []
onboarding_data = grounding.get("onboarding_data", {}) if isinstance(grounding, dict) else {}
if not isinstance(onboarding_data, dict):
onboarding_data = {}
valid_onboarding_keys = {str(k) for k in onboarding_data.keys()}
recent_alerts = grounding.get("recent_agent_alerts", []) if isinstance(grounding, dict) else []
valid_alert_ids = {
str(a.get("alert_id"))
for a in recent_alerts
if isinstance(a, dict) and a.get("alert_id") is not None
}
valid_links: List[str] = []
for raw in evidence_links:
link = str(raw or "").strip()
if not link:
continue
if link.startswith("onboarding:"):
key = link.split(":", 1)[1].strip()
if key and key in valid_onboarding_keys:
valid_links.append(link)
elif link.startswith("alert:"):
alert_id = link.split(":", 1)[1].strip()
if alert_id and alert_id in valid_alert_ids:
valid_links.append(link)
return valid_links
def validate_plan_contextuality(plan: Dict[str, Any], grounding: Dict[str, Any]) -> Dict[str, Any]:
tasks = plan.get("tasks") if isinstance(plan, dict) else None
if not isinstance(tasks, list) or not tasks:
return {
"score": 0.0,
"threshold": PLAN_CONTEXT_THRESHOLD,
"is_contextual": False,
"task_scores": [],
"tasks_below_min_evidence": 0,
"min_evidence_links": MIN_TASK_EVIDENCE_LINKS,
}
task_scores = []
below_min_evidence = 0
for idx, task in enumerate(tasks):
metadata = task.get("metadata") if isinstance(task, dict) else {}
metadata = metadata if isinstance(metadata, dict) else {}
evidence_links = _valid_evidence_links(metadata.get("evidence_links"), grounding)
has_min_evidence = len(evidence_links) >= MIN_TASK_EVIDENCE_LINKS
if not has_min_evidence:
below_min_evidence += 1
reasoning_text = str(metadata.get("reasoning") or task.get("description") or "").lower()
onboarding_hits = sum(1 for l in evidence_links if l.startswith("onboarding:"))
alert_hits = sum(1 for l in evidence_links if l.startswith("alert:"))
score = 0.0
if has_min_evidence:
score += 0.6
if onboarding_hits > 0:
score += 0.2
if alert_hits > 0:
score += 0.2
elif "alert" in reasoning_text:
score += 0.1
task_scores.append(
{
"task_index": idx,
"pillarId": task.get("pillarId"),
"title": task.get("title"),
"score": min(score, 1.0),
"evidence_links": evidence_links,
"has_min_evidence": has_min_evidence,
}
)
plan_score = sum(t["score"] for t in task_scores) / len(task_scores)
is_contextual = plan_score >= PLAN_CONTEXT_THRESHOLD and below_min_evidence == 0
return {
"score": round(plan_score, 3),
"threshold": PLAN_CONTEXT_THRESHOLD,
"is_contextual": is_contextual,
"task_scores": task_scores,
"tasks_below_min_evidence": below_min_evidence,
"min_evidence_links": MIN_TASK_EVIDENCE_LINKS,
}
def _build_single_task_for_missing_pillar( def _build_single_task_for_missing_pillar(
user_id: str, user_id: str,
date: str, date: str,
@@ -253,6 +365,7 @@ def build_grounding_context(db: Session, user_id: str, date: str) -> Dict[str, A
return { return {
"recent_agent_alerts": [ "recent_agent_alerts": [
{ {
"alert_id": a.id,
"title": a.title, "title": a.title,
"message": a.message, "message": a.message,
"created_at": a.created_at.isoformat(), "created_at": a.created_at.isoformat(),
@@ -272,9 +385,15 @@ from services.task_memory_service import TaskMemoryService
# Initialize orchestration service (singleton) # Initialize orchestration service (singleton)
orchestration_service = AgentOrchestrationService() orchestration_service = AgentOrchestrationService()
async def generate_agent_enhanced_plan(db: Session, user_id: str, date: str) -> Dict[str, Any]: async def generate_agent_enhanced_plan(
db: Session,
user_id: str,
date: str,
grounding: Optional[Dict[str, Any]] = None,
strict_contextuality: bool = False,
) -> Dict[str, Any]:
activity = AgentActivityService(db, user_id) activity = AgentActivityService(db, user_id)
grounding = build_grounding_context(db, user_id, date) grounding = grounding or build_grounding_context(db, user_id, date)
memory_service = TaskMemoryService(user_id, db) memory_service = TaskMemoryService(user_id, db)
# 1. Get Orchestrator # 1. Get Orchestrator
@@ -351,7 +470,7 @@ async def generate_agent_enhanced_plan(db: Session, user_id: str, date: str) ->
# 4. Final Selection # 4. Final Selection
# If we have agent tasks, use them. Otherwise fall back to LLM generation. # If we have agent tasks, use them. Otherwise fall back to LLM generation.
if agent_tasks: if agent_tasks and not strict_contextuality:
logger.info(f"Generated {len(agent_tasks)} tasks via Agent Committee") logger.info(f"Generated {len(agent_tasks)} tasks via Agent Committee")
# Convert TaskProposal objects to dicts for frontend # Convert TaskProposal objects to dicts for frontend
@@ -369,7 +488,8 @@ async def generate_agent_enhanced_plan(db: Session, user_id: str, date: str) ->
"metadata": { "metadata": {
"source_agent": prop.source_agent, "source_agent": prop.source_agent,
"reasoning": prop.reasoning, "reasoning": prop.reasoning,
"context_data": prop.context_data "context_data": prop.context_data,
"evidence_links": _derive_onboarding_evidence_links(grounding.get("onboarding_data", {}), limit=2),
} }
}) })
@@ -425,6 +545,15 @@ async def generate_agent_enhanced_plan(db: Session, user_id: str, date: str) ->
f"Grounding context (Alerts):\n{json.dumps(grounding.get('recent_agent_alerts', []), indent=2)}\n" f"Grounding context (Alerts):\n{json.dumps(grounding.get('recent_agent_alerts', []), indent=2)}\n"
) )
if strict_contextuality:
prompt += (
"\nStrict contextuality mode (must follow):\n"
f"- Every task.metadata must include evidence_links with at least {MIN_TASK_EVIDENCE_LINKS} entries.\n"
"- evidence_links entries must use either 'onboarding:<field_name>' or 'alert:<alert_id>' format.\n"
"- Include metadata.reasoning that explains how the evidence applies to the task.\n"
"- Reject generic tasks without explicit ties to onboarding data or active alerts.\n"
)
run = activity.start_run(agent_type="TodayWorkflowGenerator", prompt=prompt[:4000]) run = activity.start_run(agent_type="TodayWorkflowGenerator", prompt=prompt[:4000])
activity.log_event( activity.log_event(
event_type="plan", event_type="plan",
@@ -492,7 +621,25 @@ async def get_or_create_daily_workflow_plan(db: Session, user_id: str, date: Opt
if existing: if existing:
return existing, False return existing, False
plan_data = await generate_agent_enhanced_plan(db, user_id, date_str) grounding = build_grounding_context(db, user_id, date_str)
plan_data = await generate_agent_enhanced_plan(db, user_id, date_str, grounding=grounding)
validation = validate_plan_contextuality(plan_data, grounding)
if not validation.get("is_contextual"):
logger.info("Plan contextuality below threshold for user {}. Running strict regeneration.", user_id)
regenerated_plan = await generate_agent_enhanced_plan(
db,
user_id,
date_str,
grounding=grounding,
strict_contextuality=True,
)
regenerated_validation = validate_plan_contextuality(regenerated_plan, grounding)
plan_data = regenerated_plan
validation = regenerated_validation
plan_data["quality_status"] = "contextual" if validation.get("is_contextual") else "low_context"
plan_data["contextuality_validation"] = validation
tasks = plan_data.get("tasks", []) tasks = plan_data.get("tasks", [])
def _create_plan(): def _create_plan():

View File

@@ -8,7 +8,7 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from services.intelligence.monitoring.semantic_dashboard import RealTimeSemanticMonitor, SemanticHealthMetric from services.intelligence.monitoring.semantic_dashboard import RealTimeSemanticMonitor, SemanticHealthMetric
from services.today_workflow_service import _ensure_pillar_coverage, PILLAR_IDS from services.today_workflow_service import _ensure_pillar_coverage, PILLAR_IDS, validate_plan_contextuality
from services.intelligence.sif_agents import ContentGuardianAgent as SifGuardian from services.intelligence.sif_agents import ContentGuardianAgent as SifGuardian
from services.intelligence.agents.specialized_agents import ContentGuardianAgent as SpecializedGuardian from services.intelligence.agents.specialized_agents import ContentGuardianAgent as SpecializedGuardian
@@ -74,6 +74,52 @@ class SIFReleaseReadinessTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("warning", result) self.assertIn("warning", result)
self.assertEqual(result["method"], "competitor_index_search") self.assertEqual(result["method"], "competitor_index_search")
def test_validate_plan_contextuality_passes_with_evidence_links(self):
plan = {
"tasks": [
{
"pillarId": "plan",
"title": "Review strategy",
"description": "Use onboarding goals",
"metadata": {
"evidence_links": ["onboarding:business_goals", "alert:101"],
"reasoning": "Based on onboarding and alert",
},
}
]
}
grounding = {
"onboarding_data": {"business_goals": ["awareness"]},
"recent_agent_alerts": [{"alert_id": 101, "title": "Drop in traffic"}],
}
validation = validate_plan_contextuality(plan, grounding)
self.assertTrue(validation["is_contextual"])
self.assertEqual(validation["tasks_below_min_evidence"], 0)
def test_validate_plan_contextuality_flags_missing_evidence_links(self):
plan = {
"tasks": [
{
"pillarId": "generate",
"title": "Write generic post",
"description": "Create a post",
"metadata": {"reasoning": "General best practice"},
}
]
}
grounding = {
"onboarding_data": {"business_goals": ["awareness"]},
"recent_agent_alerts": [{"alert_id": 101, "title": "Drop in traffic"}],
}
validation = validate_plan_contextuality(plan, grounding)
self.assertFalse(validation["is_contextual"])
self.assertEqual(validation["tasks_below_min_evidence"], 1)
def test_pillar_coverage_guardrail_backfills_missing(self): def test_pillar_coverage_guardrail_backfills_missing(self):
tasks = [{"pillarId": "plan", "title": "Plan", "description": "d", "priority": "high", "estimatedTime": 10, "actionType": "navigate", "enabled": True}] tasks = [{"pillarId": "plan", "title": "Plan", "description": "d", "priority": "high", "estimatedTime": 10, "actionType": "navigate", "enabled": True}]
grounding = {"workflow_config": {"enforce_pillar_coverage": True}} grounding = {"workflow_config": {"enforce_pillar_coverage": True}}

View File

@@ -544,6 +544,7 @@ const App: React.FC = () => {
// Get environment variables with fallbacks // Get environment variables with fallbacks
const clerkPublishableKey = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY || ''; const clerkPublishableKey = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY || '';
const clerkJSUrl = process.env.REACT_APP_CLERK_JS_URL;
// Show error if required keys are missing // Show error if required keys are missing
if (!clerkPublishableKey) { if (!clerkPublishableKey) {
@@ -654,7 +655,7 @@ const App: React.FC = () => {
// TODO: Send to error tracking service (Sentry, LogRocket, etc.) // TODO: Send to error tracking service (Sentry, LogRocket, etc.)
}} }}
> >
<ClerkProvider publishableKey={clerkPublishableKey}> <ClerkProvider publishableKey={clerkPublishableKey} clerkJSUrl={clerkJSUrl}>
<SubscriptionProvider> <SubscriptionProvider>
<OnboardingProvider> <OnboardingProvider>
{renderApp()} {renderApp()}

View File

@@ -14,7 +14,8 @@ import {
Pause, Pause,
CheckCircle, CheckCircle,
Schedule, Schedule,
TrendingUp TrendingUp,
CloudOff
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useWorkflowStore } from '../../../stores/workflowStore'; import { useWorkflowStore } from '../../../stores/workflowStore';
@@ -42,7 +43,9 @@ const WorkflowProgressBar: React.FC<WorkflowProgressBarProps> = ({
startWorkflow, startWorkflow,
isWorkflowComplete, isWorkflowComplete,
getCompletionPercentage, getCompletionPercentage,
generateDailyWorkflow generateDailyWorkflow,
isDegradedMode,
degradedModeReason
} = useWorkflowStore(); } = useWorkflowStore();
const completionPercentage = getCompletionPercentage(); const completionPercentage = getCompletionPercentage();
@@ -79,6 +82,15 @@ const WorkflowProgressBar: React.FC<WorkflowProgressBarProps> = ({
return 'Ready to Start'; return 'Ready to Start';
}; };
const getProvenanceLabel = () => {
const summary = currentWorkflow?.provenanceSummary;
if (!summary) return 'Daily Workflow';
if (summary.generationMode === 'agent_committee') return 'Personalized by Agents';
if (summary.generationMode === 'llm_generation' && !summary.fallbackUsed) return 'AI Personalized Guide';
if (summary.fallbackUsed || summary.generationMode === 'controlled_fallback') return 'Baseline Daily Guide';
return 'Daily Workflow';
};
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, y: -20 }} initial={{ opacity: 0, y: -20 }}
@@ -125,6 +137,16 @@ const WorkflowProgressBar: React.FC<WorkflowProgressBarProps> = ({
fontWeight: 600 fontWeight: 600
}} }}
/> />
<Chip
label={getProvenanceLabel()}
size="small"
sx={{
background: 'rgba(255,255,255,0.08)',
color: 'rgba(255,255,255,0.9)',
border: '1px solid rgba(255,255,255,0.2)',
fontWeight: 600
}}
/>
</Box> </Box>
{/* Controls */} {/* Controls */}
@@ -169,6 +191,30 @@ const WorkflowProgressBar: React.FC<WorkflowProgressBarProps> = ({
)} )}
</Box> </Box>
{isDegradedMode && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 2,
p: 1.5,
borderRadius: 1,
border: `1px solid ${theme.palette.warning.main}55`,
bgcolor: `${theme.palette.warning.main}18`,
}}
>
<CloudOff sx={{ color: theme.palette.warning.light, fontSize: 18 }} />
<Typography variant="body2" sx={{ color: theme.palette.warning.light, fontWeight: 600 }}>
Degraded mode
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.75)' }}>
{degradedModeReason || 'Server workflow is unavailable; local fallback is active.'}
</Typography>
</Box>
)}
{/* Progress Bar */} {/* Progress Bar */}
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>

View File

@@ -311,18 +311,15 @@ class TaskWorkflowOrchestrator {
date: string, date: string,
context?: TaskGenerationContext context?: TaskGenerationContext
): Promise<TodayTask[]> { ): Promise<TodayTask[]> {
// This is a placeholder implementation
// In Phase 3, this will be replaced with AI-powered task generation
const defaultTasks: TodayTask[] = [ const defaultTasks: TodayTask[] = [
{ {
id: `${userId}-${date}-plan-1`, id: `${userId}-${date}-plan`,
pillarId: 'plan', pillarId: 'plan',
title: 'Review content strategy', title: 'Review today\'s plan',
description: 'Check and update your content strategy for the week', description: 'Confirm priorities and schedule for today\'s content work.',
status: 'pending', status: 'pending',
priority: 'high', priority: 'high',
estimatedTime: 15, estimatedTime: 10,
actionType: 'navigate', actionType: 'navigate',
actionUrl: '/content-planning-dashboard', actionUrl: '/content-planning-dashboard',
enabled: true, enabled: true,
@@ -330,29 +327,14 @@ class TaskWorkflowOrchestrator {
color: '#4CAF50' color: '#4CAF50'
}, },
{ {
id: `${userId}-${date}-plan-2`, id: `${userId}-${date}-generate`,
pillarId: 'plan', pillarId: 'generate',
title: 'Update content calendar', title: 'Generate a draft',
description: 'Review and update your content calendar', description: 'Create one content draft using the content writer.',
status: 'pending', status: 'pending',
priority: 'medium', priority: 'medium',
estimatedTime: 10, estimatedTime: 20,
dependencies: [`${userId}-${date}-plan-1`], dependencies: [`${userId}-${date}-plan`],
actionType: 'navigate',
actionUrl: '/content-planning-dashboard',
enabled: true,
icon: 'CalendarMonth',
color: '#4CAF50'
},
{
id: `${userId}-${date}-generate-1`,
pillarId: 'generate',
title: 'Create social media content',
description: 'Generate content for your social media platforms',
status: 'pending',
priority: 'high',
estimatedTime: 30,
dependencies: [`${userId}-${date}-plan-1`, `${userId}-${date}-plan-2`],
actionType: 'navigate', actionType: 'navigate',
actionUrl: '/facebook-writer', actionUrl: '/facebook-writer',
enabled: true, enabled: true,
@@ -360,29 +342,14 @@ class TaskWorkflowOrchestrator {
color: '#2196F3' color: '#2196F3'
}, },
{ {
id: `${userId}-${date}-generate-2`, id: `${userId}-${date}-publish`,
pillarId: 'generate',
title: 'Create blog content',
description: 'Write blog posts for your website',
status: 'pending',
priority: 'medium',
estimatedTime: 45,
dependencies: [`${userId}-${date}-plan-1`],
actionType: 'navigate',
actionUrl: '/blog-writer',
enabled: true,
icon: 'Article',
color: '#2196F3'
},
{
id: `${userId}-${date}-publish-1`,
pillarId: 'publish', pillarId: 'publish',
title: 'Publish social media content', title: 'Publish approved content',
description: 'Publish your created content to social media', description: 'Open publishing tools and publish today\'s approved draft.',
status: 'pending', status: 'pending',
priority: 'medium', priority: 'high',
estimatedTime: 10, estimatedTime: 10,
dependencies: [`${userId}-${date}-generate-1`], dependencies: [`${userId}-${date}-generate`],
actionType: 'navigate', actionType: 'navigate',
actionUrl: '/facebook-writer', actionUrl: '/facebook-writer',
enabled: true, enabled: true,
@@ -390,29 +357,14 @@ class TaskWorkflowOrchestrator {
color: '#FF9800' color: '#FF9800'
}, },
{ {
id: `${userId}-${date}-publish-2`, id: `${userId}-${date}-analyze`,
pillarId: 'publish', pillarId: 'analyze',
title: 'Publish blog content', title: 'Check performance snapshot',
description: 'Publish blog posts to your website', description: 'Review key analytics to assess today\'s published content.',
status: 'pending', status: 'pending',
priority: 'medium', priority: 'medium',
estimatedTime: 15, estimatedTime: 10,
dependencies: [`${userId}-${date}-generate-2`], dependencies: [`${userId}-${date}-publish`],
actionType: 'navigate',
actionUrl: '/blog-writer',
enabled: true,
icon: 'Publish',
color: '#FF9800'
},
{
id: `${userId}-${date}-analyze-1`,
pillarId: 'analyze',
title: 'Review content performance',
description: 'Analyze performance of published content',
status: 'pending',
priority: 'low',
estimatedTime: 20,
dependencies: [`${userId}-${date}-publish-1`, `${userId}-${date}-publish-2`],
actionType: 'navigate', actionType: 'navigate',
actionUrl: '/analytics-dashboard', actionUrl: '/analytics-dashboard',
enabled: true, enabled: true,
@@ -420,95 +372,50 @@ class TaskWorkflowOrchestrator {
color: '#9C27B0' color: '#9C27B0'
}, },
{ {
id: `${userId}-${date}-engage-1`, id: `${userId}-${date}-engage`,
pillarId: 'engage', pillarId: 'engage',
title: 'Respond to comments', title: 'Respond to audience activity',
description: 'Engage with comments on your content', description: 'Reply to new comments or mentions from today\'s posts.',
status: 'pending', status: 'pending',
priority: 'low', priority: 'low',
estimatedTime: 15, estimatedTime: 10,
dependencies: [`${userId}-${date}-publish-1`], dependencies: [`${userId}-${date}-publish`],
actionType: 'navigate', actionType: 'navigate',
actionUrl: '/engagement-dashboard', actionUrl: '/engagement-dashboard',
enabled: true, enabled: true,
icon: 'ChatBubbleOutline', icon: 'ChatBubbleOutline',
color: '#E91E63' color: '#E91E63'
}, },
// Engage pillar tasks
{ {
id: `${userId}-${date}-engage-1`, id: `${userId}-${date}-remarket`,
pillarId: 'engage',
title: 'Reply to blog comment',
description: 'Respond to comments on your latest blog post',
status: 'pending',
priority: 'high',
estimatedTime: 10,
dependencies: [`${userId}-${date}-analyze-1`],
actionType: 'navigate',
actionUrl: '/engagement-dashboard',
enabled: true,
icon: 'Comment',
color: '#E91E63'
},
{
id: `${userId}-${date}-engage-2`,
pillarId: 'engage',
title: 'Respond to Twitter mention',
description: 'Reply to Twitter mentions and engage with followers',
status: 'pending',
priority: 'medium',
estimatedTime: 5,
dependencies: [`${userId}-${date}-engage-1`],
actionType: 'navigate',
actionUrl: '/engagement-dashboard',
enabled: true,
icon: 'Twitter',
color: '#1DA1F2'
},
// Remarket pillar tasks
{
id: `${userId}-${date}-remarket-1`,
pillarId: 'remarket', pillarId: 'remarket',
title: 'Launch Retargeting Campaign', title: 'Prepare remarketing audience',
description: 'Create and launch targeted remarketing campaigns', description: 'Open remarketing tools to refresh your retargeting audience.',
status: 'pending', status: 'pending',
priority: 'high', priority: 'low',
estimatedTime: 35, estimatedTime: 15,
dependencies: [`${userId}-${date}-engage-2`], dependencies: [`${userId}-${date}-analyze`],
actionType: 'navigate', actionType: 'navigate',
actionUrl: '/remarketing-dashboard', actionUrl: '/remarketing-dashboard',
enabled: true, enabled: true,
icon: 'Psychology', icon: 'Psychology',
color: '#00695C' color: '#00695C'
},
{
id: `${userId}-${date}-remarket-2`,
pillarId: 'remarket',
title: 'Lead Nurturing Sequence',
description: 'Set up automated lead nurturing workflows',
status: 'pending',
priority: 'medium',
estimatedTime: 30,
dependencies: [`${userId}-${date}-remarket-1`],
actionType: 'navigate',
actionUrl: '/lead-nurturing',
enabled: true,
icon: 'Refresh',
color: '#4CAF50'
} }
]; ];
const uniqueTasks = this.ensureUniqueTaskIds(defaultTasks);
// Validate dependencies and get optimal execution order // Validate dependencies and get optimal execution order
const tempWorkflow: DailyWorkflow = { const tempWorkflow: DailyWorkflow = {
id: `${userId}-${date}`, id: `${userId}-${date}`,
date, date,
userId, userId,
tasks: defaultTasks, tasks: uniqueTasks,
currentTaskIndex: 0, currentTaskIndex: 0,
completedTasks: 0, completedTasks: 0,
totalTasks: defaultTasks.length, totalTasks: uniqueTasks.length,
workflowStatus: 'not_started', workflowStatus: 'not_started',
totalEstimatedTime: defaultTasks.reduce((sum, task) => sum + task.estimatedTime, 0), totalEstimatedTime: uniqueTasks.reduce((sum, task) => sum + task.estimatedTime, 0),
actualTimeSpent: 0 actualTimeSpent: 0
}; };
@@ -517,13 +424,46 @@ class TaskWorkflowOrchestrator {
if (!validation.isValid) { if (!validation.isValid) {
console.warn('Dependency validation failed:', validation.errors); console.warn('Dependency validation failed:', validation.errors);
// Return tasks without dependencies if validation fails // Return tasks without dependencies if validation fails
return defaultTasks.map(task => ({ ...task, dependencies: [] })); return uniqueTasks.map(task => ({ ...task, dependencies: [] }));
} }
// Get optimal execution order // Get optimal execution order
const orderedTasks = taskDependencyManager.getOptimalExecutionOrder(tempWorkflow); const orderedTasks = taskDependencyManager.getOptimalExecutionOrder(tempWorkflow);
return orderedTasks; return this.ensureUniqueTaskIds(orderedTasks);
}
private ensureUniqueTaskIds(tasks: TodayTask[]): TodayTask[] {
const idOccurrences = new Map<string, number>();
const oldToNew = new Map<string, string>();
const withUniqueIds = tasks.map(task => {
const count = idOccurrences.get(task.id) ?? 0;
idOccurrences.set(task.id, count + 1);
if (count === 0) {
oldToNew.set(task.id, task.id);
return { ...task };
}
const uniqueId = `${task.id}-${count + 1}`;
oldToNew.set(`${task.id}#${count + 1}`, uniqueId);
return { ...task, id: uniqueId };
});
const allTaskIds = new Set(withUniqueIds.map(task => task.id));
return withUniqueIds.map(task => {
const dependencies = (task.dependencies ?? [])
.map(dep => oldToNew.get(dep) || dep)
.filter((dep, index, arr) => arr.indexOf(dep) === index)
.filter(dep => allTaskIds.has(dep));
return {
...task,
dependencies: dependencies.length > 0 ? dependencies : undefined
};
});
} }
/** /**

View File

@@ -10,10 +10,58 @@ import {
WorkflowError WorkflowError
} from '../types/workflow'; } from '../types/workflow';
import { taskWorkflowOrchestrator } from '../services/TaskWorkflowOrchestrator'; import { taskWorkflowOrchestrator } from '../services/TaskWorkflowOrchestrator';
import { apiClient } from '../api/client'; import { apiClient, ConnectionError, NetworkError } from '../api/client';
const isServerWorkflowId = (workflowId: string) => workflowId.startsWith('daily-'); const isServerWorkflowId = (workflowId: string) => workflowId.startsWith('daily-');
const normalizeDependencies = (dependencies: unknown): string[] => {
if (Array.isArray(dependencies)) {
return dependencies.map(dep => String(dep).trim()).filter(Boolean);
}
if (typeof dependencies === 'string') {
const raw = dependencies.trim();
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map(dep => String(dep).trim()).filter(Boolean);
}
} catch {}
return [raw];
}
return [];
};
const normalizeServerWorkflow = (workflow: DailyWorkflow): DailyWorkflow => ({
...workflow,
tasks: Array.isArray(workflow.tasks)
? workflow.tasks.map(task => ({
...task,
dependencies: normalizeDependencies(task.dependencies),
}))
: [],
});
const isServerUnavailableError = (error: unknown): boolean =>
error instanceof ConnectionError || error instanceof NetworkError;
const toWorkflowError = (error: unknown, fallbackMessage: string): WorkflowError => {
if (error instanceof WorkflowError) return error;
const message = error instanceof Error ? error.message : fallbackMessage;
return {
code: 'WORKFLOW_ERROR',
message,
timestamp: new Date(),
recoverable: false,
};
};
const computeProgressAndNavigation = (workflow: DailyWorkflow): { progress: WorkflowProgress; navigation: NavigationState } => { const computeProgressAndNavigation = (workflow: DailyWorkflow): { progress: WorkflowProgress; navigation: NavigationState } => {
const tasks = Array.isArray(workflow.tasks) ? workflow.tasks : []; const tasks = Array.isArray(workflow.tasks) ? workflow.tasks : [];
const totalTasks = tasks.length; const totalTasks = tasks.length;
@@ -69,6 +117,8 @@ interface WorkflowState {
isWorkflowModalOpen: boolean; isWorkflowModalOpen: boolean;
isLoading: boolean; isLoading: boolean;
error: WorkflowError | null; error: WorkflowError | null;
isDegradedMode: boolean;
degradedModeReason: string | null;
// Actions // Actions
generateDailyWorkflow: (userId: string, date?: string) => Promise<void>; generateDailyWorkflow: (userId: string, date?: string) => Promise<void>;
@@ -108,36 +158,71 @@ export const useWorkflowStore = create<WorkflowState>()(
isWorkflowModalOpen: false, isWorkflowModalOpen: false,
isLoading: false, isLoading: false,
error: null, error: null,
isDegradedMode: false,
degradedModeReason: null,
// Generate daily workflow // Generate daily workflow
generateDailyWorkflow: async (userId: string, date?: string) => { generateDailyWorkflow: async (userId: string, date?: string) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
try { const resp = await apiClient.get('/api/today-workflow', { params: date ? { date } : {} });
const resp = await apiClient.get('/api/today-workflow', { params: date ? { date } : {} }); const serverWorkflow = resp?.data?.data?.workflow as DailyWorkflow | undefined;
const serverWorkflow = resp?.data?.data?.workflow as DailyWorkflow | undefined; const planSummary = resp?.data?.data?.plan?.provenance_summary;
if (serverWorkflow && Array.isArray(serverWorkflow.tasks)) {
const derived = computeProgressAndNavigation(serverWorkflow);
set({
currentWorkflow: serverWorkflow,
workflowProgress: derived.progress,
navigationState: derived.navigation,
isLoading: false
});
return;
}
} catch {}
if (serverWorkflow && Array.isArray(serverWorkflow.tasks)) {
if (planSummary && !serverWorkflow.provenanceSummary) {
serverWorkflow.provenanceSummary = planSummary;
}
const normalizedWorkflow = normalizeServerWorkflow(serverWorkflow);
const derived = computeProgressAndNavigation(normalizedWorkflow);
set({
currentWorkflow: normalizedWorkflow,
workflowProgress: derived.progress,
navigationState: derived.navigation,
isLoading: false,
isDegradedMode: false,
degradedModeReason: null,
});
return;
}
throw new WorkflowError({
code: 'WORKFLOW_SCHEMA_INVALID',
message: 'Server workflow response is missing a valid tasks array.',
timestamp: new Date(),
recoverable: false,
suggestedAction: 'Refresh and try again. If this persists, contact support.'
});
} catch (error) {
if (!isServerUnavailableError(error)) {
set({
error: toWorkflowError(error, 'Failed to load workflow from server.'),
isLoading: false,
isDegradedMode: false,
degradedModeReason: null,
});
return;
}
}
try {
const workflow = await taskWorkflowOrchestrator.generateDailyWorkflow(userId, date); const workflow = await taskWorkflowOrchestrator.generateDailyWorkflow(userId, date);
const progress = taskWorkflowOrchestrator.getWorkflowProgress(workflow.id); const progress = taskWorkflowOrchestrator.getWorkflowProgress(workflow.id);
const navigation = taskWorkflowOrchestrator.getNavigationState(workflow.id); const navigation = taskWorkflowOrchestrator.getNavigationState(workflow.id);
set({ currentWorkflow: workflow, workflowProgress: progress, navigationState: navigation, isLoading: false });
} catch (error) {
const workflowError = error as WorkflowError;
set({ set({
error: workflowError, currentWorkflow: workflow,
isLoading: false workflowProgress: progress,
navigationState: navigation,
isLoading: false,
isDegradedMode: true,
degradedModeReason: 'Server workflow unavailable. Using local fallback workflow.',
error: null,
});
} catch (error) {
set({
error: toWorkflowError(error, 'Failed to generate local fallback workflow.'),
isLoading: false,
}); });
} }
}, },

View File

@@ -5,6 +5,14 @@ export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'skipped';
export type TaskPriority = 'high' | 'medium' | 'low'; export type TaskPriority = 'high' | 'medium' | 'low';
export type ActionType = 'navigate' | 'modal' | 'external'; export type ActionType = 'navigate' | 'modal' | 'external';
export type WorkflowStatus = 'not_started' | 'in_progress' | 'completed' | 'paused' | 'stopped'; export type WorkflowStatus = 'not_started' | 'in_progress' | 'completed' | 'paused' | 'stopped';
export type WorkflowGenerationMode = 'agent_committee' | 'llm_generation' | 'llm_pillar_backfill' | 'controlled_fallback';
export interface WorkflowProvenanceSummary {
generationMode: WorkflowGenerationMode;
committeeAgentCount: number;
fallbackUsed: boolean;
taskSourceBreakdown: Partial<Record<WorkflowGenerationMode, number>>;
}
export interface TodayTask { export interface TodayTask {
id: string; id: string;
@@ -44,6 +52,7 @@ export interface DailyWorkflow {
completedAt?: Date; completedAt?: Date;
totalEstimatedTime: number; // in minutes totalEstimatedTime: number; // in minutes
actualTimeSpent: number; // in minutes actualTimeSpent: number; // in minutes
provenanceSummary?: WorkflowProvenanceSummary;
} }
export interface WorkflowProgress { export interface WorkflowProgress {
@@ -54,6 +63,7 @@ export interface WorkflowProgress {
nextTask?: TodayTask; nextTask?: TodayTask;
estimatedTimeRemaining: number; // in minutes estimatedTimeRemaining: number; // in minutes
actualTimeSpent: number; // in minutes actualTimeSpent: number; // in minutes
provenanceSummary?: WorkflowProvenanceSummary;
} }
export interface TaskCompletionData { export interface TaskCompletionData {