Merge branch 'main' into codex/implement-central-visibility-for-seo-onboarding-tasks
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user