feat(ai): issue verified application preview decisions

This commit is contained in:
caoxiaozhu
2026-07-14 14:37:53 +08:00
parent a662cfe6c3
commit 5b24630710
32 changed files with 1976 additions and 217 deletions

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import hashlib
import json
import uuid
from dataclasses import dataclass
from decimal import Decimal
@@ -11,10 +9,22 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.ai_application_preview import AIApplicationPreviewDecision
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_application_preview_decisions import (
ExpenseApplicationPreviewDecisionService,
)
from app.services.expense_application_snapshot import (
PREVIEW_FIELD_ALIASES,
content_fingerprint,
field_value_fingerprint,
hmac_fingerprint,
safe_fact_snapshot,
snapshot_reference,
)
from app.services.expense_cases import ExpenseCaseService
@@ -28,50 +38,6 @@ class ExpenseApplicationLearningRecords:
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
@@ -84,10 +50,14 @@ class ExpenseApplicationLearningService:
*,
action_type: str,
business_event: BusinessEvent,
preview_decision: AIApplicationPreviewDecision | None = None,
action_request_id: str = "",
) -> ExpenseApplicationLearningRecords | None:
normalized_action = self._normalize_action_type(action_type)
preview = self._application_preview(payload)
if not self._is_learning_eligible_preview(preview):
if preview_decision is not None:
raise ValueError("服务端预览决策缺少可核验的申请字段。")
return None
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
expense_case = ExpenseCaseService(self.db).ensure_case_for_claim(
@@ -101,20 +71,48 @@ class ExpenseApplicationLearningService:
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"]
if preview_decision is not None:
final_reference = self._snapshot_reference(
final_values,
key_version=preview_decision.fingerprint_key_version,
allow_key_creation=False,
)
changed_field_references = self._verified_changed_field_references(
preview_decision,
final_values,
)
suggestion_reference = {
"field_keys": list(preview_decision.field_keys_json or []),
"value_fingerprint": preview_decision.snapshot_fingerprint,
"fingerprint_key_version": preview_decision.fingerprint_key_version,
}
evidence_source = "server_preview_decision"
verification_status = "server_verified"
else:
final_reference = self._snapshot_reference(final_values)
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"]
changed_field_references = self._changed_field_references(changed_fields)
suggestion_reference = self._snapshot_reference(suggested_values)
evidence_source = (
"client_explicit_field_edit"
if changed_field_references
else "client_action_confirmation"
)
verification_status = "client_observed"
feedback_type = "edited" if changed_fields else "accepted"
feedback_type = "edited" if changed_field_references 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,
"preview_decision_id": preview_decision.id if preview_decision is not None else None,
"suggestion_reference": suggestion_reference,
"final_reference": final_reference,
"changed_fields": changed_field_references,
}
content_fingerprint = self._fingerprint(fingerprint_payload)
idempotency_key = self._idempotency_key(
@@ -133,24 +131,38 @@ class ExpenseApplicationLearningService:
expense_case_id=expense_case.id,
business_event_id=business_event.id,
expense_claim_id=claim.id,
preview_decision_id=(preview_decision.id if preview_decision is not None else None),
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),
decision_source=(
preview_decision.decision_source
if preview_decision is not None
else self._decision_source(preview, facts)
),
status="executed",
automation_mode="prefill",
confidence=self._confidence(payload),
suggestion_json=self._snapshot_reference(suggested_values),
suggestion_json=suggestion_reference,
evidence_json={
"evidence_source": "client_preview_edit_trace",
"trust_level": "behavioral_analytics_only",
"evidence_source": (
"server_preview_decision"
if preview_decision is not None
else "client_preview_edit_trace"
),
"trust_level": (
"server_snapshot_verified"
if preview_decision is not None
else "behavioral_analytics_only"
),
"model_refined": bool(preview.get("modelRefined")),
"model_review_status": self._safe_text(preview.get("modelReviewStatus"), 40),
},
version_json={
"schema_version": 1,
"fingerprint_key_version": suggestion_reference.get("fingerprint_key_version"),
"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),
@@ -170,28 +182,22 @@ class ExpenseApplicationLearningService:
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",
evidence_source=evidence_source,
verification_status=verification_status,
training_eligible=False,
final_value_json=self._snapshot_reference(final_values),
changed_fields_json=self._changed_field_references(changed_fields),
final_value_json=final_reference,
changed_fields_json=changed_field_references,
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,
"final_reference": final_reference,
"changed_fields": changed_field_references,
}
),
)
outcome_type = (
"application_submitted" if normalized_action == "submit" else "draft_saved"
)
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,
@@ -206,9 +212,7 @@ class ExpenseApplicationLearningService:
actor_type="user",
result_json={
"claim_status": "submitted" if normalized_action == "submit" else "draft",
"approval_stage": (
"直属领导审批" if normalized_action == "submit" else "待提交"
),
"approval_stage": ("直属领导审批" if normalized_action == "submit" else "待提交"),
"feedback_type": feedback_type,
},
idempotency_key=f"outcome:{idempotency_key}"[:120],
@@ -222,6 +226,15 @@ class ExpenseApplicationLearningService:
)
self.db.add_all([decision, feedback, outcome])
self.db.flush()
if preview_decision is not None:
ExpenseApplicationPreviewDecisionService(self.db).consume(
preview_decision,
request_id=action_request_id,
action_type=normalized_action,
final_values=final_values,
claim=claim,
business_event=business_event,
)
return ExpenseApplicationLearningRecords(decision, feedback, outcome)
def _find_existing(
@@ -255,11 +268,7 @@ class ExpenseApplicationLearningService:
@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)
}
return safe_fact_snapshot(facts)
@classmethod
def _safe_changed_fields(
@@ -279,7 +288,7 @@ class ExpenseApplicationLearningService:
if not isinstance(raw_item, dict):
continue
alias = cls._safe_text(raw_item.get("fieldKey"), 60)
field_key = cls._FIELD_ALIASES.get(alias)
field_key = PREVIEW_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)
@@ -309,11 +318,55 @@ class ExpenseApplicationLearningService:
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),
}
def _snapshot_reference(
cls,
values: dict[str, str],
*,
key_version: str | None = None,
allow_key_creation: bool = True,
) -> dict[str, Any]:
if key_version is None:
return snapshot_reference(values)
return snapshot_reference(
values,
key_version=key_version,
allow_key_creation=allow_key_creation,
)
@staticmethod
def _verified_changed_field_references(
decision: AIApplicationPreviewDecision,
final_values: dict[str, str],
) -> list[dict[str, str]]:
fingerprints = decision.field_fingerprints_json or {}
changes: list[dict[str, str]] = []
suggested_field_keys = set(decision.field_keys_json or [])
final_field_keys = set(final_values)
for field_key in sorted(suggested_field_keys | final_field_keys):
suggested_fingerprint = str(fingerprints.get(field_key) or "").strip()
if not suggested_fingerprint:
suggested_fingerprint = field_value_fingerprint(
"",
present=False,
key_version=decision.fingerprint_key_version,
allow_key_creation=False,
)
final_fingerprint = field_value_fingerprint(
final_values.get(field_key, ""),
present=field_key in final_values,
key_version=decision.fingerprint_key_version,
allow_key_creation=False,
)
if suggested_fingerprint == final_fingerprint:
continue
changes.append(
{
"field_key": field_key,
"suggested_value_fingerprint": suggested_fingerprint,
"final_value_fingerprint": final_fingerprint,
}
)
return changes
@classmethod
def _changed_field_references(
@@ -323,8 +376,8 @@ class ExpenseApplicationLearningService:
return [
{
"field_key": item["field_key"],
"suggested_value_fingerprint": cls._fingerprint(item["suggested_value"]),
"final_value_fingerprint": cls._fingerprint(item["final_value"]),
"suggested_value_fingerprint": hmac_fingerprint(item["suggested_value"]),
"final_value_fingerprint": hmac_fingerprint(item["final_value"]),
}
for item in changes
]
@@ -363,14 +416,7 @@ class ExpenseApplicationLearningService:
@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()}"
return content_fingerprint(payload)
@staticmethod
def _idempotency_key(action_type: str, claim_id: str, fingerprint: str) -> str:

View File

@@ -0,0 +1,276 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.expense_case import BusinessEvent
from app.models.financial_record import ExpenseClaim
from app.services.expense_application_snapshot import (
FINGERPRINT_KEY_VERSION,
field_fingerprints,
hmac_fingerprint,
preview_response_fields,
safe_fact_snapshot,
)
from app.services.expense_cases import ExpenseCaseService
PREVIEW_DECISION_TTL = timedelta(minutes=30)
class PreviewDecisionConflictError(ValueError):
"""预览决策已过期、已消费或与当前动作不一致。"""
@dataclass(frozen=True)
class IssuedPreviewDecision:
decision: AIApplicationPreviewDecision
fields: dict[str, str]
class ExpenseApplicationPreviewDecisionService:
"""签发、授权和消费费用申请预览决策,不保存明文建议值。"""
def __init__(self, db: Session) -> None:
self.db = db
def issue(
self,
facts: dict[str, Any],
current_user: CurrentUserContext,
*,
conversation_id: str,
request_id: str,
decision_source: str | None = None,
) -> IssuedPreviewDecision:
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
actor_id = self._actor_id(current_user)
auth_session_id = self._auth_session_id(current_user)
normalized_request_id = self._required_text(request_id, "预览签发请求 ID", 120)
values = safe_fact_snapshot(facts)
if not values.get("time") and not values.get("location") and not values.get("reason"):
raise ValueError("未识别到可签发的费用申请字段。")
existing = self.db.scalar(
select(AIApplicationPreviewDecision).where(
AIApplicationPreviewDecision.tenant_id == tenant_id,
AIApplicationPreviewDecision.actor_id == actor_id,
AIApplicationPreviewDecision.auth_session_id == auth_session_id,
AIApplicationPreviewDecision.issue_request_id == normalized_request_id,
)
)
if existing is not None:
snapshot_fingerprint = hmac_fingerprint(
values,
key_version=existing.fingerprint_key_version,
allow_key_creation=False,
)
if existing.snapshot_fingerprint != snapshot_fingerprint:
raise PreviewDecisionConflictError("同一预览签发请求不能对应不同内容。")
return IssuedPreviewDecision(existing, preview_response_fields(facts))
now = datetime.now(UTC)
source = decision_source or self._decision_source(facts)
snapshot_fingerprint = hmac_fingerprint(
values,
key_version=FINGERPRINT_KEY_VERSION,
)
decision = AIApplicationPreviewDecision(
tenant_id=tenant_id,
actor_id=actor_id,
auth_session_id=auth_session_id,
conversation_id=str(conversation_id or "").strip()[:120],
decision_type="expense_application_prefill",
decision_source=source,
field_keys_json=sorted(values),
field_fingerprints_json=field_fingerprints(
values,
key_version=FINGERPRINT_KEY_VERSION,
),
snapshot_fingerprint=snapshot_fingerprint,
fingerprint_key_version=FINGERPRINT_KEY_VERSION,
source_version_json={
"schema_version": 1,
"rule_name_fingerprint": hmac_fingerprint(
values.get("rule_name", ""),
key_version=FINGERPRINT_KEY_VERSION,
),
"rule_version_fingerprint": hmac_fingerprint(
values.get("rule_version", ""),
key_version=FINGERPRINT_KEY_VERSION,
),
},
status="issued",
issue_request_id=normalized_request_id,
expires_at=now + PREVIEW_DECISION_TTL,
)
self.db.add(decision)
self.db.flush()
return IssuedPreviewDecision(decision, preview_response_fields(facts))
def require_for_action(
self,
decision_id: str,
current_user: CurrentUserContext,
*,
conversation_id: str,
request_id: str,
action_type: str,
final_values: dict[str, Any],
) -> AIApplicationPreviewDecision:
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
actor_id = self._actor_id(current_user)
auth_session_id = self._auth_session_id(current_user)
normalized_id = self._required_text(decision_id, "预览决策 ID", 36)
normalized_request_id = self._required_text(request_id, "动作请求 ID", 120)
normalized_action = self._normalize_action(action_type)
decision = self.db.scalar(
select(AIApplicationPreviewDecision)
.where(
AIApplicationPreviewDecision.id == normalized_id,
AIApplicationPreviewDecision.tenant_id == tenant_id,
AIApplicationPreviewDecision.actor_id == actor_id,
AIApplicationPreviewDecision.auth_session_id == auth_session_id,
)
.with_for_update()
)
if decision is None:
raise ValueError("预览决策不存在或不属于当前登录会话。")
if decision.conversation_id != str(conversation_id or "").strip()[:120]:
raise ValueError("预览决策不属于当前会话。")
final_fingerprint = self._final_fingerprint(decision, final_values)
if decision.status == "consumed":
if (
decision.consumed_action == normalized_action
and decision.consumed_request_id == normalized_request_id
and decision.consumed_final_fingerprint == final_fingerprint
):
return decision
raise PreviewDecisionConflictError("该预览决策已被其他动作消费,请重新生成预览。")
if decision.status != "issued":
raise PreviewDecisionConflictError("该预览决策当前不可使用,请重新生成预览。")
if self._as_utc(decision.expires_at) <= datetime.now(UTC):
raise PreviewDecisionConflictError("该预览决策已过期,请重新生成预览。")
return decision
def reject_active_decision_bypass(
self,
current_user: CurrentUserContext,
*,
conversation_id: str,
) -> None:
"""同一登录会话已有有效签发时,不允许通过省略 decision_id 降级。"""
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
actor_id = self._actor_id(current_user)
auth_session_id = str(current_user.auth_session_id or "").strip()[:120]
# 旧调用没有会话标识,也不具备签发 decision_id 的前提,继续走非可信兼容路径。
if not auth_session_id:
return
normalized_conversation_id = str(conversation_id or "").strip()[:120]
active_decision = self.db.scalar(
select(AIApplicationPreviewDecision)
.where(
AIApplicationPreviewDecision.tenant_id == tenant_id,
AIApplicationPreviewDecision.actor_id == actor_id,
AIApplicationPreviewDecision.auth_session_id == auth_session_id,
AIApplicationPreviewDecision.conversation_id == normalized_conversation_id,
AIApplicationPreviewDecision.status == "issued",
AIApplicationPreviewDecision.expires_at > datetime.now(UTC),
)
.order_by(AIApplicationPreviewDecision.created_at.desc())
.with_for_update()
)
if active_decision is not None:
raise PreviewDecisionConflictError(
"当前会话已有服务端签发预览,动作必须携带 decision_id。"
)
def consume(
self,
decision: AIApplicationPreviewDecision,
*,
request_id: str,
action_type: str,
final_values: dict[str, Any],
claim: ExpenseClaim,
business_event: BusinessEvent,
) -> None:
normalized_request_id = self._required_text(request_id, "动作请求 ID", 120)
normalized_action = self._normalize_action(action_type)
final_fingerprint = self._final_fingerprint(decision, final_values)
if decision.status == "consumed":
if (
decision.consumed_action == normalized_action
and decision.consumed_request_id == normalized_request_id
and decision.consumed_claim_id == claim.id
and decision.consumed_business_event_id == business_event.id
and decision.consumed_final_fingerprint == final_fingerprint
):
return
raise PreviewDecisionConflictError("该预览决策已被消费。")
if decision.status != "issued":
raise PreviewDecisionConflictError("该预览决策当前不可消费。")
decision.status = "consumed"
decision.consumed_action = normalized_action
decision.consumed_request_id = normalized_request_id
decision.consumed_claim_id = claim.id
decision.consumed_business_event_id = business_event.id
decision.consumed_final_fingerprint = final_fingerprint
decision.consumed_at = datetime.now(UTC)
self.db.flush()
@staticmethod
def _final_fingerprint(
decision: AIApplicationPreviewDecision,
final_values: dict[str, Any],
) -> str:
return hmac_fingerprint(
safe_fact_snapshot(final_values),
key_version=decision.fingerprint_key_version,
allow_key_creation=False,
)
@staticmethod
def _decision_source(facts: dict[str, Any]) -> str:
has_rule = bool(str(facts.get("rule_version") or "").strip())
has_transport_estimate = bool(str(facts.get("transport_estimate_source") or "").strip())
if has_rule and has_transport_estimate:
return "hybrid"
if has_rule:
return "rule"
return "heuristic"
@staticmethod
def _actor_id(current_user: CurrentUserContext) -> str:
return str(current_user.employee_id or current_user.username or "").strip()[:120]
@staticmethod
def _auth_session_id(current_user: CurrentUserContext) -> str:
session_id = str(current_user.auth_session_id or "").strip()[:120]
if not session_id:
raise ValueError("当前登录会话缺少可验证的会话标识,请重新登录。")
return session_id
@staticmethod
def _required_text(value: object, label: str, limit: int) -> str:
normalized = str(value or "").strip()[:limit]
if not normalized:
raise ValueError(f"{label}不能为空。")
return normalized
@staticmethod
def _normalize_action(action_type: str) -> str:
normalized = str(action_type or "").strip().lower()
if normalized not in {"save_draft", "submit"}:
raise ValueError("不支持的预览决策动作。")
return normalized
@staticmethod
def _as_utc(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Any
from app.api.deps import CurrentUserContext
def build_trusted_expense_application_context(
current_user: CurrentUserContext,
base_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
context_json = dict(base_context or {})
context_json.setdefault("session_type", "application")
context_json.setdefault("entry_source", "workbench_ai_inline")
context_json.setdefault("document_type", "expense_application")
context_json.setdefault("application_stage", "expense_application")
# 身份与权限字段必须覆盖请求体中的同名值。
context_json.update(
{
"tenant_id": current_user.tenant_id,
"role_codes": current_user.role_codes,
"is_admin": current_user.is_admin,
"username": current_user.username,
"name": current_user.name,
"department_name": current_user.department_name,
"position": current_user.position,
"grade": current_user.grade,
"employee_no": current_user.employee_no,
"manager_name": current_user.manager_name,
}
)
return context_json

View File

@@ -0,0 +1,195 @@
from __future__ import annotations
import hashlib
import hmac
import json
from typing import Any
from app.core.expense_application_fingerprint_keys import (
ACTIVE_FINGERPRINT_KEY_VERSION,
get_expense_application_fingerprint_key,
)
FINGERPRINT_KEY_VERSION = ACTIVE_FINGERPRINT_KEY_VERSION
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",
)
PREVIEW_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",
}
PREVIEW_RESPONSE_KEYS = {
"application_type": "applicationType",
"time": "time",
"location": "location",
"reason": "reason",
"days": "days",
"transport_mode": "transportMode",
"amount": "amount",
"grade": "grade",
"department": "department",
"position": "position",
"manager_name": "managerName",
"lodging_daily_cap": "lodgingDailyCap",
"subsidy_daily_cap": "subsidyDailyCap",
"transport_policy": "transportPolicy",
"policy_estimate": "policyEstimate",
"matched_city": "matchedCity",
"rule_name": "ruleName",
"rule_version": "ruleVersion",
"hotel_amount": "hotelAmount",
"allowance_amount": "allowanceAmount",
"transport_estimated_amount": "transportEstimatedAmount",
"transport_estimate_source": "transportEstimateSource",
"transport_estimate_confidence": "transportEstimateConfidence",
"policy_total_amount": "policyTotalAmount",
}
def safe_text(value: object, limit: int = 500) -> str:
return str(value or "").strip()[:limit]
def safe_fact_snapshot(facts: dict[str, Any]) -> dict[str, str]:
return {key: value for key in FACT_KEYS if (value := safe_text(facts.get(key)))}
def canonical_preview_fields(preview: dict[str, Any]) -> dict[str, str]:
fields = preview.get("fields") if isinstance(preview, dict) else None
if not isinstance(fields, dict):
return {}
values: dict[str, str] = {}
for alias, field_key in PREVIEW_FIELD_ALIASES.items():
value = safe_text(fields.get(alias))
if value:
values[field_key] = value
return values
def preview_response_fields(facts: dict[str, Any]) -> dict[str, str]:
snapshot = safe_fact_snapshot(facts)
return {
response_key: snapshot[field_key]
for field_key, response_key in PREVIEW_RESPONSE_KEYS.items()
if field_key in snapshot
}
def hmac_fingerprint(
payload: object,
*,
key_version: str = FINGERPRINT_KEY_VERSION,
allow_key_creation: bool = True,
) -> str:
serialized = _serialize(payload)
secret_key = get_expense_application_fingerprint_key(
key_version,
create=allow_key_creation,
)
digest = hmac.new(
secret_key,
b"expense-application-preview:v1:" + serialized,
hashlib.sha256,
).hexdigest()
return f"hmac-sha256:{digest}"
def content_fingerprint(payload: object) -> str:
return f"sha256:{hashlib.sha256(_serialize(payload)).hexdigest()}"
def field_value_fingerprint(
value: str,
*,
present: bool,
key_version: str = FINGERPRINT_KEY_VERSION,
allow_key_creation: bool = True,
) -> str:
return hmac_fingerprint(
{"present": present, "value": value if present else ""},
key_version=key_version,
allow_key_creation=allow_key_creation,
)
def field_fingerprints(
values: dict[str, str],
*,
key_version: str = FINGERPRINT_KEY_VERSION,
allow_key_creation: bool = True,
) -> dict[str, str]:
return {
key: field_value_fingerprint(
value,
present=True,
key_version=key_version,
allow_key_creation=allow_key_creation,
)
for key, value in sorted(values.items())
}
def snapshot_reference(
values: dict[str, str],
*,
key_version: str = FINGERPRINT_KEY_VERSION,
allow_key_creation: bool = True,
) -> dict[str, Any]:
return {
"field_keys": sorted(values),
"value_fingerprint": hmac_fingerprint(
values,
key_version=key_version,
allow_key_creation=allow_key_creation,
),
"fingerprint_key_version": key_version,
}
def _serialize(payload: object) -> bytes:
return json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")

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.ai_application_preview import AIApplicationPreviewDecision
from app.models.expense_case import BusinessEvent
from app.models.financial_record import ExpenseClaim
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
@@ -646,6 +647,8 @@ class UserAgentApplicationPersistenceMixin:
*,
action_type: str,
business_event: BusinessEvent,
preview_decision: AIApplicationPreviewDecision | None = None,
action_request_id: str = "",
) -> None:
if trusted_current_user is None:
return
@@ -656,6 +659,8 @@ class UserAgentApplicationPersistenceMixin:
trusted_current_user,
action_type=action_type,
business_event=business_event,
preview_decision=preview_decision,
action_request_id=action_request_id,
)
@staticmethod
@@ -727,6 +732,8 @@ class UserAgentApplicationPersistenceMixin:
*,
submit: bool,
learning_current_user: CurrentUserContext | None = None,
learning_preview_decision: AIApplicationPreviewDecision | None = None,
learning_action_request_id: str = "",
) -> ExpenseClaim:
current_user = self._build_application_current_user(payload)
previous_status = str(claim.status or "").strip()
@@ -771,6 +778,8 @@ class UserAgentApplicationPersistenceMixin:
learning_current_user,
action_type="save_draft",
business_event=draft_event,
preview_decision=learning_preview_decision,
action_request_id=learning_action_request_id,
)
self.db.commit()
self.db.refresh(claim)
@@ -794,6 +803,8 @@ class UserAgentApplicationPersistenceMixin:
learning_current_user,
action_type="submit",
business_event=event,
preview_decision=learning_preview_decision,
action_request_id=learning_action_request_id,
)
),
)
@@ -811,6 +822,8 @@ class UserAgentApplicationPersistenceMixin:
*,
submit: bool,
learning_current_user: CurrentUserContext | None = None,
learning_preview_decision: AIApplicationPreviewDecision | None = None,
learning_action_request_id: str = "",
) -> ExpenseClaim:
current_user = self._build_application_current_user(payload)
access_policy = ExpenseClaimAccessPolicy(self.db)
@@ -885,6 +898,8 @@ class UserAgentApplicationPersistenceMixin:
learning_current_user,
action_type="save_draft",
business_event=draft_event,
preview_decision=learning_preview_decision,
action_request_id=learning_action_request_id,
)
self.db.commit()
self.db.refresh(claim)
@@ -904,6 +919,8 @@ class UserAgentApplicationPersistenceMixin:
learning_current_user,
action_type="submit",
business_event=event,
preview_decision=learning_preview_decision,
action_request_id=learning_action_request_id,
)
),
)
@@ -1286,6 +1303,8 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
*,
risk_flags: list[str],
learning_current_user: CurrentUserContext | None = None,
learning_preview_decision: AIApplicationPreviewDecision | None = None,
learning_action_request_id: str = "",
) -> UserAgentResponse:
facts = self._resolve_expense_application_facts(payload)
step = self._resolve_expense_application_step(payload, facts)
@@ -1299,6 +1318,8 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
editable_claim,
submit=step == "submitted",
learning_current_user=learning_current_user,
learning_preview_decision=learning_preview_decision,
learning_action_request_id=learning_action_request_id,
)
facts["application_edit_mode"] = "true"
elif step == "submitted":
@@ -1312,6 +1333,8 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
facts,
submit=True,
learning_current_user=learning_current_user,
learning_preview_decision=learning_preview_decision,
learning_action_request_id=learning_action_request_id,
)
else:
application_claim = self._create_expense_application_record(
@@ -1319,6 +1342,8 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
facts,
submit=False,
learning_current_user=learning_current_user,
learning_preview_decision=learning_preview_decision,
learning_action_request_id=learning_action_request_id,
)
if application_claim is not None:
facts["application_no"] = application_claim.claim_no