import json from datetime import datetime, timezone from typing import Any, Dict, List, Optional from sqlalchemy.orm import Session from models.daily_workflow_models import DailyWorkflowPlan, DailyWorkflowTask from models.agent_activity_models import AgentAlert from models.content_planning import CalendarEvent, ContentStrategy from services.agent_activity_service import AgentActivityService, build_agent_event_payload from services.llm_providers.main_text_generation import llm_text_gen from services.database import get_all_user_ids, get_session_for_user from services.onboarding.progress_service import OnboardingProgressService from services.active_strategy_service import ActiveStrategyService from loguru import logger PILLAR_IDS = ["plan", "generate", "publish", "analyze", "engage", "remarket"] MIN_TASK_EVIDENCE_LINKS = 1 PLAN_CONTEXT_THRESHOLD = 0.65 # Calendar → Workflow mapping CALENDAR_CONTENT_PILLAR = "generate" _PLATFORM_ACTION_URL = { "linkedin": "/linkedin-writer", "facebook": "/facebook-writer", "twitter": "/twitter-writer", "instagram": "/instagram-writer", "youtube": "/youtube-writer", "tiktok": "/tiktok-writer", } _CONTENT_ACTION_URL = { "blog_post": "/blog-writer", "linkedin_post": "/linkedin-writer", "facebook_post": "/facebook-writer", "seo_page": "/seo-dashboard", "video": "/video-writer", } _CONTENT_ESTIMATED_TIME = { "blog_post": 45, "linkedin_post": 20, "facebook_post": 15, "twitter_post": 10, "instagram_post": 15, "seo_page": 30, "video": 60, } def _resolve_calendar_action_url(content_type: str, platform: str) -> Optional[str]: platform_lower = (platform or "").strip().lower() if platform_lower in _PLATFORM_ACTION_URL: return _PLATFORM_ACTION_URL[platform_lower] ct_lower = (content_type or "").strip().lower() if ct_lower in _CONTENT_ACTION_URL: return _CONTENT_ACTION_URL[ct_lower] logger.warning("No action_url mapping for calendar event content_type={!r} platform={!r}", content_type, platform) return None def _resolve_calendar_estimated_time(content_type: str) -> int: return _CONTENT_ESTIMATED_TIME.get((content_type or "").strip().lower(), 30) def _generate_calendar_event_plan(date: str, grounding: Dict[str, Any]) -> Dict[str, Any]: calendar_events = grounding.get("calendar_events_today", []) if not calendar_events: return {"date": date, "tasks": []} tasks = [] for event in calendar_events: action_url = _resolve_calendar_action_url( event.get("content_type", ""), event.get("platform", "") ) if action_url is None: continue task = { "pillarId": CALENDAR_CONTENT_PILLAR, "title": (event.get("title") or "Untitled").strip()[:255], "description": (event.get("description") or "").strip(), "priority": "high", "estimatedTime": _resolve_calendar_estimated_time(event.get("content_type", "")), "actionType": "navigate", "actionUrl": action_url, "enabled": True, "dependencies": [], "metadata": { "source": "calendar_event", "source_event_id": event.get("id"), "calendar_title": event.get("title"), "content_type": event.get("content_type"), "platform": event.get("platform"), }, } tasks.append(task) return {"date": date, "tasks": tasks} def _today_date_str() -> str: return datetime.now(timezone.utc).date().isoformat() def _coerce_priority(value: Any) -> str: v = str(value or "medium").lower().strip() return v if v in {"high", "medium", "low"} else "medium" def _coerce_status(value: Any) -> str: v = str(value or "pending").lower().strip() if v in {"pending", "in_progress", "completed", "skipped", "dismissed"}: return "skipped" if v == "dismissed" else v return "pending" def _proposal_priority_rank(priority: str) -> int: return {"low": 0, "medium": 1, "high": 2}.get(str(priority or "").lower(), 1) def _proposal_order_key(proposal: Any) -> tuple: return ( str(getattr(proposal, "source_agent", "") or "").lower(), str(getattr(proposal, "title", "") or "").lower(), str(getattr(proposal, "description", "") or "").lower(), str(getattr(proposal, "action_url", "") or "").lower(), ) def _is_coverage_guardrail_enabled(grounding: Dict[str, Any]) -> bool: workflow_config = grounding.get("workflow_config", {}) if isinstance(grounding, dict) else {} if not isinstance(workflow_config, dict): return True if workflow_config.get("disable_pillar_coverage_guardrail") is True: return False if workflow_config.get("enforce_pillar_coverage") is False: return False return True def _sanitize_task(task: Dict[str, Any], agent_name: Optional[str] = None) -> Optional[Dict[str, Any]]: if not isinstance(task, dict): return None pillar_id = str(task.get("pillarId") or "").lower().strip() title = str(task.get("title") or "").strip() if pillar_id not in PILLAR_IDS or not title: reason = "empty title" if not title else f"invalid pillar_id={pillar_id!r}" logger.warning(f"Rejected task from agent {agent_name or 'unknown'}: {reason}") return None sanitized = dict(task) sanitized["pillarId"] = pillar_id sanitized["title"] = title sanitized["description"] = str(task.get("description") or "").strip() sanitized["priority"] = _coerce_priority(task.get("priority")) sanitized["estimatedTime"] = max(5, int(task.get("estimatedTime") or 15)) sanitized["actionType"] = str(task.get("actionType") or "navigate").strip() or "navigate" sanitized["actionUrl"] = str(task.get("actionUrl") or "").strip() or None sanitized["enabled"] = bool(task.get("enabled", True)) 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( user_id: str, date: str, pillar_id: str, grounding: Dict[str, Any], ) -> Optional[Dict[str, Any]]: schema = { "type": "object", "properties": { "pillarId": {"type": "string"}, "title": {"type": "string"}, "description": {"type": "string"}, "priority": {"type": "string"}, "estimatedTime": {"type": "number"}, "actionType": {"type": "string"}, "actionUrl": {"type": "string"}, "enabled": {"type": "boolean"}, "metadata": {"type": "object"}, }, "required": ["pillarId", "title", "description", "priority", "estimatedTime", "actionType", "enabled"], } prompt = ( "Generate exactly one actionable JSON task for today's workflow.\n" f"Date: {date}\n" f"Required pillarId: {pillar_id}\n" "Constraints:\n" "- Return a single JSON object only.\n" "- Keep title concise and practical.\n" "- Task must be completable today.\n" "- Use actionType='navigate' and a valid ALwrity route when possible.\n" f"User context: {json.dumps(grounding.get('onboarding_data', {}), indent=2)}\n" ) try: raw = llm_text_gen(prompt=prompt, json_struct=schema, user_id=user_id) candidate = raw if isinstance(raw, dict) else json.loads(raw) except Exception as e: logger.warning(f"Failed to generate pillar backfill task for {pillar_id}: {e}") return None candidate = _sanitize_task(candidate) if candidate: candidate["pillarId"] = pillar_id metadata = candidate.get("metadata") if isinstance(candidate.get("metadata"), dict) else {} metadata["source"] = "llm_pillar_backfill" candidate["metadata"] = metadata return candidate def _ensure_pillar_coverage( tasks: List[Dict[str, Any]], user_id: str, date: str, grounding: Dict[str, Any], ) -> List[Dict[str, Any]]: sanitized_tasks = [t for t in (_sanitize_task(task) for task in tasks) if t] if not _is_coverage_guardrail_enabled(grounding): return sanitized_tasks covered_pillars = {task["pillarId"] for task in sanitized_tasks} for pillar_id in PILLAR_IDS: if pillar_id in covered_pillars: continue generated = _build_single_task_for_missing_pillar(user_id, date, pillar_id, grounding) if generated: sanitized_tasks.append(generated) covered_pillars.add(pillar_id) return sanitized_tasks def build_grounding_context(db: Session, user_id: str, date: str) -> Dict[str, Any]: # 1. Fetch unread alerts unread_agent_alerts = ( db.query(AgentAlert) .filter(AgentAlert.user_id == user_id, AgentAlert.read_at.is_(None)) .order_by(AgentAlert.created_at.desc()) .limit(10) .all() ) # 2. Fetch comprehensive onboarding data (SIF) onboarding_context = {} try: from api.content_planning.services.content_strategy.onboarding.data_integration import OnboardingDataIntegrationService svc = OnboardingDataIntegrationService() integrated = svc.get_integrated_data_sync(user_id, db) or {} # Populate key sections onboarding_context = integrated except Exception as e: logger.warning(f"Failed to load full onboarding data for context: {e}") # Ensure workflow_config exists if "workflow_config" not in onboarding_context: onboarding_context["workflow_config"] = {} # 3. Fetch calendar events for today calendar_events_today = [] try: from datetime import datetime as dt_func, timedelta today_start = dt_func.strptime(date, "%Y-%m-%d").replace(hour=0, minute=0, second=0) today_end = today_start + timedelta(days=1) calendar_events_today = ( db.query(CalendarEvent) .join(ContentStrategy, CalendarEvent.strategy_id == ContentStrategy.id) .filter( ContentStrategy.user_id == user_id, CalendarEvent.scheduled_date >= today_start, CalendarEvent.scheduled_date < today_end, CalendarEvent.status.in_(["draft", "scheduled"]), ) .all() ) except Exception as e: logger.warning(f"Failed to fetch calendar events for grounding context: {e}") return { "recent_agent_alerts": [ { "alert_id": a.id, "title": a.title, "message": a.message, "created_at": a.created_at.isoformat(), "alert_type": a.alert_type, } for a in unread_agent_alerts ], "onboarding_data": onboarding_context, "workflow_config": onboarding_context.get("workflow_config", {}), "calendar_events_today": [ { "id": event.id, "title": event.title, "description": event.description, "content_type": event.content_type, "platform": event.platform, "status": event.status, "scheduled_date": event.scheduled_date.isoformat() if event.scheduled_date else None, } for event in calendar_events_today ], } import asyncio from services.intelligence.agents.agent_orchestrator import AgentOrchestrationService from services.task_memory_service import TaskMemoryService # Initialize orchestration service (singleton) orchestration_service = AgentOrchestrationService() 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) grounding = grounding or build_grounding_context(db, user_id, date) memory_service = TaskMemoryService(user_id, db) # 1. Get Orchestrator try: orchestrator = await orchestration_service.get_or_create_orchestrator(user_id) except Exception as e: logger.error(f"Failed to get orchestrator: {e}") return {"date": date, "tasks": []} # 2. Parallel "Committee" Proposal Gathering logger.info(f"Gathering daily task proposals from agent committee for user {user_id}") agent_tasks = [] try: # Define agents to poll agents_to_poll = [ orchestrator.agents.get('content'), # ContentStrategyAgent orchestrator.agents.get('strategy'), # StrategyArchitectAgent orchestrator.agents.get('seo'), # SEOOptimizationAgent orchestrator.agents.get('social'), # SocialAmplificationAgent orchestrator.agents.get('competitor'), # CompetitorResponseAgent orchestrator.agents.get('content_gap_radar'), # ContentGapRadarAgent ] # Filter out None agents (disabled/failed init) active_agents = [a for a in agents_to_poll if a] # Execute propose_daily_tasks in parallel results = await asyncio.gather( *[a.propose_daily_tasks(grounding) for a in active_agents], return_exceptions=True ) # Collect successful proposals raw_proposals = [] for res in results: if isinstance(res, list): raw_proposals.extend(res) elif isinstance(res, Exception): logger.warning(f"Agent proposal failed: {res}") # 3. Filter Redundant Proposals (Self-Learning) # Note: We need to ensure we don't filter out essential recurring tasks if they were completed long ago # But for now, we filter exact duplicates from recent history (last 7 days) # We can implement semantic filtering later # Simple deduplication based on title+pillar unique_map = {} for p in raw_proposals: key = f"{p.pillar_id}:{p.title}" if key not in unique_map: unique_map[key] = p continue existing = unique_map[key] if _proposal_priority_rank(p.priority) > _proposal_priority_rank(existing.priority): unique_map[key] = p continue # Deterministic tie-breaker for equal priority proposals. if ( _proposal_priority_rank(p.priority) == _proposal_priority_rank(existing.priority) and _proposal_order_key(p) < _proposal_order_key(existing) ): unique_map[key] = p agent_tasks = list(unique_map.values()) # Phase 3: Check memory for rejections (Semantic Filter) agent_tasks = await memory_service.filter_redundant_proposals(agent_tasks) # Log committee meeting event for frontend transparency try: accepted_ids = {f"{p.pillar_id}:{p.title}" for p in agent_tasks} proposals_log = [] for p in raw_proposals: valid = p.pillar_id in PILLAR_IDS key = f"{p.pillar_id}:{p.title}" proposals_log.append({ "agent": p.source_agent, "title": p.title, "pillar_id": p.pillar_id, "priority": p.priority, "valid": valid, "accepted": key in accepted_ids, "rejected_reason": None if valid else f"pillar_id '{p.pillar_id}' not in {PILLAR_IDS}", "reasoning": p.reasoning, "estimated_time": p.estimated_time, "action_type": p.action_type, }) if not valid: logger.warning( f"Rejected proposal from agent {p.source_agent}: " f"invalid pillar_id={p.pillar_id!r} (title={p.title!r}). " f"Must be one of {PILLAR_IDS}" ) activity.log_event( event_type="committee_meeting", message=f"Committee: {len(agent_tasks)}/{len(raw_proposals)} tasks accepted from {len(active_agents)} agents", payload={ "agents_polled": len(active_agents), "total_proposals": len(raw_proposals), "accepted_count": len(agent_tasks), "rejected_count": len(raw_proposals) - len(agent_tasks), "proposals": proposals_log, }, ) except Exception as e: logger.warning(f"Failed to log committee meeting event: {e}") # --- Committee Watchdog Audit (ContentGuardianAgent) --- try: guardian_agent = orchestrator.agents.get('guardian') if guardian_agent and hasattr(guardian_agent, 'audit_committee'): # Build proposals list from committee data (same format as proposals_log above) accepted_ids = {f"{p.pillar_id}:{p.title}" for p in agent_tasks} audit_input = [] for p in raw_proposals: key = f"{p.pillar_id}:{p.title}" audit_input.append({ "agent": p.source_agent, "title": p.title, "pillar_id": p.pillar_id, "priority": p.priority, "reasoning": p.reasoning or "", "accepted": key in accepted_ids, "valid": p.pillar_id in PILLAR_IDS, "rejected_reason": None if p.pillar_id in PILLAR_IDS else f"pillar_id '{p.pillar_id}' not in {PILLAR_IDS}", }) audit_report = await guardian_agent.audit_committee(audit_input) activity.log_event( event_type="quality_audit", message=f"Committee audit: {audit_report['health_score']}/100 health — {len(audit_report['alerts'])} findings", payload=audit_report, ) logger.info( f"Committee audit: health={audit_report['health_score']}, " f"critiques={len(audit_report['agent_critiques'])}, " f"gaps={len(audit_report['coverage_gaps'])}, " f"overlaps={len(audit_report['overlaps'])}" ) # Create alerts for serious watchdog findings for alert in audit_report.get("alerts", []): sev = alert.get("severity", "warning") dedupe_key = f"guardian:{alert['type']}:{alert.get('agent','')}:{alert.get('title','')}" try: activity.create_alert( alert_type=f"guardian_{alert['type']}", title=alert["title"], message=alert["message"], severity="error" if sev == "error" else "warning", cta_path=alert.get("cta_path"), payload={"guardian_agent": alert.get("agent"), "type": alert["type"]}, dedupe_key=dedupe_key, ) except Exception as ae: logger.warning(f"Failed to create guardian alert: {ae}") except Exception as e: logger.warning(f"Committee watchdog audit failed: {e}") # --- Trend Signals (TrendSurferAgent) --- try: trend_agent = orchestrator.agents.get('trend') if trend_agent and hasattr(trend_agent, 'surf_trends'): opportunities = await trend_agent.surf_trends() if opportunities: activity.log_event( event_type="trend_signals", message=f"Trend signals: {len(opportunities)} opportunities detected", payload={ "opportunities": opportunities[:5], "total_detected": len(opportunities), "scan_timestamp": datetime.utcnow().isoformat(), }, ) logger.info(f"Logged trend_signals event with {len(opportunities)} opportunities") except Exception as e: logger.warning(f"Trend signal phase failed: {e}") except Exception as e: logger.error(f"Committee proposal phase failed: {e}") # Continue to fallback or LLM generation if committee fails # 4. Final Selection # If we have agent tasks, use them. Otherwise fall back to LLM generation. if agent_tasks and not strict_contextuality: logger.info(f"Generated {len(agent_tasks)} tasks via Agent Committee") # Convert TaskProposal objects to dicts for frontend final_tasks = [] for prop in agent_tasks: final_tasks.append({ "pillarId": prop.pillar_id, "title": prop.title, "description": prop.description, "priority": prop.priority, "estimatedTime": prop.estimated_time, "actionType": prop.action_type, "actionUrl": prop.action_url, "enabled": True, "metadata": { "source_agent": prop.source_agent, "reasoning": prop.reasoning, "context_data": prop.context_data, "evidence_links": _derive_onboarding_evidence_links(grounding.get("onboarding_data", {}), limit=2), } }) final_tasks = _ensure_pillar_coverage(final_tasks, user_id, date, grounding) return { "date": date, "tasks": final_tasks } # Fallback to original LLM generation if agents returned nothing logger.info("Agent committee returned no tasks, falling back to LLM generation") schema = { "type": "object", "properties": { "date": {"type": "string"}, "tasks": { "type": "array", "items": { "type": "object", "properties": { "pillarId": {"type": "string"}, "title": {"type": "string"}, "description": {"type": "string"}, "priority": {"type": "string"}, "estimatedTime": {"type": "number"}, "actionType": {"type": "string"}, "actionUrl": {"type": "string"}, "enabled": {"type": "boolean"}, "dependencies": {"type": "array", "items": {"type": "string"}}, "metadata": {"type": "object"}, }, }, }, }, } prompt = ( "Generate a personalized Today workflow plan for ALwrity with exactly 6 lifecycle pillars: " "plan, generate, publish, analyze, engage, remarket.\n\n" "User Context (Onboarding & Strategy):\n" f"{json.dumps(grounding.get('onboarding_data', {}), indent=2)}\n\n" "Rules:\n" "- Produce JSON only that matches the schema.\n" "- Include 1-3 tasks per pillar.\n" "- Each task must have pillarId in {plan, generate, publish, analyze, engage, remarket}.\n" "- Customize tasks based on the user's industry, business type, and content pillars found in User Context.\n" "- If competitors are listed, include a task to analyze one of them.\n" "- Prefer actionable tasks that can be completed today.\n" "- Use these common actionUrl routes when relevant: " "/content-planning-dashboard, /blog-writer, /linkedin-writer, /facebook-writer, /seo-dashboard, /scheduler-dashboard.\n" "- Keep descriptions concise.\n\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:' or 'alert:' 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]) activity.log_event( event_type="plan", severity="info", message="Building grounded daily workflow plan", payload=build_agent_event_payload(phase="planning", step="build_grounded_plan", tool_name="llm_text_gen", progress_percent=10, input_summary="Grounding data assembled from onboarding + alerts", output_summary="Preparing daily workflow generation", decision_reason="Need context-aware workflow", evidence_refs=["onboarding_data","recent_agent_alerts"], safe_debug=True, metadata={"grounding": grounding}), run_id=run.id, agent_type="TodayWorkflowGenerator", ) try: raw = llm_text_gen(prompt=prompt, json_struct=schema, user_id=user_id) if isinstance(raw, dict): result = raw else: try: result = json.loads(raw) except Exception: result = {"date": date, "tasks": []} except Exception as e: activity.log_event( event_type="warning", severity="warning", message=str(e)[:2000], payload=build_agent_event_payload(phase="generation", step="llm_failed", tool_name="llm_text_gen", progress_percent=70, output_summary="LLM generation failed, returning empty tasks", decision_reason="Exception during workflow generation", safe_debug=False, metadata={"error": str(e)[:200]}), run_id=run.id, agent_type="TodayWorkflowGenerator", ) result = {"date": date, "tasks": []} tasks = result.get("tasks") if isinstance(result, dict) else None if not isinstance(tasks, list): tasks = [] result = { "date": date, "tasks": _ensure_pillar_coverage(tasks, user_id, date, grounding), } activity.log_event( event_type="final_summary", severity="info", message="Daily workflow plan generated", payload=build_agent_event_payload(phase="generation", step="workflow_generated", tool_name="llm_text_gen", progress_percent=100, output_summary=f"Generated {len(result.get('tasks', []))} tasks", decision_reason="Workflow assembled successfully", evidence_refs=[date], safe_debug=True, metadata={"date": date, "task_count": len(result.get("tasks", []))}), run_id=run.id, agent_type="TodayWorkflowGenerator", ) activity.finish_run(run.id, success=True, result_summary=json.dumps({"date": date, "tasks": result.get("tasks", [])})[:4000]) return result async def get_or_create_daily_workflow_plan( db: Session, user_id: str, date: Optional[str] = None, creation_source: str = "manual", ) -> tuple[DailyWorkflowPlan, bool]: from starlette.concurrency import run_in_threadpool date_str = date or _today_date_str() def _get_existing(): return ( db.query(DailyWorkflowPlan) .filter(DailyWorkflowPlan.user_id == user_id, DailyWorkflowPlan.date == date_str) .first() ) existing = await run_in_threadpool(_get_existing) if existing: return existing, False grounding = build_grounding_context(db, user_id, date_str) # Step 1: Calendar events → generate pillar (SSOT for content creation) calendar_plan = _generate_calendar_event_plan(date_str, grounding) calendar_task_titles = {t.get("title") for t in calendar_plan.get("tasks", []) if t.get("title")} # Step 2: Agent committee → proposals for plan + analyze + engage + publish + remarket agent_plan_data = await generate_agent_enhanced_plan(db, user_id, date_str, grounding=grounding, strict_contextuality=False) # Filter agent proposals: keep only non-generate pillars, dedup by title committee_pillars = {"plan", "analyze", "engage", "publish", "remarket"} filtered_agent_tasks = [ t for t in agent_plan_data.get("tasks", []) if t.get("pillarId") in committee_pillars and t.get("title") not in calendar_task_titles ] # Step 3: Merge — calendar wins for generate, agents fill other pillars all_tasks = calendar_plan.get("tasks", []) + filtered_agent_tasks calendar_source = bool(calendar_plan.get("tasks")) # Step 4: Pillar coverage — LLM backfill for any pillar still uncovered all_tasks = _ensure_pillar_coverage(all_tasks, user_id, date_str, grounding) # Step 5: Validation plan_data = {**agent_plan_data, "tasks": all_tasks} validation = validate_plan_contextuality(plan_data, grounding) plan_data["quality_status"] = ( "calendar_driven" if calendar_source else "contextual" if validation.get("is_contextual") else "low_context" ) plan_data["contextuality_validation"] = validation tasks = plan_data.get("tasks", []) def _create_plan(): plan = DailyWorkflowPlan( user_id=user_id, date=date_str, source=creation_source, generation_mode="calendar_driven" if calendar_source else _derive_generation_mode(plan_data), committee_agent_count=_count_committee_agents(tasks), fallback_used=False, plan_json=plan_data, created_at=datetime.utcnow(), updated_at=datetime.utcnow(), ) db.add(plan) db.commit() db.refresh(plan) for t in tasks: pillar_id = str(t.get("pillarId") or "").lower().strip() if pillar_id not in PILLAR_IDS: agent = None metadata = t.get("metadata") if isinstance(metadata, dict): agent = metadata.get("source_agent") logger.warning(f"Skipping task persistence for invalid pillar_id={pillar_id!r} " f"from agent {agent or 'unknown'}: title={t.get('title', '')}") continue task = DailyWorkflowTask( plan_id=plan.id, user_id=user_id, pillar_id=pillar_id, title=str(t.get("title") or "Task").strip()[:255], description=str(t.get("description") or "").strip(), status=_coerce_status(t.get("status")), priority=_coerce_priority(t.get("priority")), estimated_time=int(t.get("estimatedTime") or 15), action_type=str(t.get("actionType") or "navigate").strip()[:20], action_url=str(t.get("actionUrl") or "").strip(), dependencies=json.dumps(t.get("dependencies") or []), metadata_json=t.get("metadata") or {}, enabled=bool(t.get("enabled", True)), created_at=datetime.utcnow(), updated_at=datetime.utcnow(), ) db.add(task) db.commit() return plan plan = await run_in_threadpool(_create_plan) return plan, True def _derive_generation_mode(plan_data: Dict[str, Any]) -> str: tasks = plan_data.get("tasks", []) if isinstance(plan_data, dict) else [] source_modes = set() for task in tasks: metadata = task.get("metadata") if isinstance(task, dict) else {} metadata = metadata if isinstance(metadata, dict) else {} source_agent = str(metadata.get("source_agent") or "").strip() source = str(metadata.get("source") or "").strip() if source == "calendar_event": return "calendar_driven" if source_agent: source_modes.add("agent_committee") elif source in {"llm_pillar_backfill"}: source_modes.add(source) if "calendar_driven" in source_modes: return "calendar_driven" if "agent_committee" in source_modes: return "agent_committee" if "llm_pillar_backfill" in source_modes: return "llm_pillar_backfill" return "llm_generation" def _count_committee_agents(tasks: List[Dict[str, Any]]) -> int: agents = set() for task in tasks: metadata = task.get("metadata") if isinstance(task, dict) else {} metadata = metadata if isinstance(metadata, dict) else {} source_agent = str(metadata.get("source_agent") or "").strip() if source_agent: agents.add(source_agent) return len(agents) def _plan_uses_fallback(tasks: List[Dict[str, Any]]) -> bool: for task in tasks: metadata = task.get("metadata") if isinstance(task, dict) else {} metadata = metadata if isinstance(metadata, dict) else {} source = str(metadata.get("source") or "").strip() if source in {"controlled_fallback", "llm_pillar_backfill"}: return True return False async def generate_scheduled_daily_workflows() -> Dict[str, int]: user_ids = get_all_user_ids() stats = {"users_seen": 0, "created": 0, "existing": 0, "skipped_no_onboarding": 0, "skipped_no_strategy": 0, "failed": 0} for user_id in user_ids: stats["users_seen"] += 1 db = None try: # Gate 1: Onboarding must be completed onboarding_service = OnboardingProgressService() status = onboarding_service.get_onboarding_status(user_id) if not status.get("is_completed", False): stats["skipped_no_onboarding"] += 1 logger.info("Skipping daily workflow for user {} — onboarding not completed", user_id) continue db = get_session_for_user(user_id) if not db: stats["failed"] += 1 continue # Gate 2: User must have an active content strategy active_strategy_service = ActiveStrategyService(db_session=db) has_active_strategy = active_strategy_service.has_active_strategies_with_tasks() if not has_active_strategy: stats["skipped_no_strategy"] += 1 logger.info("Skipping daily workflow for user {} — no active strategy", user_id) db.close() db = None continue plan, created = await get_or_create_daily_workflow_plan( db, user_id, creation_source="scheduled", ) if created: stats["created"] += 1 logger.info("Scheduled daily workflow created for user {} date {}", user_id, plan.date) else: stats["existing"] += 1 logger.info("Scheduled daily workflow already exists for user {} date {}", user_id, plan.date) except Exception as e: stats["failed"] += 1 logger.error("Scheduled daily workflow generation failed for user {}: {}", user_id, e) finally: if db: db.close() logger.info("Scheduled daily workflow run complete: {}", stats) return stats def update_task_status( db: Session, user_id: str, task_id: int, status: str, completion_notes: Optional[str] = None, ) -> Optional[DailyWorkflowTask]: task = db.query(DailyWorkflowTask).filter(DailyWorkflowTask.id == task_id, DailyWorkflowTask.user_id == user_id).first() if not task: return None task.status = _coerce_status(status) task.decided_at = datetime.utcnow() if completion_notes is not None: task.completion_notes = completion_notes[:4000] db.add(task) db.commit() db.refresh(task) # If a calendar-sourced task is completed, mark the calendar event as published if status == "completed" and task.metadata_json: source = task.metadata_json.get("source") source_event_id = task.metadata_json.get("source_event_id") if source == "calendar_event" and source_event_id: try: cal_event = ( db.query(CalendarEvent) .join(ContentStrategy, CalendarEvent.strategy_id == ContentStrategy.id) .filter( CalendarEvent.id == source_event_id, ContentStrategy.user_id == user_id, ) .first() ) if cal_event and cal_event.status != "published": cal_event.status = "published" cal_event.updated_at = datetime.utcnow() db.add(cal_event) db.commit() except Exception as e: logger.warning(f"Failed to update calendar event {source_event_id} on task completion: {e}") return task