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

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

View File

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

View File

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

View File

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

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

View File

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

View 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}"))

View File

@@ -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)

View File

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