Files
awoooi/apps/api/src/jobs/knowledge_decay_job.py
OG T fb1bbd0e20
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
feat(Phase 3): 學習閉環補完 — Root cause 3 + 診斷 feedback + 知識遺忘 + Fine-tune 管線
- 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>
2026-04-15 20:57:43 +08:00

181 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)