Files
leocrm/app/ai/oversight.py
T

109 lines
3.6 KiB
Python
Raw Normal View History

"""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