feat(Phase 3): 學習閉環補完 — Root cause 3 + 診斷 feedback + 知識遺忘 + Fine-tune 管線
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled

- approval_execution.py: _run_post_execution_verify() 補接 record_verification_result()
  Root cause 3 終結:環境驗證結果(success/degraded/failed/timeout)不再孤立
- learning_service.py: 新增 record_verification_result() — 驗證結果 → Redis + Playbook EWMA
- learning_service.py: 新增 record_diagnosis_outcome() — 誤診負向訊號回寫(L3×D4)
- jobs/knowledge_decay_job.py: 新建 30d 知識遺忘 Job(未引用 draft/review → archived)
- services/finetune_exporter.py: 新建每週 JSONL 匯出(EvidenceSnapshot × AgentSession)
- main.py: 掛載 knowledge_decay_loop(24h)+ finetune_export_loop(7d)
- MASTER §8: Phase 3 核心改造項全部落地記錄

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-15 20:57:33 +08:00
parent e23e49c13b
commit fb1bbd0e20
6 changed files with 598 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
"""
AWOOOI AIOps Phase 3 — 知識遺忘 Job
=====================================
職責每日掃描知識庫knowledge_entries中 30 天未被引用view_count = 0
且 updated_at < now-30d的草稿/審核條目,標記為 archived知識遺忘
為什麼需要知識遺忘?
短期學習偏差AI 早期案例學習的修復模式可能已過時K8s 版本、服務名稱改變)。
若不遺忘,舊的 zero-evidence 條目會持續污染 RAG 檢索,
拉低 Playbook 匹配精度,增加誤診率。
遺忘策略:
- 對象status in (draft, review) 且 view_count = 0 且 updated_at < now-30d
- 動作status → archivedtags 追加 'kb_decay_30d'
- 豁免status = approved需人工封存status = archived已封存
- 每次執行記錄摘要到 structlog不寫 governance event避免雜訊
設計原則:
1. 只標記,不刪除(符合 archive_not_delete 鐵律)
2. 批次操作,每次最多 200 筆(避免長事務)
3. Job 失敗只記錄 error不影響主路徑
ADR-083 Phase 3: 知識遺忘L7×D4
2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 3 初始建立
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from datetime import timedelta
import structlog
from sqlalchemy import and_, select, update
from src.db.base import get_session_factory
from src.db.models import KnowledgeEntryRecord
from src.models.knowledge import EntryStatus
from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# 常數
# ─────────────────────────────────────────────────────────────────────────────
DECAY_AGE_DAYS = 30
DECAY_TAG = "kb_decay_30d"
BATCH_LIMIT = 200
DAILY_INTERVAL_SEC = 86_400 # 24h
# ─────────────────────────────────────────────────────────────────────────────
# Data Types
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class DecayScanResult:
"""知識遺忘掃描結果"""
total_scanned: int
decayed_ids: list[str] = field(default_factory=list)
scanned_at: str = field(default_factory=lambda: now_taipei().isoformat())
@property
def decayed_count(self) -> int:
return len(self.decayed_ids)
def to_dict(self) -> dict:
return {
"total_scanned": self.total_scanned,
"decayed_count": self.decayed_count,
"decayed_ids_sample": self.decayed_ids[:20],
"scanned_at": self.scanned_at,
}
# ─────────────────────────────────────────────────────────────────────────────
# Main Job
# ─────────────────────────────────────────────────────────────────────────────
class KnowledgeDecayJob:
"""
知識遺忘 Job每日執行
Usage:
job = KnowledgeDecayJob()
result = await job.run()
"""
async def run(self) -> DecayScanResult:
"""
完整執行:掃描 → 標記 archived知識遺忘
Returns:
DecayScanResult
"""
from src.core.feature_flags import aiops_flags
if not aiops_flags.AIOPS_P3_ENABLED:
logger.debug("knowledge_decay_job_skipped_feature_flag")
return DecayScanResult(total_scanned=0)
try:
return await self._run_scan()
except Exception as e:
logger.error(
"knowledge_decay_job_error",
error=str(e),
)
return DecayScanResult(total_scanned=0)
async def _run_scan(self) -> DecayScanResult:
cutoff = now_taipei() - timedelta(days=DECAY_AGE_DAYS)
decayable_statuses = [EntryStatus.DRAFT.value, EntryStatus.REVIEW.value]
session_factory = get_session_factory()
async with session_factory() as db:
# 查30 天未引用view_count=0且 updated_at < cutoff 的 draft/review 條目
stmt = select(KnowledgeEntryRecord).where(
and_(
KnowledgeEntryRecord.status.in_(decayable_statuses),
KnowledgeEntryRecord.view_count == 0,
KnowledgeEntryRecord.updated_at < cutoff,
)
).limit(BATCH_LIMIT)
result = await db.execute(stmt)
entries = result.scalars().all()
total_scanned = len(entries)
if not entries:
logger.debug("knowledge_decay_nothing_to_decay")
return DecayScanResult(total_scanned=0)
decayed_ids = []
for entry in entries:
# 追加 decay tag不重複
current_tags: list[str] = list(entry.tags or [])
if DECAY_TAG not in current_tags:
current_tags.append(DECAY_TAG)
entry.status = EntryStatus.ARCHIVED.value
entry.tags = current_tags
decayed_ids.append(entry.id)
await db.commit()
result = DecayScanResult(
total_scanned=total_scanned,
decayed_ids=decayed_ids,
)
logger.info(
"knowledge_decay_job_done",
**result.to_dict(),
)
return result
# ─────────────────────────────────────────────────────────────────────────────
# Loop掛載到 main.py
# ─────────────────────────────────────────────────────────────────────────────
async def run_knowledge_decay_loop() -> None:
"""
無限迴圈:每 24h 執行一次知識遺忘掃描。
在 main.py startup 以 asyncio.create_task 掛載。
"""
job = KnowledgeDecayJob()
while True:
try:
result = await job.run()
if result.decayed_count > 0:
logger.info(
"knowledge_decay_loop_tick",
decayed=result.decayed_count,
)
except Exception as e:
logger.error("knowledge_decay_loop_error", error=str(e))
await asyncio.sleep(DAILY_INTERVAL_SEC)