feat(ai): add expense application feedback ledger
This commit is contained in:
@@ -7,6 +7,7 @@ from app.models.agent_asset import (
|
||||
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
||||
from app.models.agent_feedback import AgentOperationFeedback
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.auth_session import AuthSession
|
||||
@@ -51,6 +52,8 @@ __all__ = [
|
||||
"ApprovalRecord",
|
||||
"AuditLog",
|
||||
"AuthSession",
|
||||
"AIDecision",
|
||||
"AIDecisionFeedback",
|
||||
"BudgetAllocation",
|
||||
"BudgetReservation",
|
||||
"BudgetTransaction",
|
||||
@@ -78,4 +81,5 @@ __all__ = [
|
||||
"SystemSetting",
|
||||
"SystemSettingSecret",
|
||||
"UserSessionMetric",
|
||||
"WorkflowOutcome",
|
||||
]
|
||||
|
||||
260
server/src/app/models/ai_learning.py
Normal file
260
server/src/app/models/ai_learning.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
def _new_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class AIDecision(Base):
|
||||
"""AI 在费用流程中给出的结构化建议事实。"""
|
||||
|
||||
__tablename__ = "ai_decisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "id", name="uq_ai_decisions_tenant_id"),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"expense_case_id",
|
||||
"id",
|
||||
name="uq_ai_decisions_tenant_case_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_ai_decisions_tenant_idempotency",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id"],
|
||||
["expense_cases.tenant_id", "expense_cases.id"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_ai_decisions_tenant_case",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id", "business_event_id"],
|
||||
[
|
||||
"business_events.tenant_id",
|
||||
"business_events.expense_case_id",
|
||||
"business_events.id",
|
||||
],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_ai_decisions_tenant_event",
|
||||
),
|
||||
CheckConstraint(
|
||||
"confidence IS NULL OR (confidence >= 0 AND confidence <= 1)",
|
||||
name="ck_ai_decisions_confidence",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('suggested', 'accepted', 'edited', 'rejected', "
|
||||
"'ignored', 'executed', 'rolled_back')",
|
||||
name="ck_ai_decisions_status",
|
||||
),
|
||||
Index(
|
||||
"ix_ai_decisions_tenant_case_time",
|
||||
"tenant_id",
|
||||
"expense_case_id",
|
||||
"created_at",
|
||||
),
|
||||
Index(
|
||||
"ix_ai_decisions_tenant_subject",
|
||||
"tenant_id",
|
||||
"subject_type",
|
||||
"subject_id",
|
||||
),
|
||||
Index("ix_ai_decisions_agent_run_id", "agent_run_id"),
|
||||
Index("ix_ai_decisions_correlation_id", "correlation_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
expense_case_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
expense_claim_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
agent_run_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
subject_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
subject_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
decision_type: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
decision_source: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
automation_mode: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 4), nullable=True)
|
||||
suggestion_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
evidence_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
version_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
training_eligible: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false"
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
content_fingerprint: Mapped[str] = mapped_column(String(71), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class AIDecisionFeedback(Base):
|
||||
"""用户对 AI 建议的采纳或显式字段纠正事实。"""
|
||||
|
||||
__tablename__ = "ai_decision_feedback"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_ai_decision_feedback_tenant_idempotency",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "decision_id"],
|
||||
["ai_decisions.tenant_id", "ai_decisions.id"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_ai_decision_feedback_tenant_decision",
|
||||
),
|
||||
CheckConstraint(
|
||||
"feedback_type IN ('accepted', 'edited', 'rejected', 'ignored')",
|
||||
name="ck_ai_decision_feedback_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"verification_status IN ('client_observed', 'server_verified', "
|
||||
"'human_verified', 'invalidated')",
|
||||
name="ck_ai_decision_feedback_verification",
|
||||
),
|
||||
CheckConstraint(
|
||||
"NOT training_eligible OR verification_status IN "
|
||||
"('server_verified', 'human_verified')",
|
||||
name="ck_ai_decision_feedback_training_eligibility",
|
||||
),
|
||||
Index(
|
||||
"ix_ai_decision_feedback_tenant_decision_time",
|
||||
"tenant_id",
|
||||
"decision_id",
|
||||
"created_at",
|
||||
),
|
||||
Index(
|
||||
"ix_ai_decision_feedback_tenant_type_time",
|
||||
"tenant_id",
|
||||
"feedback_type",
|
||||
"created_at",
|
||||
),
|
||||
Index("ix_ai_decision_feedback_correlation_id", "correlation_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
decision_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
expense_claim_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
feedback_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
action_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
actor_type: Mapped[str] = mapped_column(String(30), nullable=False, default="user")
|
||||
evidence_source: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
verification_status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
training_eligible: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="false"
|
||||
)
|
||||
final_value_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
changed_fields_json: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, nullable=False, default=list
|
||||
)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
content_fingerprint: Mapped[str] = mapped_column(String(71), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class WorkflowOutcome(Base):
|
||||
"""与 AI 决策关联、但独立于技术执行状态的业务结果。"""
|
||||
|
||||
__tablename__ = "workflow_outcomes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_workflow_outcomes_tenant_idempotency",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id"],
|
||||
["expense_cases.tenant_id", "expense_cases.id"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_workflow_outcomes_tenant_case",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id", "decision_id"],
|
||||
[
|
||||
"ai_decisions.tenant_id",
|
||||
"ai_decisions.expense_case_id",
|
||||
"ai_decisions.id",
|
||||
],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_workflow_outcomes_tenant_decision",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id", "business_event_id"],
|
||||
[
|
||||
"business_events.tenant_id",
|
||||
"business_events.expense_case_id",
|
||||
"business_events.id",
|
||||
],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_workflow_outcomes_tenant_event",
|
||||
),
|
||||
CheckConstraint(
|
||||
"outcome_status IN ('recorded', 'verified', 'reversed')",
|
||||
name="ck_workflow_outcomes_status",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_outcomes_tenant_case_time",
|
||||
"tenant_id",
|
||||
"expense_case_id",
|
||||
"effective_at",
|
||||
),
|
||||
Index(
|
||||
"ix_workflow_outcomes_tenant_type_time",
|
||||
"tenant_id",
|
||||
"outcome_type",
|
||||
"effective_at",
|
||||
),
|
||||
Index("ix_workflow_outcomes_correlation_id", "correlation_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
expense_case_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
decision_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
expense_claim_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
outcome_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
outcome_status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
actor_type: Mapped[str] = mapped_column(String(30), nullable=False, default="user")
|
||||
result_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
content_fingerprint: Mapped[str] = mapped_column(String(71), nullable=False)
|
||||
effective_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
@@ -18,6 +18,7 @@ def _new_id() -> str:
|
||||
class ExpenseCase(Base):
|
||||
__tablename__ = "expense_cases"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "id", name="uq_expense_cases_tenant_id"),
|
||||
UniqueConstraint("tenant_id", "case_no", name="uq_expense_cases_tenant_case_no"),
|
||||
Index("ix_expense_cases_tenant_stage", "tenant_id", "current_stage"),
|
||||
Index("ix_expense_cases_tenant_status", "tenant_id", "status"),
|
||||
@@ -73,6 +74,12 @@ class ExpenseCaseLink(Base):
|
||||
class BusinessEvent(Base):
|
||||
__tablename__ = "business_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"expense_case_id",
|
||||
"id",
|
||||
name="uq_business_events_tenant_case_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"aggregate_type",
|
||||
|
||||
Reference in New Issue
Block a user