449 lines
16 KiB
Python
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)
|