309 lines
10 KiB
Python
309 lines
10 KiB
Python
"""
|
||
Hermes KB Growth Worker
|
||
=======================
|
||
|
||
消費 governance_remediation_dispatch 中的 hermes_kb_growth_healthcheck work item,
|
||
把 knowledge_degradation 告警推進成可審核的 KM 草稿。
|
||
|
||
邊界:
|
||
- 可以建立 REVIEW 狀態的 auto_runbook 草稿,讓 owner 在前端審核。
|
||
- 不可以直接把 KM 標成 APPROVED / PUBLISHED。
|
||
- 不修改 immutable ai_governance_events;流程進度寫回 dispatch.decision_context。
|
||
|
||
2026-05-19 ogt + Codex: T90 Hermes KB growth healthcheck worker。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from copy import deepcopy
|
||
from typing import Any
|
||
|
||
import structlog
|
||
|
||
from src.db.base import get_db_context
|
||
from src.db.models import GovernanceRemediationDispatch
|
||
from src.models.knowledge import (
|
||
EntrySource,
|
||
EntryStatus,
|
||
EntryType,
|
||
KnowledgeEntry,
|
||
KnowledgeEntryCreate,
|
||
)
|
||
from src.repositories.governance_remediation_dispatch_repo import (
|
||
InvalidStatusTransition,
|
||
list_pending_by_executor,
|
||
transition_status,
|
||
update_decision_context,
|
||
)
|
||
from src.repositories.knowledge_repository import KnowledgeDBRepository
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
EXECUTOR_TYPE = "hermes_kb_growth_healthcheck"
|
||
DEFAULT_INTERVAL_SECONDS = 300
|
||
DEFAULT_LIMIT = 20
|
||
|
||
|
||
async def run_hermes_kb_growth_once(limit: int = DEFAULT_LIMIT) -> dict[str, int]:
|
||
"""執行一輪 Hermes KB growth healthcheck。
|
||
|
||
Returns:
|
||
統計資訊,供 log / smoke test 判讀。
|
||
"""
|
||
rows = await list_pending_by_executor(EXECUTOR_TYPE, limit=limit)
|
||
result = {
|
||
"scanned": len(rows),
|
||
"processed": 0,
|
||
"skipped": 0,
|
||
"failed": 0,
|
||
}
|
||
|
||
for row in rows:
|
||
try:
|
||
await _process_dispatch(row)
|
||
result["processed"] += 1
|
||
except InvalidStatusTransition as exc:
|
||
result["skipped"] += 1
|
||
logger.info(
|
||
"hermes_kb_growth_dispatch_skipped",
|
||
dispatch_id=row.id,
|
||
event_id=row.governance_event_id,
|
||
reason=str(exc),
|
||
)
|
||
except Exception as exc:
|
||
result["failed"] += 1
|
||
logger.exception(
|
||
"hermes_kb_growth_dispatch_failed",
|
||
dispatch_id=row.id,
|
||
event_id=row.governance_event_id,
|
||
error=str(exc),
|
||
)
|
||
await _mark_failed_if_started(row.id, str(exc))
|
||
|
||
if any(result.values()):
|
||
logger.info("hermes_kb_growth_once_completed", **result)
|
||
return result
|
||
|
||
|
||
async def run_hermes_kb_growth_loop(
|
||
interval_seconds: int = DEFAULT_INTERVAL_SECONDS,
|
||
limit: int = DEFAULT_LIMIT,
|
||
) -> None:
|
||
"""背景 loop:定期消費 Hermes KB growth dispatch。"""
|
||
logger.info(
|
||
"hermes_kb_growth_loop_started",
|
||
interval_seconds=interval_seconds,
|
||
limit=limit,
|
||
)
|
||
while True:
|
||
try:
|
||
await run_hermes_kb_growth_once(limit=limit)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
logger.exception("hermes_kb_growth_loop_error", error=str(exc))
|
||
await asyncio.sleep(interval_seconds)
|
||
|
||
|
||
async def _process_dispatch(row: GovernanceRemediationDispatch) -> None:
|
||
"""處理單筆 pending dispatch,最後停在 waiting_owner_review。"""
|
||
dispatched = await transition_status(row.id, "pending", "dispatched")
|
||
executing = await transition_status(dispatched.id, "dispatched", "executing")
|
||
|
||
km_entry = await _create_or_get_km_review_draft(executing)
|
||
updated_context = _build_review_context(
|
||
executing.decision_context or {},
|
||
dispatch_id=executing.id,
|
||
governance_event_id=executing.governance_event_id,
|
||
km_entry_id=km_entry.id,
|
||
)
|
||
await update_decision_context(executing.id, updated_context)
|
||
await transition_status(executing.id, "executing", "succeeded")
|
||
|
||
logger.info(
|
||
"hermes_kb_growth_review_draft_ready",
|
||
dispatch_id=executing.id,
|
||
event_id=executing.governance_event_id,
|
||
km_entry_id=km_entry.id,
|
||
workflow_stage="waiting_owner_review",
|
||
)
|
||
|
||
|
||
async def _create_or_get_km_review_draft(
|
||
dispatch: GovernanceRemediationDispatch,
|
||
) -> KnowledgeEntry:
|
||
"""以 governance event tag 做冪等,建立或取得 REVIEW 狀態 KM 草稿。"""
|
||
dispatch_tag = f"dispatch:{dispatch.id}"
|
||
event_tag = f"governance_event:{dispatch.governance_event_id}"
|
||
payload = _build_km_review_entry_payload(dispatch)
|
||
|
||
async with get_db_context() as db:
|
||
repo = KnowledgeDBRepository(db)
|
||
existing, _ = await repo.list_entries(tags=[event_tag], limit=1)
|
||
if existing:
|
||
return existing[0]
|
||
existing, _ = await repo.list_entries(tags=[dispatch_tag], limit=1)
|
||
if existing:
|
||
return existing[0]
|
||
return await repo.create(payload)
|
||
|
||
|
||
def _build_km_review_entry_payload(
|
||
dispatch: GovernanceRemediationDispatch,
|
||
) -> KnowledgeEntryCreate:
|
||
"""把 governance dispatch 轉成待審核的 KM 草稿 payload。"""
|
||
context = dispatch.decision_context or {}
|
||
workflow = context.get("workflow") if isinstance(context.get("workflow"), dict) else {}
|
||
impact = workflow.get("impact") if isinstance(workflow.get("impact"), dict) else {}
|
||
extra = context.get("extra") if isinstance(context.get("extra"), dict) else {}
|
||
ownership = context.get("ownership") if isinstance(context.get("ownership"), dict) else {}
|
||
if not ownership and isinstance(extra.get("ownership"), dict):
|
||
ownership = extra["ownership"]
|
||
|
||
stale_count = _pick_first(impact, extra, key="stale_count")
|
||
total_count = _pick_first(impact, extra, key="total_count")
|
||
stale_ratio = _pick_first(impact, context, key="stale_ratio")
|
||
threshold = _pick_first(impact, context, key="threshold")
|
||
stale_days = _pick_first(impact, extra, key="stale_days")
|
||
lead_agent = ownership.get("lead_agent") or "Hermes"
|
||
human_owner = ownership.get("human_owner") or "KM owner / SRE owner"
|
||
|
||
content = "\n".join([
|
||
"# KM 健康檢查草稿",
|
||
"",
|
||
"## 來源",
|
||
f"- governance_event_id: {dispatch.governance_event_id}",
|
||
f"- dispatch_id: {dispatch.id}",
|
||
f"- executor_type: {dispatch.executor_type}",
|
||
"",
|
||
"## 影響摘要",
|
||
f"- stale_count: {_format_unknown(stale_count)}",
|
||
f"- total_count: {_format_unknown(total_count)}",
|
||
f"- stale_ratio: {_format_ratio(stale_ratio)}",
|
||
f"- threshold: {_format_ratio(threshold)}",
|
||
f"- stale_days: {_format_unknown(stale_days)}",
|
||
"",
|
||
"## AI 已完成",
|
||
"- Hermes 已接手 knowledge_degradation dispatch。",
|
||
"- 已產生 KM 更新草稿與 owner review work item。",
|
||
"- 尚未把任何條目標成 approved / published。",
|
||
"",
|
||
"## Owner 審核重點",
|
||
"- 優先反查最近被 Incident、Sentry、SigNoz、PlayBook 引用的 KM。",
|
||
"- 確認草稿內容沒有把過期處置方式寫回正式知識庫。",
|
||
"- 審核通過後再進入 km_writeback_after_approval。",
|
||
"",
|
||
"## 安全邊界",
|
||
"- writes_km_without_approval=false",
|
||
f"- lead_agent={lead_agent}",
|
||
f"- human_owner={human_owner}",
|
||
])
|
||
|
||
return KnowledgeEntryCreate(
|
||
title=f"KM healthcheck review draft - {dispatch.governance_event_id[:8]}",
|
||
content=content,
|
||
entry_type=EntryType.AUTO_RUNBOOK,
|
||
category="AI治理",
|
||
tags=[
|
||
"governance:knowledge_degradation",
|
||
"workflow:kb_growth_healthcheck",
|
||
"stage:waiting_owner_review",
|
||
"agent:Hermes",
|
||
"needs_owner_review",
|
||
f"dispatch:{dispatch.id}",
|
||
f"governance_event:{dispatch.governance_event_id}",
|
||
],
|
||
source=EntrySource.AI_EXTRACTED,
|
||
status=EntryStatus.REVIEW,
|
||
path_type="hermes_kb_growth_healthcheck",
|
||
created_by="hermes_kb_growth_worker",
|
||
)
|
||
|
||
|
||
def _build_review_context(
|
||
context: dict[str, Any],
|
||
*,
|
||
dispatch_id: str,
|
||
governance_event_id: str,
|
||
km_entry_id: str,
|
||
) -> dict[str, Any]:
|
||
"""更新 dispatch read model,讓 Work Items/Telegram 可見目前停在 owner review。"""
|
||
updated = deepcopy(context)
|
||
workflow = updated.setdefault("workflow", {})
|
||
if not isinstance(workflow, dict):
|
||
workflow = {}
|
||
updated["workflow"] = workflow
|
||
|
||
stages = workflow.setdefault("stage_by_dispatch_status", {})
|
||
if not isinstance(stages, dict):
|
||
stages = {}
|
||
workflow["stage_by_dispatch_status"] = stages
|
||
stages.update({
|
||
"executing": "draft_km_updates",
|
||
"succeeded": "waiting_owner_review",
|
||
"failed": "needs_manual_km_triage",
|
||
})
|
||
|
||
workflow["current_stage"] = "waiting_owner_review"
|
||
workflow["next_action"] = "owner_review_km_draft"
|
||
workflow["needs_human_review"] = True
|
||
workflow["writes_km_without_approval"] = False
|
||
workflow["kb_draft_entry_id"] = km_entry_id
|
||
|
||
updated["next_action"] = "owner_review_km_draft"
|
||
updated["decision_path"] = "draft_created_waiting_owner_review"
|
||
updated["proposed_action"] = "Hermes 已建立 KM 更新草稿,等待 owner 審核"
|
||
updated["worker_result"] = {
|
||
"worker": "Hermes",
|
||
"executor_type": EXECUTOR_TYPE,
|
||
"dispatch_id": dispatch_id,
|
||
"governance_event_id": governance_event_id,
|
||
"km_draft_entry_id": km_entry_id,
|
||
"stage": "waiting_owner_review",
|
||
"status": "draft_created",
|
||
"writes_km_without_approval": False,
|
||
}
|
||
return updated
|
||
|
||
|
||
async def _mark_failed_if_started(dispatch_id: str, error: str) -> None:
|
||
"""若 worker 已取得 dispatch,將它收斂到 failed,保留錯誤。"""
|
||
for from_status in ("executing", "dispatched"):
|
||
try:
|
||
await transition_status(
|
||
dispatch_id,
|
||
from_status,
|
||
"failed",
|
||
last_error=error[:500],
|
||
)
|
||
return
|
||
except InvalidStatusTransition:
|
||
continue
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"hermes_kb_growth_mark_failed_failed",
|
||
dispatch_id=dispatch_id,
|
||
from_status=from_status,
|
||
error=str(exc),
|
||
)
|
||
return
|
||
|
||
|
||
def _pick_first(*sources: dict[str, Any], key: str) -> Any:
|
||
for source in sources:
|
||
if key in source:
|
||
return source[key]
|
||
return None
|
||
|
||
|
||
def _format_unknown(value: Any) -> str:
|
||
return "unknown" if value is None else str(value)
|
||
|
||
|
||
def _format_ratio(value: Any) -> str:
|
||
try:
|
||
return f"{float(value) * 100:.1f}%"
|
||
except (TypeError, ValueError):
|
||
return "unknown"
|