feat(approval): add safe risk disposition workflow

This commit is contained in:
caoxiaozhu
2026-07-16 15:34:58 +08:00
parent ee88a36baf
commit 4940ebc419
64 changed files with 6602 additions and 631 deletions

View File

@@ -11,6 +11,7 @@ from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
from app.models.approval import ApprovalRecord
from app.models.approval_action import ApprovalActionLedger
from app.models.attachment_association_job import AttachmentAssociationJob
from app.models.audit_log import AuditLog
from app.models.auth_session import AuthSession
@@ -32,6 +33,7 @@ from app.models.hermes_report import HermesRiskReport
from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest
from app.models.risk_disposition import RiskDisposition, RiskDispositionEvent
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.models.role import Role
from app.models.system_model_setting import SystemModelSetting
@@ -54,6 +56,7 @@ __all__ = [
"AgentTraceEvent",
"AIApplicationPreviewDecision",
"ApprovalRecord",
"ApprovalActionLedger",
"AttachmentAssociationJob",
"AuditLog",
"AuthSession",
@@ -80,6 +83,8 @@ __all__ = [
"NotificationState",
"OrganizationUnit",
"ReimbursementRequest",
"RiskDisposition",
"RiskDispositionEvent",
"RiskObservation",
"RiskObservationFeedback",
"Role",

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import CheckConstraint, DateTime, Index, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.db.base_class import Base
class ApprovalActionLedger(Base):
"""审批类写动作的持久化幂等账本。"""
__tablename__ = "approval_action_ledgers"
__table_args__ = (
CheckConstraint(
"action IN ('approve', 'return', 'pay')",
name="ck_approval_action_ledger_action",
),
CheckConstraint(
"(completed_at IS NULL AND result_status IS NULL "
"AND result_approval_stage IS NULL) OR "
"(completed_at IS NOT NULL AND result_status IS NOT NULL "
"AND result_approval_stage IS NOT NULL)",
name="ck_approval_action_ledger_completion",
),
UniqueConstraint(
"tenant_id",
"actor_id",
"request_id",
name="uq_approval_action_ledger_request",
),
Index(
"ix_approval_action_ledger_claim_action",
"tenant_id",
"claim_id",
"action",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
request_id: Mapped[str] = mapped_column(String(120), nullable=False)
claim_id: Mapped[str] = mapped_column(String(36), nullable=False)
action: Mapped[str] = mapped_column(String(20), nullable=False)
payload_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
expected_status: Mapped[str] = mapped_column(String(30), nullable=False)
expected_approval_stage: Mapped[str] = mapped_column(String(50), nullable=False)
result_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
result_approval_stage: Mapped[str | None] = mapped_column(String(50), nullable=True)
response_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)

View File

@@ -0,0 +1,174 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKeyConstraint,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from app.db.base_class import Base
def _new_id() -> str:
return str(uuid.uuid4())
class RiskDisposition(Base):
"""风险观察的当前处置投影;裁决结论和处置生命周期彼此独立。"""
__tablename__ = "risk_dispositions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_risk_dispositions_tenant_id",
),
UniqueConstraint(
"tenant_id",
"observation_id",
name="uq_risk_dispositions_tenant_observation",
),
CheckConstraint(
"adjudication IN ('unreviewed', 'confirmed', 'false_positive')",
name="ck_risk_dispositions_adjudication",
),
CheckConstraint(
"lifecycle_status IN ('open', 'supplement_requested', "
"'remediation_in_progress', 'waiver_requested', 'resolved')",
name="ck_risk_dispositions_lifecycle",
),
CheckConstraint("version >= 0", name="ck_risk_dispositions_version"),
ForeignKeyConstraint(
["tenant_id", "observation_id"],
["risk_observations.tenant_id", "risk_observations.id"],
ondelete="RESTRICT",
name="fk_risk_dispositions_tenant_observation",
),
Index(
"ix_risk_dispositions_tenant_lifecycle_due",
"tenant_id",
"lifecycle_status",
"due_at",
),
Index("ix_risk_dispositions_assignee", "tenant_id", "assignee"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
observation_id: Mapped[str] = mapped_column(String(36), nullable=False)
adjudication: Mapped[str] = mapped_column(
String(24),
nullable=False,
default="unreviewed",
server_default="unreviewed",
)
lifecycle_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="open",
server_default="open",
)
version: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default="0",
)
assignee: Mapped[str | None] = mapped_column(String(120), nullable=True)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
resolution: Mapped[str | None] = mapped_column(Text(), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
observation = relationship("RiskObservation", back_populates="disposition")
events = relationship(
"RiskDispositionEvent",
back_populates="disposition",
order_by="asc(RiskDispositionEvent.version)",
lazy="selectin",
passive_deletes=True,
)
class RiskDispositionEvent(Base):
"""只追加的风险处置事实;每个版本只对应一个类型化动作。"""
__tablename__ = "risk_disposition_events"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"request_id",
name="uq_risk_disposition_events_tenant_request",
),
UniqueConstraint(
"disposition_id",
"version",
name="uq_risk_disposition_events_version",
),
CheckConstraint(
"action IN ('confirm', 'false_positive', 'request_supplement', "
"'start_remediation', 'resolve', 'request_waiver')",
name="ck_risk_disposition_events_action",
),
CheckConstraint("version > 0", name="ck_risk_disposition_events_version"),
ForeignKeyConstraint(
["tenant_id", "disposition_id"],
["risk_dispositions.tenant_id", "risk_dispositions.id"],
ondelete="RESTRICT",
name="fk_risk_disposition_events_tenant_disposition",
),
ForeignKeyConstraint(
["tenant_id", "observation_id"],
["risk_observations.tenant_id", "risk_observations.id"],
ondelete="RESTRICT",
name="fk_risk_disposition_events_tenant_observation",
),
Index(
"ix_risk_disposition_events_tenant_observation_time",
"tenant_id",
"observation_id",
"created_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
disposition_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
observation_id: Mapped[str] = mapped_column(String(36), nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False)
action: Mapped[str] = mapped_column(String(32), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
actor_name: Mapped[str] = mapped_column(String(120), nullable=False)
request_id: Mapped[str] = mapped_column(String(120), nullable=False)
payload_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
comment: Mapped[str | None] = mapped_column(Text(), nullable=True)
before_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
after_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
disposition = relationship("RiskDisposition", back_populates="events")

View File

@@ -24,6 +24,11 @@ from app.db.base_class import Base
class RiskObservation(Base):
__tablename__ = "risk_observations"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_risk_observations_tenant_id",
),
UniqueConstraint(
"tenant_id",
"observation_key",
@@ -95,6 +100,12 @@ class RiskObservation(Base):
cascade="all, delete-orphan",
order_by="desc(RiskObservationFeedback.created_at)",
)
disposition = relationship(
"RiskDisposition",
back_populates="observation",
passive_deletes=True,
uselist=False,
)
@property
def sampling_strategy(self) -> dict[str, Any]: