"""轻量级调度中心。""" from __future__ import annotations import asyncio import os from datetime import datetime, timedelta from typing import Optional from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import async_sessionmaker from ..core.logging import get_logger from ..models import KnowledgeBaseDoc, PortfolioSnapshot, Project from .adaptive_rag_service import AdaptiveRAGService from .ai_pmo_cadence_service import AIPMOCadenceService from .project_knowledge_graph import ProjectKnowledgeGraphService from . import workload_service from . import workload_alert_service logger = get_logger(__name__) class WorkloadRecalcScheduler: """每日 00:00 重算 workload_ratio。""" def __init__(self, session_factory: async_sessionmaker): self._session_factory = session_factory self._task: Optional[asyncio.Task] = None self._stopped = asyncio.Event() def start(self) -> None: if self._task and not self._task.done(): return self._stopped.clear() self._task = asyncio.create_task(self._run_loop(), name="workload-recalc-daily") logger.info("workload_scheduler_started") async def stop(self) -> None: self._stopped.set() if not self._task: return self._task.cancel() try: await self._task except asyncio.CancelledError: pass logger.info("workload_scheduler_stopped") async def run_once(self) -> dict: """手动执行一次,便于调试与测试。""" return await self._run_recalc() async def _run_loop(self) -> None: while not self._stopped.is_set(): seconds = self._seconds_until_next_midnight() try: await asyncio.wait_for(self._stopped.wait(), timeout=seconds) break except TimeoutError: pass try: await self._run_recalc() except Exception as exc: # pragma: no cover logger.warning("workload_scheduler_recalc_failed", error=str(exc)) @staticmethod def _seconds_until_next_midnight() -> float: now = datetime.now() tomorrow = (now + timedelta(days=1)).date() next_midnight = datetime.combine(tomorrow, datetime.min.time()) return max((next_midnight - now).total_seconds(), 1.0) async def _run_recalc(self) -> dict: async with self._session_factory() as db: tenant_ids = await workload_service.list_tenant_ids(db) total_users = 0 total_suggestions = 0 for tenant_id in tenant_ids or [1]: total_users += await workload_service.recalculate_all_users_workload_ratio( db, tenant_id=tenant_id, ) summary = await workload_alert_service.process_tenant_unresolved_overloads( db, tenant_id=tenant_id, ) total_suggestions += int(summary.get("suggestion_count", 0)) logger.info( "workload_scheduler_recalc_done", tenant_count=len(tenant_ids or [1]), user_count=total_users, suggestion_count=total_suggestions, ) return { "tenant_count": len(tenant_ids or [1]), "user_count": total_users, "suggestion_count": total_suggestions, } class KnowledgeUrlRefreshScheduler: """按周期回抓 URL 知识源(默认每 168 小时)。""" def __init__(self, session_factory: async_sessionmaker): self._session_factory = session_factory self._task: Optional[asyncio.Task] = None self._stopped = asyncio.Event() try: hours = int(os.getenv("KB_URL_REFRESH_HOURS", "168")) except Exception: hours = 168 self._interval_seconds = max(hours, 1) * 3600 def start(self) -> None: if self._task and not self._task.done(): return self._stopped.clear() self._task = asyncio.create_task(self._run_loop(), name="knowledge-url-refresh") logger.info("knowledge_url_refresh_scheduler_started", interval_seconds=self._interval_seconds) async def stop(self) -> None: self._stopped.set() if not self._task: return self._task.cancel() try: await self._task except asyncio.CancelledError: pass logger.info("knowledge_url_refresh_scheduler_stopped") async def run_once(self) -> dict: return await self._run_refresh() async def _run_loop(self) -> None: while not self._stopped.is_set(): try: await asyncio.wait_for(self._stopped.wait(), timeout=self._interval_seconds) break except TimeoutError: pass try: await self._run_refresh() except Exception as exc: # pragma: no cover logger.warning("knowledge_url_refresh_scheduler_failed", error=str(exc)) async def _run_refresh(self) -> dict: async with self._session_factory() as db: now = datetime.now() cutoff = now - timedelta(days=7) docs = ( await db.execute( select(KnowledgeBaseDoc).where( and_( KnowledgeBaseDoc.source_type == "url", or_( KnowledgeBaseDoc.last_fetched_at.is_(None), KnowledgeBaseDoc.last_fetched_at <= cutoff, ), ) ) ) ).scalars().all() refreshed = 0 failed = 0 for doc in docs: tenant_id = int(getattr(doc, "tenant_id", 1) or 1) service = AdaptiveRAGService(db, tenant_id=tenant_id) try: await service.refresh_url_doc(doc_id=int(doc.id), project_id=None) refreshed += 1 except Exception as exc: # pragma: no cover failed += 1 logger.warning( "knowledge_url_refresh_doc_failed", doc_id=int(doc.id), tenant_id=tenant_id, error=str(exc), ) logger.info( "knowledge_url_refresh_done", candidate_count=len(docs), refreshed=refreshed, failed=failed, ) return { "candidate_count": len(docs), "refreshed": refreshed, "failed": failed, } class KnowledgeGraphIncrementalScheduler: """按周期执行知识图谱增量学习流水线。""" def __init__(self, session_factory: async_sessionmaker): self._session_factory = session_factory self._task: Optional[asyncio.Task] = None self._stopped = asyncio.Event() try: minutes = int(os.getenv("KG_INCREMENTAL_INTERVAL_MINUTES", "5")) except Exception: minutes = 5 self._interval_seconds = max(minutes, 1) * 60 try: self._delta_limit = max(100, int(os.getenv("KG_INCREMENTAL_DELTA_LIMIT", "1000"))) except Exception: self._delta_limit = 1000 try: self._event_batch = max(10, int(os.getenv("KG_INCREMENTAL_EVENT_BATCH", "200"))) except Exception: self._event_batch = 200 try: self._code_scan_hours = max(1, int(os.getenv("KG_CODE_SCAN_INTERVAL_HOURS", "6"))) except Exception: self._code_scan_hours = 6 def start(self) -> None: if self._task and not self._task.done(): return self._stopped.clear() self._task = asyncio.create_task(self._run_loop(), name="knowledge-graph-incremental") logger.info( "knowledge_graph_incremental_scheduler_started", interval_seconds=self._interval_seconds, delta_limit=self._delta_limit, event_batch=self._event_batch, code_scan_hours=self._code_scan_hours, ) async def stop(self) -> None: self._stopped.set() if not self._task: return self._task.cancel() try: await self._task except asyncio.CancelledError: pass logger.info("knowledge_graph_incremental_scheduler_stopped") async def run_once(self) -> dict: return await self._run_cycle() async def _run_loop(self) -> None: while not self._stopped.is_set(): try: await asyncio.wait_for(self._stopped.wait(), timeout=self._interval_seconds) break except TimeoutError: pass try: await self._run_cycle() except Exception as exc: # pragma: no cover logger.warning("knowledge_graph_incremental_scheduler_failed", error=str(exc)) async def _run_cycle(self) -> dict: async with self._session_factory() as db: tenant_ids = await workload_service.list_tenant_ids(db) effective_tenants = tenant_ids or [1] tenant_results: list[dict] = [] for tenant_id in effective_tenants: service = ProjectKnowledgeGraphService(db, tenant_id=int(tenant_id)) result = await service.run_incremental_cycle( delta_limit=self._delta_limit, event_batch=self._event_batch, code_scan_hours=self._code_scan_hours, run_source="scheduler", ) tenant_results.append(result) logger.info( "knowledge_graph_incremental_scheduler_done", tenant_count=len(effective_tenants), results=tenant_results, ) return { "tenant_count": len(effective_tenants), "results": tenant_results, } class AIPMOCadenceScheduler: """AI PMO cadence 自动调度。""" def __init__(self, session_factory: async_sessionmaker): self._session_factory = session_factory self._task: Optional[asyncio.Task] = None self._stopped = asyncio.Event() try: minutes = int(os.getenv("AI_PMO_CADENCE_INTERVAL_MINUTES", "60")) except Exception: minutes = 60 self._interval_seconds = max(minutes, 5) * 60 def start(self) -> None: if self._task and not self._task.done(): return self._stopped.clear() self._task = asyncio.create_task(self._run_loop(), name="ai-pmo-cadence") logger.info("ai_pmo_cadence_scheduler_started", interval_seconds=self._interval_seconds) async def stop(self) -> None: self._stopped.set() if not self._task: return self._task.cancel() try: await self._task except asyncio.CancelledError: pass logger.info("ai_pmo_cadence_scheduler_stopped") async def run_once(self) -> dict: return await self._run_cycle() async def _run_loop(self) -> None: while not self._stopped.is_set(): try: await asyncio.wait_for(self._stopped.wait(), timeout=self._interval_seconds) break except TimeoutError: pass try: await self._run_cycle() except Exception as exc: # pragma: no cover logger.warning("ai_pmo_cadence_scheduler_failed", error=str(exc)) async def _run_cycle(self) -> dict: async with self._session_factory() as db: now = datetime.now() tenant_ids = await workload_service.list_tenant_ids(db) effective_tenants = tenant_ids or [1] emitted = 0 for tenant_id in effective_tenants: service = AIPMOCadenceService(db, tenant_id=int(tenant_id)) project_ids = ( await db.execute( select(Project.id).where( and_( Project.tenant_id == int(tenant_id), Project.status.in_(["active", "planning", "doing", "todo"]), ) ) ) ).scalars().all() if await self._snapshot_due( db=db, tenant_id=int(tenant_id), scope_type="risk_review", scope_ref=f"tenant:{tenant_id}", freshness=timedelta(minutes=50), ): await service.generate_snapshot(cadence_type="risk_review", project_id=None) emitted += 1 for project_id in project_ids: if await self._snapshot_due( db=db, tenant_id=int(tenant_id), scope_type="risk_review", scope_ref=f"project:{int(project_id)}", freshness=timedelta(minutes=50), ): await service.generate_snapshot(cadence_type="risk_review", project_id=int(project_id)) emitted += 1 if 8 <= now.hour <= 10: if await self._snapshot_due( db=db, tenant_id=int(tenant_id), scope_type="daily_standup", scope_ref=f"tenant:{tenant_id}", freshness=timedelta(hours=20), ): await service.generate_snapshot(cadence_type="daily_standup", project_id=None) emitted += 1 for project_id in project_ids: if await self._snapshot_due( db=db, tenant_id=int(tenant_id), scope_type="daily_standup", scope_ref=f"project:{int(project_id)}", freshness=timedelta(hours=20), ): await service.generate_snapshot(cadence_type="daily_standup", project_id=int(project_id)) emitted += 1 if 18 <= now.hour <= 20: if await self._snapshot_due( db=db, tenant_id=int(tenant_id), scope_type="milestone_check", scope_ref=f"tenant:{tenant_id}", freshness=timedelta(hours=20), ): await service.generate_snapshot(cadence_type="milestone_check", project_id=None) emitted += 1 if now.weekday() == 0 and 8 <= now.hour <= 11: if await self._snapshot_due( db=db, tenant_id=int(tenant_id), scope_type="weekly_review", scope_ref=f"tenant:{tenant_id}", freshness=timedelta(days=6), ): await service.generate_snapshot(cadence_type="weekly_review", project_id=None) emitted += 1 await db.commit() logger.info("ai_pmo_cadence_scheduler_done", tenant_count=len(effective_tenants), snapshot_count=emitted) return {"tenant_count": len(effective_tenants), "snapshot_count": emitted} @staticmethod async def _snapshot_due( *, db, tenant_id: int, scope_type: str, scope_ref: str, freshness: timedelta, ) -> bool: row = ( await db.execute( select(PortfolioSnapshot.created_at).where( and_( PortfolioSnapshot.tenant_id == int(tenant_id), PortfolioSnapshot.scope_type == scope_type, PortfolioSnapshot.scope_ref == scope_ref[:64], ) ).order_by(PortfolioSnapshot.id.desc()).limit(1) ) ).scalars().first() if row is None: return True return row <= (datetime.now() - freshness)