109 lines
3.6 KiB
Python
109 lines
3.6 KiB
Python
|
|
"""Human oversight and decision records for AI agents.
|
||
|
|
|
||
|
|
Stores a durable audit trail of AI recommendations and the human decisions
|
||
|
|
made on them. A ``DecisionRecord`` captures the recommendation, the evidence
|
||
|
|
that supported it, and the reviewer's decision (approved / rejected) with an
|
||
|
|
explanation when the decision deviates from the recommendation.
|
||
|
|
|
||
|
|
Used by:
|
||
|
|
- ``app/ai/agent_loop.py`` — recording recommendations that need review
|
||
|
|
- ``app/plugins/builtins/automation`` — agent run oversight
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from sqlalchemy import String, Text
|
||
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
||
|
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
||
|
|
|
||
|
|
from app.core.db import Base, TenantMixin
|
||
|
|
from app.models.owned_mixin import OwnedMixin
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class DecisionRecord:
|
||
|
|
"""A recommendation and its human decision, for the audit trail.
|
||
|
|
|
||
|
|
Attributes:
|
||
|
|
agent_run_id: The agent run this decision belongs to.
|
||
|
|
recommendation: The AI's recommendation text.
|
||
|
|
evidence: Supporting data for the recommendation.
|
||
|
|
reviewer_id: The human reviewer (None while pending).
|
||
|
|
decision: ``approved``, ``rejected``, or ``None`` (pending).
|
||
|
|
decision_timestamp: ISO timestamp of the decision (None while pending).
|
||
|
|
deviation_note: Explanation when the decision differs from the
|
||
|
|
recommendation.
|
||
|
|
"""
|
||
|
|
|
||
|
|
agent_run_id: uuid.UUID
|
||
|
|
recommendation: str
|
||
|
|
evidence: dict[str, Any] = field(default_factory=dict)
|
||
|
|
reviewer_id: uuid.UUID | None = None
|
||
|
|
decision: str | None = None # "approved", "rejected", None (pending)
|
||
|
|
decision_timestamp: str | None = None
|
||
|
|
deviation_note: str | None = None
|
||
|
|
|
||
|
|
|
||
|
|
class DecisionRecordDB(Base, TenantMixin, OwnedMixin):
|
||
|
|
"""Persistent storage for AI decision records (audit trail)."""
|
||
|
|
|
||
|
|
__tablename__ = "ai_decision_records"
|
||
|
|
|
||
|
|
id: Mapped[uuid.UUID] = mapped_column(
|
||
|
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||
|
|
)
|
||
|
|
agent_run_id: Mapped[uuid.UUID] = mapped_column(
|
||
|
|
PGUUID(as_uuid=True), nullable=False, index=True
|
||
|
|
)
|
||
|
|
recommendation: Mapped[str] = mapped_column(Text, nullable=False)
|
||
|
|
evidence: Mapped[dict[str, Any]] = mapped_column(
|
||
|
|
JSONB, nullable=False, default=dict
|
||
|
|
)
|
||
|
|
reviewer_id: Mapped[uuid.UUID | None] = mapped_column(
|
||
|
|
PGUUID(as_uuid=True), nullable=True
|
||
|
|
)
|
||
|
|
decision: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||
|
|
decision_timestamp: Mapped[str | None] = mapped_column(
|
||
|
|
String(40), nullable=True
|
||
|
|
)
|
||
|
|
deviation_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||
|
|
|
||
|
|
|
||
|
|
async def create_decision_record(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
record: DecisionRecord,
|
||
|
|
) -> uuid.UUID:
|
||
|
|
"""Store a decision record for the audit trail.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
db: Async DB session.
|
||
|
|
tenant_id: Tenant ID.
|
||
|
|
record: The decision record to persist.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
The UUID of the created record.
|
||
|
|
"""
|
||
|
|
entry = DecisionRecordDB(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
agent_run_id=record.agent_run_id,
|
||
|
|
recommendation=record.recommendation,
|
||
|
|
evidence=record.evidence or {},
|
||
|
|
reviewer_id=record.reviewer_id,
|
||
|
|
decision=record.decision,
|
||
|
|
decision_timestamp=record.decision_timestamp
|
||
|
|
or (datetime.now(UTC).isoformat() if record.decision else None),
|
||
|
|
deviation_note=record.deviation_note,
|
||
|
|
owner_id=record.reviewer_id,
|
||
|
|
)
|
||
|
|
db.add(entry)
|
||
|
|
await db.flush()
|
||
|
|
return entry.id
|