feat(ai): add expense application feedback ledger

This commit is contained in:
caoxiaozhu
2026-07-14 11:10:55 +08:00
parent 5ed34c2b8f
commit a662cfe6c3
24 changed files with 2159 additions and 34 deletions

View File

@@ -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",
]

View 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()
)

View File

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