PMFlow/backend/app/services/scheduler_center.py

449 lines
16 KiB
Python

"""轻量级调度中心。"""
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)