feat(ai): issue verified application preview decisions
This commit is contained in:
@@ -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:
|
||||
|
||||
276
server/src/app/services/expense_application_preview_decisions.py
Normal file
276
server/src/app/services/expense_application_preview_decisions.py
Normal 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)
|
||||
@@ -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
|
||||
195
server/src/app/services/expense_application_snapshot.py
Normal file
195
server/src/app/services/expense_application_snapshot.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user