From 9d6aa7ea454b1d444e7de6b9cea19a0eed30d5f6 Mon Sep 17 00:00:00 2001 From: OG T Date: Fri, 17 Apr 2026 16:14:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(trust):=20ADR-088=20Trust=20Score=20?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=20=E2=80=94=20L4=20=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E6=94=BE=E8=A1=8C=E6=A0=B8=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrustScoreManager 從記憶體升級為 PostgreSQL 持久化, Pod 重啟後信任分數不再歸零,AI 能真正累積到 L4 自動放行門檻。 變更: - migrations/adr088_trust_score_persistence.sql: trust_records 表 - db/models.py: TrustRecordDB ORM model - repositories/interfaces.py: ITrustRepository Protocol - repositories/trust_repository.py: PG upsert ON CONFLICT DO UPDATE - services/trust_engine.py: bulk_load() 啟動 warm-up - services/learning_service.py: _persist_trust() + 2 call sites - main.py: 啟動時 load_all() → bulk_load() 流程: 批准 5 次 → score=5 寫入 DB → Pod 重啟 → warm-up 讀回 → evaluate_adjusted_risk MEDIUM→LOW → 自動執行 2026-04-17 ogt + Claude Sonnet 4.6(亞太): ADR-088 Co-Authored-By: Claude Sonnet 4.6 --- .../adr088_trust_score_persistence.sql | 24 +++ apps/api/src/db/models.py | 53 +++++++ apps/api/src/main.py | 13 ++ apps/api/src/repositories/interfaces.py | 38 +++++ apps/api/src/repositories/trust_repository.py | 145 ++++++++++++++++++ apps/api/src/services/learning_service.py | 41 ++++- apps/api/src/services/trust_engine.py | 40 +++++ 7 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 apps/api/migrations/adr088_trust_score_persistence.sql create mode 100644 apps/api/src/repositories/trust_repository.py diff --git a/apps/api/migrations/adr088_trust_score_persistence.sql b/apps/api/migrations/adr088_trust_score_persistence.sql new file mode 100644 index 00000000..c977b57e --- /dev/null +++ b/apps/api/migrations/adr088_trust_score_persistence.sql @@ -0,0 +1,24 @@ +-- ADR-088: Trust Score 持久化 +-- Phase 4+: TrustScoreManager 從記憶體升級為 PostgreSQL 持久化 +-- 解決問題: Pod 重啟後 AI 信任分數歸零,永遠無法累積到 L4 自動放行門檻 +-- 2026-04-17 ogt + Claude Sonnet 4.6(亞太) + +CREATE TABLE IF NOT EXISTS trust_records ( + action_pattern VARCHAR(255) PRIMARY KEY, + score INTEGER NOT NULL DEFAULT 0, + total_approvals INTEGER NOT NULL DEFAULT 0, + total_rejections INTEGER NOT NULL DEFAULT 0, + last_approval_by VARCHAR(100), + last_approval_at TIMESTAMPTZ, + last_rejection_by VARCHAR(100), + last_rejection_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE trust_records IS + 'ADR-088: TrustScoreManager 持久化層。記錄每個 action_pattern 的累積信任分數,' + '跨 Pod 重啟存活。score >= 5 → MEDIUM 自動降 LOW,score >= 10 → HIGH 降 MEDIUM。'; + +CREATE INDEX IF NOT EXISTS ix_trust_records_score ON trust_records (score DESC); +CREATE INDEX IF NOT EXISTS ix_trust_records_updated ON trust_records (updated_at DESC); diff --git a/apps/api/src/db/models.py b/apps/api/src/db/models.py index 3b6a9f51..0ec0b832 100644 --- a/apps/api/src/db/models.py +++ b/apps/api/src/db/models.py @@ -1175,3 +1175,56 @@ class AiGovernanceEvent(Base): Index("ix_ai_governance_triggered_at", "triggered_at"), Index("ix_ai_governance_resolved", "resolved"), ) + + +# ============================================================================= +# TrustRecordDB - ADR-088 TrustScore 持久化 +# ============================================================================= + +class TrustRecordDB(Base): + """ + Trust Score 持久化記錄 + + ADR-088: TrustScoreManager 從記憶體升級為 PostgreSQL 持久化。 + Pod 重啟後分數不歸零,AI 能真正累積信任達到 L4 自動放行。 + + score >= 5: MEDIUM → LOW (自動執行) + score >= 10: HIGH → MEDIUM (降一級) + + 2026-04-17 ogt + Claude Sonnet 4.6(亞太): Phase 4 信任持久化 + """ + __tablename__ = "trust_records" + + action_pattern: Mapped[str] = mapped_column( + String(255), primary_key=True, + comment="操作模式,例如 delete:nginx-frontend-*" + ) + score: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, + comment="累積信任分數。+1/approve,reject 歸零" + ) + total_approvals: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, + ) + total_rejections: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, + ) + last_approval_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + last_approval_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, + ) + last_rejection_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + last_rejection_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=taipei_now, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=taipei_now, onupdate=taipei_now, + ) + + __table_args__ = ( + Index("ix_trust_records_score", "score"), + Index("ix_trust_records_updated", "updated_at"), + ) diff --git a/apps/api/src/main.py b/apps/api/src/main.py index d96d2094..77221f73 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -302,6 +302,19 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: except Exception as e: logger.warning("working_memory_warmup_failed", error=str(e)) + # ADR-088: Trust Score 冷啟動 — 從 PostgreSQL 恢復信任分數 + # 解決: Pod 重啟後 TrustScoreManager 記憶歸零,AI 永遠無法累積到 L4 自動放行 + # 2026-04-17 ogt + Claude Sonnet 4.6(亞太): Phase 4 信任持久化 + try: + from src.repositories.trust_repository import get_trust_repository + from src.services.trust_engine import get_trust_manager + + trust_records = await get_trust_repository().load_all() + loaded = get_trust_manager().bulk_load(trust_records) + logger.info("trust_scores_warmed_up", loaded=loaded) + except Exception as e: + logger.warning("trust_scores_warmup_failed", error=str(e)) + # Phase 4 飛輪修復: Playbook Embedding 冷啟動索引 # 2026-04-10 Claude Sonnet 4.6 Asia/Taipei # 目的: 確保 playbook_embeddings 表有最新向量,供語義相似度查詢 diff --git a/apps/api/src/repositories/interfaces.py b/apps/api/src/repositories/interfaces.py index 95823186..9d9291c3 100644 --- a/apps/api/src/repositories/interfaces.py +++ b/apps/api/src/repositories/interfaces.py @@ -13,6 +13,7 @@ Phase 16 R3: Repository 層 Protocol 介面 建立者: Claude Code (Phase 16 架構重構) """ +from datetime import datetime from typing import Protocol, runtime_checkable from uuid import UUID @@ -639,3 +640,40 @@ class IFailureWatcher(Protocol): (success, result_message) """ ... + + +@runtime_checkable +class ITrustRepository(Protocol): + """ + Trust Repository Protocol + + 職責: TrustRecord 持久化 (PostgreSQL) + 實作: TrustRepository + + ADR-088: TrustScoreManager 持久化層 + 2026-04-17 ogt + Claude Sonnet 4.6(亞太): Phase 4 信任持久化 + """ + + async def upsert( + self, + action_pattern: str, + score: int, + total_approvals: int, + total_rejections: int, + last_approval_by: str | None = None, + last_approval_at: datetime | None = None, + last_rejection_by: str | None = None, + last_rejection_at: datetime | None = None, + ) -> bool: + """INSERT or UPDATE trust record (upsert by action_pattern)""" + ... + + async def load_all(self) -> list[dict]: + """ + 載入所有 trust records 供啟動 warm-up + + Returns: + list[{action_pattern, score, total_approvals, total_rejections, + last_approval_by, last_approval_at, last_rejection_by, last_rejection_at}] + """ + ... diff --git a/apps/api/src/repositories/trust_repository.py b/apps/api/src/repositories/trust_repository.py new file mode 100644 index 00000000..33b46fc0 --- /dev/null +++ b/apps/api/src/repositories/trust_repository.py @@ -0,0 +1,145 @@ +""" +Trust Repository - PostgreSQL 持久化層 +====================================== +ADR-088: TrustScoreManager 持久化升級 + +職責: trust_records 表的 upsert / load_all +設計: 實作 ITrustRepository Protocol + +Pod 重啟後信任分數不歸零,AI 能真正累積到 L4 自動放行門檻。 + +2026-04-17 ogt + Claude Sonnet 4.6(亞太): Phase 4 信任持久化 +""" + +from datetime import datetime + +import structlog +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from src.db.base import get_db_context +from src.db.models import TrustRecordDB +from src.repositories.interfaces import ITrustRepository + +logger = structlog.get_logger(__name__) + + +class TrustRepository: + """ + Trust Repository 實作(PostgreSQL) + + 使用 PostgreSQL ON CONFLICT DO UPDATE (upsert), + 保證並行安全且無競爭條件。 + """ + + async def upsert( + self, + action_pattern: str, + score: int, + total_approvals: int, + total_rejections: int, + last_approval_by: str | None = None, + last_approval_at: datetime | None = None, + last_rejection_by: str | None = None, + last_rejection_at: datetime | None = None, + ) -> bool: + """ + INSERT or UPDATE trust record。 + + 使用 PostgreSQL upsert 保證並行安全。 + action_pattern 是主鍵,衝突時更新除 created_at 外的所有欄位。 + """ + from src.utils.timezone import now_taipei + + try: + async with get_db_context() as db: + stmt = pg_insert(TrustRecordDB).values( + action_pattern=action_pattern, + score=score, + total_approvals=total_approvals, + total_rejections=total_rejections, + last_approval_by=last_approval_by, + last_approval_at=last_approval_at, + last_rejection_by=last_rejection_by, + last_rejection_at=last_rejection_at, + created_at=now_taipei(), + updated_at=now_taipei(), + ).on_conflict_do_update( + index_elements=["action_pattern"], + set_={ + "score": score, + "total_approvals": total_approvals, + "total_rejections": total_rejections, + "last_approval_by": last_approval_by, + "last_approval_at": last_approval_at, + "last_rejection_by": last_rejection_by, + "last_rejection_at": last_rejection_at, + "updated_at": now_taipei(), + }, + ) + await db.execute(stmt) + + logger.debug( + "trust_record_upserted", + action_pattern=action_pattern, + score=score, + ) + return True + + except Exception as e: + logger.error( + "trust_record_upsert_failed", + action_pattern=action_pattern, + error=str(e), + ) + return False + + async def load_all(self) -> list[dict]: + """ + 載入所有 trust records,供啟動 warm-up。 + + Returns: + list[dict] — 每筆含 action_pattern, score, total_approvals, + total_rejections, last_approval_by, last_approval_at, + last_rejection_by, last_rejection_at + """ + try: + async with get_db_context() as db: + result = await db.execute(select(TrustRecordDB)) + rows = result.scalars().all() + + records = [ + { + "action_pattern": r.action_pattern, + "score": r.score, + "total_approvals": r.total_approvals, + "total_rejections": r.total_rejections, + "last_approval_by": r.last_approval_by, + "last_approval_at": r.last_approval_at, + "last_rejection_by": r.last_rejection_by, + "last_rejection_at": r.last_rejection_at, + } + for r in rows + ] + + logger.info("trust_records_loaded", count=len(records)) + return records + + except Exception as e: + logger.error("trust_records_load_failed", error=str(e)) + return [] + + +# ============================================================================= +# Singleton +# ============================================================================= + +_repository: TrustRepository | None = None + + +def get_trust_repository() -> ITrustRepository: + """取得 TrustRepository 單例""" + global _repository + if _repository is None: + _repository = TrustRepository() + return _repository diff --git a/apps/api/src/services/learning_service.py b/apps/api/src/services/learning_service.py index 8bae62b5..52569014 100644 --- a/apps/api/src/services/learning_service.py +++ b/apps/api/src/services/learning_service.py @@ -32,8 +32,9 @@ import structlog from src.models.approval import ApprovalRequest from src.models.incident import IncidentStatus -from src.repositories.interfaces import ILearningRepository +from src.repositories.interfaces import ILearningRepository, ITrustRepository from src.repositories.learning_repository import get_learning_repository +from src.repositories.trust_repository import get_trust_repository from src.services.trust_engine import get_trust_manager logger = structlog.get_logger(__name__) @@ -156,9 +157,11 @@ class LearningService: def __init__( self, repository: ILearningRepository | None = None, + trust_repository: ITrustRepository | None = None, ): self._trust_manager = get_trust_manager() self._repository = repository or get_learning_repository() + self._trust_repo = trust_repository or get_trust_repository() async def process_execution_result( self, @@ -200,6 +203,9 @@ class LearningService: ) feedback_type = FeedbackType.EXECUTION_FAILURE + # ADR-088: 持久化信任分數到 PostgreSQL (Pod 重啟後不歸零) + await self._persist_trust(action_pattern) + # 取得更新後的信任分數 trust_record = self._trust_manager.get_trust_record(action_pattern) trust_after = trust_record.score if trust_record else 0 @@ -325,6 +331,9 @@ class LearningService: ) playbook_updated = await self._demote_playbook(feedback.incident_id) + # ADR-088: 持久化信任分數到 PostgreSQL (Pod 重啟後不歸零) + await self._persist_trust(action_pattern) + trust_record = self._trust_manager.get_trust_record(action_pattern) trust_after = trust_record.score if trust_record else 0 @@ -955,6 +964,36 @@ class LearningService: "learning_status": learning_status, } + async def _persist_trust(self, action_pattern: str) -> None: + """ + 将内存中的 TrustRecord 持久化到 PostgreSQL。 + + ADR-088: 每次 approve/reject 後同步寫入 DB, + 確保 Pod 重啟後信任分數不歸零。 + + 2026-04-17 ogt + Claude Sonnet 4.6(亞太): Phase 4 信任持久化 + """ + record = self._trust_manager.get_trust_record(action_pattern) + if not record: + return + try: + await self._trust_repo.upsert( + action_pattern=action_pattern, + score=record.score, + total_approvals=record.total_approvals, + total_rejections=record.total_rejections, + last_approval_by=record.last_approval_by, + last_approval_at=record.last_approval_at, + last_rejection_by=record.last_rejection_by, + last_rejection_at=record.last_rejection_at, + ) + except Exception as e: + logger.warning( + "trust_persist_failed", + action_pattern=action_pattern, + error=str(e), + ) + def _get_action_tier(self, action: str) -> int: """取得動作的 Tier""" tier_actions = { diff --git a/apps/api/src/services/trust_engine.py b/apps/api/src/services/trust_engine.py index b643adbf..75d1b22d 100644 --- a/apps/api/src/services/trust_engine.py +++ b/apps/api/src/services/trust_engine.py @@ -362,6 +362,46 @@ class TrustScoreManager: record.score = 0 logger.warning("[TrustEngine] All trust scores reset!") + def bulk_load(self, records: list[dict]) -> int: + """ + 從 DB 批量載入 trust records 到記憶體(啟動 warm-up 用)。 + + ADR-088: Pod 重啟後從 PostgreSQL 恢復信任分數, + 確保 AI 不會因重啟而失憶歸零。 + + Args: + records: list[dict] — 每筆含 action_pattern, score, + total_approvals, total_rejections, + last_approval_by, last_approval_at, + last_rejection_by, last_rejection_at + + Returns: + int: 載入筆數 + + 2026-04-17 ogt + Claude Sonnet 4.6(亞太): ADR-088 + """ + loaded = 0 + for r in records: + pattern = r.get("action_pattern") + if not pattern: + continue + record = TrustRecord( + action_pattern=pattern, + score=r.get("score", 0), + total_approvals=r.get("total_approvals", 0), + total_rejections=r.get("total_rejections", 0), + last_approval_by=r.get("last_approval_by"), + last_approval_at=r.get("last_approval_at"), + last_rejection_by=r.get("last_rejection_by"), + last_rejection_at=r.get("last_rejection_at"), + ) + self._records[pattern] = record + loaded += 1 + + if loaded: + logger.info(f"[TrustEngine] Warm-up: loaded {loaded} trust records from DB") + return loaded + # ==================== Pattern Matching Utilities ====================