feat(ai): add expense application feedback ledger
This commit is contained in:
@@ -163,6 +163,7 @@ def run_application_preview_action(
|
||||
user_agent_response = UserAgentService(db)._build_expense_application_response(
|
||||
request,
|
||||
risk_flags=[],
|
||||
learning_current_user=current_user,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
@@ -9,6 +9,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
|
||||
@@ -52,6 +53,8 @@ __all__ = [
|
||||
"AgentRun",
|
||||
"AgentToolCall",
|
||||
"AgentTraceEvent",
|
||||
"AIDecision",
|
||||
"AIDecisionFeedback",
|
||||
"ApprovalRecord",
|
||||
"AuditLog",
|
||||
"AuthSession",
|
||||
@@ -82,4 +85,5 @@ __all__ = [
|
||||
"SystemSetting",
|
||||
"SystemSettingSecret",
|
||||
"UserSessionMetric",
|
||||
"WorkflowOutcome",
|
||||
]
|
||||
|
||||
@@ -27,8 +27,19 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
|
||||
"auth_sessions",
|
||||
}
|
||||
),
|
||||
"20260714_0003": frozenset(
|
||||
{
|
||||
"expense_cases",
|
||||
"expense_case_links",
|
||||
"business_events",
|
||||
"auth_sessions",
|
||||
"ai_decisions",
|
||||
"ai_decision_feedback",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
),
|
||||
}
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260713_0002"] != MIGRATION_OWNED_TABLES:
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"] != MIGRATION_OWNED_TABLES:
|
||||
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,12 @@ from app.db.base import Base
|
||||
MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
|
||||
{
|
||||
"auth_sessions",
|
||||
"ai_decisions",
|
||||
"ai_decision_feedback",
|
||||
"expense_cases",
|
||||
"expense_case_links",
|
||||
"business_events",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.expense_case import BusinessEvent
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
@@ -30,8 +30,8 @@ class ExpenseApplicationDraftEventService:
|
||||
event_type: str,
|
||||
previous_status: str = "",
|
||||
previous_approval_stage: str = "",
|
||||
) -> None:
|
||||
ExpenseCaseService(self.db).record_claim_event(
|
||||
) -> tuple[ExpenseCase, BusinessEvent]:
|
||||
return ExpenseCaseService(self.db).record_claim_event(
|
||||
claim,
|
||||
event_type=event_type,
|
||||
actor_id=current_user.username,
|
||||
|
||||
382
server/src/app/services/expense_application_learning.py
Normal file
382
server/src/app/services/expense_application_learning.py
Normal file
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.expense_case import BusinessEvent
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpenseApplicationLearningRecords:
|
||||
decision: AIDecision
|
||||
feedback: AIDecisionFeedback
|
||||
outcome: WorkflowOutcome
|
||||
|
||||
|
||||
class ExpenseApplicationLearningService:
|
||||
"""把 AI 申请预填、显式用户编辑和工作流结果原子写入学习账本。"""
|
||||
|
||||
_FACT_KEYS = (
|
||||
"application_type",
|
||||
"time",
|
||||
"location",
|
||||
"reason",
|
||||
"days",
|
||||
"transport_mode",
|
||||
"amount",
|
||||
"grade",
|
||||
"department",
|
||||
"position",
|
||||
"manager_name",
|
||||
"lodging_daily_cap",
|
||||
"subsidy_daily_cap",
|
||||
"transport_policy",
|
||||
"policy_estimate",
|
||||
"matched_city",
|
||||
"rule_name",
|
||||
"rule_version",
|
||||
"hotel_amount",
|
||||
"allowance_amount",
|
||||
"transport_estimated_amount",
|
||||
"transport_estimate_source",
|
||||
"transport_estimate_confidence",
|
||||
"policy_total_amount",
|
||||
)
|
||||
_FIELD_ALIASES = {
|
||||
"applicationType": "application_type",
|
||||
"application_type": "application_type",
|
||||
"time": "time",
|
||||
"time_return": "time",
|
||||
"location": "location",
|
||||
"reason": "reason",
|
||||
"days": "days",
|
||||
"transportMode": "transport_mode",
|
||||
"transport_mode": "transport_mode",
|
||||
"amount": "amount",
|
||||
"grade": "grade",
|
||||
"department": "department",
|
||||
"position": "position",
|
||||
"managerName": "manager_name",
|
||||
"manager_name": "manager_name",
|
||||
}
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def record_action(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
claim: ExpenseClaim,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
action_type: str,
|
||||
business_event: BusinessEvent,
|
||||
) -> ExpenseApplicationLearningRecords | None:
|
||||
normalized_action = self._normalize_action_type(action_type)
|
||||
preview = self._application_preview(payload)
|
||||
if not self._is_learning_eligible_preview(preview):
|
||||
return None
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
expense_case = ExpenseCaseService(self.db).ensure_case_for_claim(
|
||||
claim,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
if (
|
||||
business_event.tenant_id != tenant_id
|
||||
or business_event.expense_case_id != expense_case.id
|
||||
):
|
||||
raise PermissionError("AI 学习记录不能关联其他租户或费用事件的业务事件。")
|
||||
|
||||
final_values = self._safe_fact_snapshot(facts)
|
||||
changed_fields = self._safe_changed_fields(payload, final_values)
|
||||
suggested_values = dict(final_values)
|
||||
for item in changed_fields:
|
||||
suggested_values[item["field_key"]] = item["suggested_value"]
|
||||
|
||||
feedback_type = "edited" if changed_fields else "accepted"
|
||||
correlation_id = ExpenseCaseService.normalize_correlation_id(payload.run_id)
|
||||
fingerprint_payload = {
|
||||
"tenant_id": tenant_id,
|
||||
"expense_claim_id": claim.id,
|
||||
"action_type": normalized_action,
|
||||
"suggested_values": suggested_values,
|
||||
"final_values": final_values,
|
||||
"changed_fields": changed_fields,
|
||||
}
|
||||
content_fingerprint = self._fingerprint(fingerprint_payload)
|
||||
idempotency_key = self._idempotency_key(
|
||||
normalized_action,
|
||||
claim.id,
|
||||
content_fingerprint,
|
||||
)
|
||||
existing = self._find_existing(tenant_id, idempotency_key)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
decision_id = self._stable_id("decision", tenant_id, idempotency_key)
|
||||
decision = AIDecision(
|
||||
id=decision_id,
|
||||
tenant_id=tenant_id,
|
||||
expense_case_id=expense_case.id,
|
||||
business_event_id=business_event.id,
|
||||
expense_claim_id=claim.id,
|
||||
agent_run_id=str(payload.run_id or "").strip() or None,
|
||||
correlation_id=correlation_id,
|
||||
subject_type="expense_claim",
|
||||
subject_id=claim.id,
|
||||
decision_type="expense_application_prefill",
|
||||
decision_source=self._decision_source(preview, facts),
|
||||
status="executed",
|
||||
automation_mode="prefill",
|
||||
confidence=self._confidence(payload),
|
||||
suggestion_json=self._snapshot_reference(suggested_values),
|
||||
evidence_json={
|
||||
"evidence_source": "client_preview_edit_trace",
|
||||
"trust_level": "behavioral_analytics_only",
|
||||
"model_refined": bool(preview.get("modelRefined")),
|
||||
"model_review_status": self._safe_text(preview.get("modelReviewStatus"), 40),
|
||||
},
|
||||
version_json={
|
||||
"schema_version": 1,
|
||||
"parse_strategy": self._safe_text(preview.get("parseStrategy"), 60),
|
||||
"rule_name": self._safe_text(facts.get("rule_name"), 120),
|
||||
"rule_version": self._safe_text(facts.get("rule_version"), 80),
|
||||
},
|
||||
schema_version=1,
|
||||
training_eligible=False,
|
||||
idempotency_key=idempotency_key,
|
||||
content_fingerprint=content_fingerprint,
|
||||
)
|
||||
feedback = AIDecisionFeedback(
|
||||
id=self._stable_id("feedback", tenant_id, idempotency_key),
|
||||
tenant_id=tenant_id,
|
||||
decision_id=decision_id,
|
||||
expense_claim_id=claim.id,
|
||||
correlation_id=correlation_id,
|
||||
feedback_type=feedback_type,
|
||||
action_type=normalized_action,
|
||||
actor_id=str(current_user.username or "anonymous").strip() or "anonymous",
|
||||
actor_type="user",
|
||||
evidence_source=(
|
||||
"client_explicit_field_edit"
|
||||
if changed_fields
|
||||
else "client_action_confirmation"
|
||||
),
|
||||
verification_status="client_observed",
|
||||
training_eligible=False,
|
||||
final_value_json=self._snapshot_reference(final_values),
|
||||
changed_fields_json=self._changed_field_references(changed_fields),
|
||||
idempotency_key=f"feedback:{idempotency_key}"[:120],
|
||||
content_fingerprint=self._fingerprint(
|
||||
{
|
||||
"decision_id": decision_id,
|
||||
"feedback_type": feedback_type,
|
||||
"final_values": final_values,
|
||||
"changed_fields": changed_fields,
|
||||
}
|
||||
),
|
||||
)
|
||||
outcome_type = (
|
||||
"application_submitted" if normalized_action == "submit" else "draft_saved"
|
||||
)
|
||||
outcome = WorkflowOutcome(
|
||||
id=self._stable_id("outcome", tenant_id, idempotency_key),
|
||||
tenant_id=tenant_id,
|
||||
expense_case_id=expense_case.id,
|
||||
decision_id=decision_id,
|
||||
business_event_id=business_event.id,
|
||||
expense_claim_id=claim.id,
|
||||
correlation_id=correlation_id,
|
||||
outcome_type=outcome_type,
|
||||
outcome_status="recorded",
|
||||
actor_id=str(current_user.username or "anonymous").strip() or "anonymous",
|
||||
actor_type="user",
|
||||
result_json={
|
||||
"claim_status": "submitted" if normalized_action == "submit" else "draft",
|
||||
"approval_stage": (
|
||||
"直属领导审批" if normalized_action == "submit" else "待提交"
|
||||
),
|
||||
"feedback_type": feedback_type,
|
||||
},
|
||||
idempotency_key=f"outcome:{idempotency_key}"[:120],
|
||||
content_fingerprint=self._fingerprint(
|
||||
{
|
||||
"decision_id": decision_id,
|
||||
"outcome_type": outcome_type,
|
||||
"feedback_type": feedback_type,
|
||||
}
|
||||
),
|
||||
)
|
||||
self.db.add_all([decision, feedback, outcome])
|
||||
self.db.flush()
|
||||
return ExpenseApplicationLearningRecords(decision, feedback, outcome)
|
||||
|
||||
def _find_existing(
|
||||
self,
|
||||
tenant_id: str,
|
||||
idempotency_key: str,
|
||||
) -> ExpenseApplicationLearningRecords | None:
|
||||
decision = self.db.scalar(
|
||||
select(AIDecision).where(
|
||||
AIDecision.tenant_id == tenant_id,
|
||||
AIDecision.idempotency_key == idempotency_key,
|
||||
)
|
||||
)
|
||||
if decision is None:
|
||||
return None
|
||||
feedback = self.db.scalar(
|
||||
select(AIDecisionFeedback).where(
|
||||
AIDecisionFeedback.tenant_id == tenant_id,
|
||||
AIDecisionFeedback.decision_id == decision.id,
|
||||
)
|
||||
)
|
||||
outcome = self.db.scalar(
|
||||
select(WorkflowOutcome).where(
|
||||
WorkflowOutcome.tenant_id == tenant_id,
|
||||
WorkflowOutcome.decision_id == decision.id,
|
||||
)
|
||||
)
|
||||
if feedback is None or outcome is None:
|
||||
raise RuntimeError("AI 学习账本幂等状态不完整,请重试。")
|
||||
return ExpenseApplicationLearningRecords(decision, feedback, outcome)
|
||||
|
||||
@classmethod
|
||||
def _safe_fact_snapshot(cls, facts: dict[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
key: cls._safe_text(facts.get(key), 500)
|
||||
for key in cls._FACT_KEYS
|
||||
if cls._safe_text(facts.get(key), 500)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _safe_changed_fields(
|
||||
cls,
|
||||
payload: UserAgentRequest,
|
||||
final_values: dict[str, str],
|
||||
) -> list[dict[str, str]]:
|
||||
raw_feedback = cls._application_preview(payload).get("aiDecisionFeedback")
|
||||
if not isinstance(raw_feedback, dict):
|
||||
return []
|
||||
raw_changes = raw_feedback.get("changedFields")
|
||||
if not isinstance(raw_changes, list):
|
||||
return []
|
||||
|
||||
changes: dict[str, dict[str, str]] = {}
|
||||
for raw_item in raw_changes[:30]:
|
||||
if not isinstance(raw_item, dict):
|
||||
continue
|
||||
alias = cls._safe_text(raw_item.get("fieldKey"), 60)
|
||||
field_key = cls._FIELD_ALIASES.get(alias)
|
||||
if not field_key or field_key not in final_values:
|
||||
continue
|
||||
suggested_value = cls._safe_text(raw_item.get("suggestedValue"), 500)
|
||||
final_value = cls._safe_text(final_values.get(field_key), 500)
|
||||
if suggested_value == final_value:
|
||||
continue
|
||||
changes[field_key] = {
|
||||
"field_key": field_key,
|
||||
"suggested_value": suggested_value,
|
||||
"final_value": final_value,
|
||||
}
|
||||
return [changes[key] for key in sorted(changes)]
|
||||
|
||||
@staticmethod
|
||||
def _application_preview(payload: UserAgentRequest) -> dict[str, Any]:
|
||||
context = payload.context_json if isinstance(payload.context_json, dict) else {}
|
||||
preview = context.get("application_preview")
|
||||
return preview if isinstance(preview, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _is_learning_eligible_preview(preview: dict[str, Any]) -> bool:
|
||||
fields = preview.get("fields")
|
||||
if not isinstance(fields, dict) or not fields:
|
||||
return False
|
||||
if bool(preview.get("applicationEditMode") or preview.get("application_edit_mode")):
|
||||
return False
|
||||
return str(preview.get("modelReviewStatus") or "").strip().lower() != "template"
|
||||
|
||||
@classmethod
|
||||
def _snapshot_reference(cls, values: dict[str, str]) -> dict[str, Any]:
|
||||
return {
|
||||
"field_keys": sorted(values),
|
||||
"value_fingerprint": cls._fingerprint(values),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _changed_field_references(
|
||||
cls,
|
||||
changes: list[dict[str, str]],
|
||||
) -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"field_key": item["field_key"],
|
||||
"suggested_value_fingerprint": cls._fingerprint(item["suggested_value"]),
|
||||
"final_value_fingerprint": cls._fingerprint(item["final_value"]),
|
||||
}
|
||||
for item in changes
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _decision_source(preview: dict[str, Any], facts: dict[str, str]) -> str:
|
||||
if preview.get("modelRefined") and facts.get("rule_version"):
|
||||
return "hybrid"
|
||||
if preview.get("modelRefined"):
|
||||
return "model"
|
||||
if facts.get("rule_version"):
|
||||
return "rule"
|
||||
return "heuristic"
|
||||
|
||||
@staticmethod
|
||||
def _confidence(payload: UserAgentRequest) -> Decimal | None:
|
||||
raw_value = getattr(payload.ontology, "confidence", None)
|
||||
if raw_value is None:
|
||||
return None
|
||||
try:
|
||||
value = Decimal(str(raw_value))
|
||||
except Exception:
|
||||
return None
|
||||
return max(Decimal("0"), min(Decimal("1"), value)).quantize(Decimal("0.0001"))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_action_type(action_type: str) -> str:
|
||||
normalized = str(action_type or "").strip().lower()
|
||||
if normalized not in {"save_draft", "submit"}:
|
||||
raise ValueError("不支持的 AI 申请动作。")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _safe_text(value: object, limit: int) -> str:
|
||||
return str(value or "").strip()[:limit]
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint(payload: object) -> str:
|
||||
serialized = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
return f"sha256:{hashlib.sha256(serialized.encode('utf-8')).hexdigest()}"
|
||||
|
||||
@staticmethod
|
||||
def _idempotency_key(action_type: str, claim_id: str, fingerprint: str) -> str:
|
||||
digest = fingerprint.removeprefix("sha256:")
|
||||
return f"ai-application:{action_type}:{claim_id}:{digest}"[:120]
|
||||
|
||||
@staticmethod
|
||||
def _stable_id(record_type: str, tenant_id: str, idempotency_key: str) -> str:
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"{record_type}:{tenant_id}:{idempotency_key}"))
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
@@ -20,6 +21,7 @@ from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.hermes_report import HermesRiskReport
|
||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||
@@ -563,7 +565,14 @@ class ExpenseClaimItemActionMixin:
|
||||
"item_id": item.id,
|
||||
}
|
||||
|
||||
def submit_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None:
|
||||
def submit_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
before_commit: Callable[[BusinessEvent], None] | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
@@ -646,11 +655,12 @@ class ExpenseClaimItemActionMixin:
|
||||
|
||||
claim.risk_flags_json = dedupe_claim_risk_flags(claim.risk_flags_json)
|
||||
|
||||
self._expense_cases.record_claim_event(
|
||||
_, submission_event = self._expense_cases.record_claim_event(
|
||||
claim,
|
||||
event_type=("application_submitted" if is_application_claim else "claim_submitted"),
|
||||
actor_id=current_user.username,
|
||||
tenant_id=getattr(current_user, "tenant_id", None),
|
||||
correlation_id=correlation_id,
|
||||
idempotency_key=(
|
||||
f"submit:{claim.id}:{claim.submitted_at.isoformat()}"
|
||||
if claim.submitted_at is not None
|
||||
@@ -659,6 +669,8 @@ class ExpenseClaimItemActionMixin:
|
||||
previous_status=str(before_json.get("status") or ""),
|
||||
previous_approval_stage=str(before_json.get("approval_stage") or ""),
|
||||
)
|
||||
if before_commit is not None:
|
||||
before_commit(submission_event)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.expense_case import BusinessEvent
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||
from app.schemas.user_agent import (
|
||||
@@ -28,6 +29,7 @@ from app.services.document_numbering import (
|
||||
from app.services.expense_application_draft_events import (
|
||||
ExpenseApplicationDraftEventService,
|
||||
)
|
||||
from app.services.expense_application_learning import ExpenseApplicationLearningService
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
@@ -635,6 +637,27 @@ class UserAgentApplicationSlotMixin:
|
||||
|
||||
|
||||
class UserAgentApplicationPersistenceMixin:
|
||||
def _record_expense_application_learning(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
claim: ExpenseClaim,
|
||||
trusted_current_user: CurrentUserContext | None,
|
||||
*,
|
||||
action_type: str,
|
||||
business_event: BusinessEvent,
|
||||
) -> None:
|
||||
if trusted_current_user is None:
|
||||
return
|
||||
ExpenseApplicationLearningService(self.db).record_action(
|
||||
payload,
|
||||
facts,
|
||||
claim,
|
||||
trusted_current_user,
|
||||
action_type=action_type,
|
||||
business_event=business_event,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_edit_claim_id(context_json: dict[str, object]) -> str:
|
||||
if not isinstance(context_json, dict):
|
||||
@@ -703,6 +726,7 @@ class UserAgentApplicationPersistenceMixin:
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
submit: bool,
|
||||
learning_current_user: CurrentUserContext | None = None,
|
||||
) -> ExpenseClaim:
|
||||
current_user = self._build_application_current_user(payload)
|
||||
previous_status = str(claim.status or "").strip()
|
||||
@@ -732,7 +756,7 @@ class UserAgentApplicationPersistenceMixin:
|
||||
claim.approval_stage = "待提交"
|
||||
claim.submitted_at = None
|
||||
try:
|
||||
ExpenseApplicationDraftEventService(self.db).record(
|
||||
_, draft_event = ExpenseApplicationDraftEventService(self.db).record(
|
||||
payload,
|
||||
claim,
|
||||
current_user,
|
||||
@@ -740,6 +764,14 @@ class UserAgentApplicationPersistenceMixin:
|
||||
previous_status=previous_status,
|
||||
previous_approval_stage=previous_approval_stage,
|
||||
)
|
||||
self._record_expense_application_learning(
|
||||
payload,
|
||||
facts,
|
||||
claim,
|
||||
learning_current_user,
|
||||
action_type="save_draft",
|
||||
business_event=draft_event,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
return claim
|
||||
@@ -749,10 +781,28 @@ class UserAgentApplicationPersistenceMixin:
|
||||
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
|
||||
if submitted is None:
|
||||
raise ValueError("未找到可修改的申请单。")
|
||||
return submitted
|
||||
try:
|
||||
submitted = ExpenseClaimService(self.db).submit_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
correlation_id=payload.run_id,
|
||||
before_commit=(
|
||||
lambda event: self._record_expense_application_learning(
|
||||
payload,
|
||||
facts,
|
||||
claim,
|
||||
learning_current_user,
|
||||
action_type="submit",
|
||||
business_event=event,
|
||||
)
|
||||
),
|
||||
)
|
||||
if submitted is None:
|
||||
raise ValueError("未找到可修改的申请单。")
|
||||
return submitted
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
|
||||
def _create_expense_application_record(
|
||||
self,
|
||||
@@ -760,6 +810,7 @@ class UserAgentApplicationPersistenceMixin:
|
||||
facts: dict[str, str],
|
||||
*,
|
||||
submit: bool,
|
||||
learning_current_user: CurrentUserContext | None = None,
|
||||
) -> ExpenseClaim:
|
||||
current_user = self._build_application_current_user(payload)
|
||||
access_policy = ExpenseClaimAccessPolicy(self.db)
|
||||
@@ -821,19 +872,41 @@ class UserAgentApplicationPersistenceMixin:
|
||||
return existing
|
||||
raise
|
||||
if not submit:
|
||||
draft_event_service.record(
|
||||
_, draft_event = draft_event_service.record(
|
||||
payload,
|
||||
claim,
|
||||
current_user,
|
||||
event_type="claim_draft_created",
|
||||
)
|
||||
self._record_expense_application_learning(
|
||||
payload,
|
||||
facts,
|
||||
claim,
|
||||
learning_current_user,
|
||||
action_type="save_draft",
|
||||
business_event=draft_event,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
return claim
|
||||
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
|
||||
submitted = ExpenseClaimService(self.db).submit_claim(
|
||||
claim.id,
|
||||
current_user,
|
||||
correlation_id=payload.run_id,
|
||||
before_commit=(
|
||||
lambda event: self._record_expense_application_learning(
|
||||
payload,
|
||||
facts,
|
||||
claim,
|
||||
learning_current_user,
|
||||
action_type="submit",
|
||||
business_event=event,
|
||||
)
|
||||
),
|
||||
)
|
||||
if submitted is None:
|
||||
raise ValueError("未找到可提交的申请单。")
|
||||
return submitted
|
||||
@@ -1212,6 +1285,7 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
|
||||
payload: UserAgentRequest,
|
||||
*,
|
||||
risk_flags: list[str],
|
||||
learning_current_user: CurrentUserContext | None = None,
|
||||
) -> UserAgentResponse:
|
||||
facts = self._resolve_expense_application_facts(payload)
|
||||
step = self._resolve_expense_application_step(payload, facts)
|
||||
@@ -1224,6 +1298,7 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
|
||||
facts,
|
||||
editable_claim,
|
||||
submit=step == "submitted",
|
||||
learning_current_user=learning_current_user,
|
||||
)
|
||||
facts["application_edit_mode"] = "true"
|
||||
elif step == "submitted":
|
||||
@@ -1236,12 +1311,14 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
|
||||
payload,
|
||||
facts,
|
||||
submit=True,
|
||||
learning_current_user=learning_current_user,
|
||||
)
|
||||
else:
|
||||
application_claim = self._create_expense_application_record(
|
||||
payload,
|
||||
facts,
|
||||
submit=False,
|
||||
learning_current_user=learning_current_user,
|
||||
)
|
||||
if application_claim is not None:
|
||||
facts["application_no"] = application_claim.claim_no
|
||||
|
||||
Reference in New Issue
Block a user