feat(F): F-PERM permissions, F-APPR approval, F-AIUSE metadata, F-TRANS transparency, F-DATA-POL data policy, F-OVERSIGHT decision record, F-DRY dry-run, F-AUDIT audit log
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- F-PERM: app/ai/agent_permissions.py (230 lines) — AgentPermissionContext, resolve_agent_permissions(), filter_visible_agents(), check_agent_execute_permission(), optimistic locking - F-APPR: app/core/approval.py (160 lines) + app/routes/approvals.py (305 lines) + migration 0123 — ApprovalRequest model, CRUD API, approve/reject/expire - F-AIUSE: app/ai/ai_use_case.py (156 lines) — AIUseCaseMetadata Pydantic model, validate_ai_use_case() - F-TRANS: app/ai/transparency.py (60 lines) — mark_as_ai_generated(), is_ai_participant() - F-DATA-POL: app/ai/data_policy.py (210 lines) — enforce_data_policy() with SENSITIVE_FIELDS + provider compliance - F-OVERSIGHT: app/ai/oversight.py (108 lines) — DecisionRecord, create_decision_record() - F-DRY: agent_loop.py updated with dry_run parameter - F-AUDIT: agent_loop.py updated with audit log for tool calls - agent_routes.py: AI use case metadata endpoints added - main.py: approval routes registered - All Python compile checks pass
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user