feat(approval): add safe risk disposition workflow
This commit is contained in:
@@ -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",
|
||||
|
||||
64
server/src/app/models/approval_action.py
Normal file
64
server/src/app/models/approval_action.py
Normal 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(),
|
||||
)
|
||||
174
server/src/app/models/risk_disposition.py
Normal file
174
server/src/app/models/risk_disposition.py
Normal 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")
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user