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

@@ -0,0 +1,138 @@
"""add server-issued expense application preview decisions
Revision ID: 20260714_0004
Revises: 20260714_0003
Create Date: 2026-07-14 15:30:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260714_0004"
down_revision: str | None = "20260714_0003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"ai_application_preview_decisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("actor_id", sa.String(length=120), nullable=False),
sa.Column("auth_session_id", sa.String(length=120), nullable=False),
sa.Column("conversation_id", sa.String(length=120), nullable=False),
sa.Column("decision_type", sa.String(length=80), nullable=False),
sa.Column("decision_source", sa.String(length=20), nullable=False),
sa.Column("field_keys_json", sa.JSON(), nullable=False),
sa.Column("field_fingerprints_json", sa.JSON(), nullable=False),
sa.Column("snapshot_fingerprint", sa.String(length=80), nullable=False),
sa.Column("fingerprint_key_version", sa.String(length=40), nullable=False),
sa.Column("source_version_json", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("issue_request_id", sa.String(length=120), nullable=False),
sa.Column("consumed_action", sa.String(length=30), nullable=True),
sa.Column("consumed_request_id", sa.String(length=120), nullable=True),
sa.Column("consumed_claim_id", sa.String(length=36), nullable=True),
sa.Column("consumed_business_event_id", sa.String(length=36), nullable=True),
sa.Column("consumed_final_fingerprint", sa.String(length=80), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.CheckConstraint(
"status IN ('issued', 'consumed', 'expired', 'revoked')",
name="ck_ai_application_preview_decisions_status",
),
sa.CheckConstraint(
"decision_source IN ('heuristic', 'rule', 'hybrid', 'server_draft')",
name="ck_ai_application_preview_decisions_source",
),
sa.CheckConstraint(
"consumed_action IS NULL OR consumed_action IN ('save_draft', 'submit')",
name="ck_ai_application_preview_decisions_consumed_action",
),
sa.CheckConstraint(
"status != 'consumed' OR ("
"consumed_action IS NOT NULL AND consumed_request_id IS NOT NULL AND "
"consumed_claim_id IS NOT NULL AND consumed_business_event_id IS NOT NULL AND "
"consumed_final_fingerprint IS NOT NULL AND consumed_at IS NOT NULL)",
name="ck_ai_application_preview_decisions_consumed_fields",
),
sa.CheckConstraint(
"expires_at > created_at",
name="ck_ai_application_preview_decisions_expiry",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"id",
name="uq_ai_application_preview_decisions_tenant_id",
),
sa.UniqueConstraint(
"tenant_id",
"actor_id",
"auth_session_id",
"issue_request_id",
name="uq_ai_application_preview_decisions_issue_request",
),
)
op.create_index(
"ix_ai_application_preview_decisions_actor_status_expiry",
"ai_application_preview_decisions",
["tenant_id", "actor_id", "status", "expires_at"],
)
op.create_index(
"ix_ai_application_preview_decisions_conversation",
"ai_application_preview_decisions",
["tenant_id", "conversation_id", "created_at"],
)
op.add_column(
"ai_decisions",
sa.Column("preview_decision_id", sa.String(length=36), nullable=True),
)
op.create_foreign_key(
"fk_ai_decisions_tenant_preview_decision",
"ai_decisions",
"ai_application_preview_decisions",
["tenant_id", "preview_decision_id"],
["tenant_id", "id"],
ondelete="RESTRICT",
)
op.create_unique_constraint(
"uq_ai_decisions_tenant_preview_decision",
"ai_decisions",
["tenant_id", "preview_decision_id"],
)
def downgrade() -> None:
op.drop_constraint(
"uq_ai_decisions_tenant_preview_decision",
"ai_decisions",
type_="unique",
)
op.drop_constraint(
"fk_ai_decisions_tenant_preview_decision",
"ai_decisions",
type_="foreignkey",
)
op.drop_column("ai_decisions", "preview_decision_id")
op.drop_index(
"ix_ai_application_preview_decisions_conversation",
table_name="ai_application_preview_decisions",
)
op.drop_index(
"ix_ai_application_preview_decisions_actor_status_expiry",
table_name="ai_application_preview_decisions",
)
op.drop_table("ai_application_preview_decisions")

View File

@@ -0,0 +1,230 @@
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.expense_application_preview import (
ExpenseApplicationPreviewDecisionCreate,
ExpenseApplicationPreviewDecisionRead,
)
from app.schemas.ontology import OntologyParseResult, OntologyPermission
from app.schemas.reimbursement import (
ExpenseApplicationPreviewActionPayload,
ExpenseApplicationPreviewActionResponse,
ExpenseApplicationPreviewActionResult,
)
from app.schemas.user_agent import UserAgentRequest
from app.services.expense_application_preview_decisions import (
ExpenseApplicationPreviewDecisionService,
PreviewDecisionConflictError,
)
from app.services.expense_application_request_context import (
build_trusted_expense_application_context,
)
from app.services.user_agent import UserAgentService
router = APIRouter(prefix="/reimbursements")
logger = logging.getLogger(__name__)
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.post(
"/application-previews",
response_model=ExpenseApplicationPreviewDecisionRead,
status_code=status.HTTP_201_CREATED,
summary="签发费用申请预览决策",
description=(
"服务端重新解析申请原文、重算规则字段并签发短期 decision_id"
"不接受客户端来源声明作为可信 AI 证据。"
),
)
def issue_expense_application_preview_decision(
payload: ExpenseApplicationPreviewDecisionCreate,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseApplicationPreviewDecisionRead:
run_id = f"application-preview-decision:{payload.request_id}"
context_json = build_trusted_expense_application_context(current_user)
request = UserAgentRequest(
run_id=run_id,
user_id=current_user.username or current_user.name,
message=payload.message,
ontology=OntologyParseResult(run_id=run_id),
context_json=context_json,
tool_payload={},
selected_capability_codes=[],
degraded=False,
requires_confirmation=False,
)
try:
facts = UserAgentService(db)._resolve_expense_application_facts(request)
issued = ExpenseApplicationPreviewDecisionService(db).issue(
facts,
current_user,
conversation_id=payload.conversation_id or "",
request_id=payload.request_id,
)
db.commit()
db.refresh(issued.decision)
except PreviewDecisionConflictError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
return ExpenseApplicationPreviewDecisionRead(
decision_id=issued.decision.id,
decision_source=issued.decision.decision_source,
expires_at=issued.decision.expires_at,
application_preview={
"fields": issued.fields,
"decisionId": issued.decision.id,
"decisionSource": issued.decision.decision_source,
"decisionExpiresAt": issued.decision.expires_at.isoformat(),
},
)
@router.post(
"/application-preview-action",
response_model=ExpenseApplicationPreviewActionResponse,
summary="按申请核对预览快速保存或提交申请单",
description=(
"用于 AI 工作台已完成表格核对后的轻量建单/提交流程,避免重复进入通用 Orchestrator 编排。"
),
)
def run_application_preview_action(
payload: ExpenseApplicationPreviewActionPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseApplicationPreviewActionResponse:
context_json = build_trusted_expense_application_context(
current_user,
payload.context_json,
)
if payload.action_type == "save_draft":
context_json["application_action"] = "save_draft"
context_json["application_save_mode"] = True
elif payload.action_type == "submit":
context_json.pop("application_action", None)
context_json.pop("application_save_mode", None)
run_id = f"application-preview-action:{payload.conversation_id or current_user.username}"
request = UserAgentRequest(
run_id=run_id,
user_id=current_user.username or current_user.name,
message=payload.message,
ontology=OntologyParseResult(
scenario="expense",
intent="operate",
permission=OntologyPermission(
level="approval_required",
allowed=True,
reason="application preview fast action",
),
confidence=1.0,
run_id=run_id,
),
context_json=context_json,
tool_payload={},
selected_capability_codes=[],
degraded=False,
requires_confirmation=False,
)
try:
user_agent_service = UserAgentService(db)
facts = user_agent_service._resolve_expense_application_facts(request)
resolved_step = user_agent_service._resolve_expense_application_step(request, facts)
resolved_action = "save_draft" if resolved_step == "draft" else "submit"
if payload.action_type and payload.action_type != resolved_action:
raise ValueError("动作类型与申请内容不一致。")
preview_decision_service = ExpenseApplicationPreviewDecisionService(db)
preview_decision = None
if payload.decision_id:
preview_decision = preview_decision_service.require_for_action(
payload.decision_id,
current_user,
conversation_id=payload.conversation_id or "",
request_id=payload.request_id or "",
action_type=resolved_action,
final_values=facts,
)
else:
preview_decision_service.reject_active_decision_bypass(
current_user,
conversation_id=payload.conversation_id or "",
)
consumed_preview_decision_id = preview_decision.id if preview_decision is not None else ""
user_agent_response = user_agent_service._build_expense_application_response(
request,
risk_flags=[],
learning_current_user=current_user,
learning_preview_decision=preview_decision,
learning_action_request_id=payload.request_id or "",
)
except PreviewDecisionConflictError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
next_preview_decision = None
if (
preview_decision is not None
and resolved_action == "save_draft"
and user_agent_response.draft_payload is not None
):
try:
next_preview_decision = (
ExpenseApplicationPreviewDecisionService(db)
.issue(
facts,
current_user,
conversation_id=payload.conversation_id or "",
request_id=(f"next:{consumed_preview_decision_id}:{payload.request_id or ''}")[
:120
],
decision_source="server_draft",
)
.decision
)
db.commit()
db.refresh(next_preview_decision)
except Exception as error:
# 业务动作已经在 UserAgent 内提交;续签仅是派生能力,失败不能反转成功结果。
db.rollback()
next_preview_decision = None
logger.warning(
"费用申请草稿已保存但后续预览决策签发失败decision_id=%s error_type=%s",
consumed_preview_decision_id,
type(error).__name__,
)
return ExpenseApplicationPreviewActionResponse(
status="succeeded",
conversation_id=payload.conversation_id,
result=ExpenseApplicationPreviewActionResult(
message=user_agent_response.answer,
answer=user_agent_response.answer,
suggested_actions=[
action.model_dump(mode="json") for action in user_agent_response.suggested_actions
],
risk_flags=user_agent_response.risk_flags,
requires_confirmation=user_agent_response.requires_confirmation,
draft_payload=(
user_agent_response.draft_payload.model_dump(mode="json")
if user_agent_response.draft_payload is not None
else None
),
decision_id=(next_preview_decision.id if next_preview_decision is not None else None),
decision_expires_at=(
next_preview_decision.expires_at if next_preview_decision is not None else None
),
),
)

View File

@@ -10,11 +10,7 @@ from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
from app.schemas.budget import BudgetClaimAnalysisRead
from app.schemas.common import ErrorResponse, PaginatedResponse
from app.schemas.ontology import OntologyParseResult, OntologyPermission
from app.schemas.reimbursement import (
ExpenseApplicationPreviewActionPayload,
ExpenseApplicationPreviewActionResponse,
ExpenseApplicationPreviewActionResult,
ExpenseClaimActionResponse,
ExpenseClaimApprovalPayload,
ExpenseClaimAttachmentActionResponse,
@@ -31,13 +27,11 @@ from app.schemas.reimbursement import (
TravelReimbursementCalculatorRequest,
TravelReimbursementCalculatorResponse,
)
from app.schemas.user_agent import UserAgentRequest
from app.services.budget import BudgetService
from app.services.document_numbering import is_application_claim_no
from app.services.expense_claims import ExpenseClaimService
from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent import UserAgentService
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
@@ -95,100 +89,6 @@ def calculate_travel_reimbursement(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
def _build_application_preview_action_context(
payload: ExpenseApplicationPreviewActionPayload,
current_user: CurrentUserContext,
) -> dict[str, object]:
context_json = dict(payload.context_json 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
@router.post(
"/application-preview-action",
response_model=ExpenseApplicationPreviewActionResponse,
summary="按申请核对预览快速保存或提交申请单",
description=(
"用于 AI 工作台已完成表格核对后的轻量建单/提交流程,"
"避免重复进入通用 Orchestrator 编排。"
),
)
def run_application_preview_action(
payload: ExpenseApplicationPreviewActionPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseApplicationPreviewActionResponse:
context_json = _build_application_preview_action_context(payload, current_user)
run_id = f"application-preview-action:{payload.conversation_id or current_user.username}"
request = UserAgentRequest(
run_id=run_id,
user_id=current_user.username or current_user.name,
message=payload.message,
ontology=OntologyParseResult(
scenario="expense",
intent="operate",
permission=OntologyPermission(
level="approval_required",
allowed=True,
reason="application preview fast action",
),
confidence=1.0,
run_id=run_id,
),
context_json=context_json,
tool_payload={},
selected_capability_codes=[],
degraded=False,
requires_confirmation=False,
)
try:
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
return ExpenseApplicationPreviewActionResponse(
status="succeeded",
conversation_id=payload.conversation_id,
result=ExpenseApplicationPreviewActionResult(
message=user_agent_response.answer,
answer=user_agent_response.answer,
suggested_actions=[
action.model_dump(mode="json")
for action in user_agent_response.suggested_actions
],
risk_flags=user_agent_response.risk_flags,
requires_confirmation=user_agent_response.requires_confirmation,
draft_payload=(
user_agent_response.draft_payload.model_dump(mode="json")
if user_agent_response.draft_payload is not None
else None
),
),
)
@router.get(
"/claims",
response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],

View File

@@ -6,17 +6,24 @@ from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
from app.api.v1.endpoints.agent_traces import router as agent_traces_router
from app.api.v1.endpoints.analytics import router as analytics_router
from app.api.v1.endpoints.attachment_association_jobs import router as attachment_association_jobs_router
from app.api.v1.endpoints.attachment_association_jobs import (
router as attachment_association_jobs_router,
)
from app.api.v1.endpoints.audit_logs import router as audit_logs_router
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.bootstrap import router as bootstrap_router
from app.api.v1.endpoints.budgets import router as budgets_router
from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.expense_cases import router as expense_cases_router
from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router
from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.expense_application_previews import (
router as expense_application_previews_router,
)
from app.api.v1.endpoints.expense_cases import router as expense_cases_router
from app.api.v1.endpoints.health import router as health_router
from app.api.v1.endpoints.knowledge import router as knowledge_router
from app.api.v1.endpoints.linked_reimbursement_draft_jobs import router as linked_reimbursement_draft_jobs_router
from app.api.v1.endpoints.linked_reimbursement_draft_jobs import (
router as linked_reimbursement_draft_jobs_router,
)
from app.api.v1.endpoints.notification_states import router as notification_states_router
from app.api.v1.endpoints.ocr import router as ocr_router
from app.api.v1.endpoints.ontology import router as ontology_router
@@ -27,7 +34,7 @@ from app.api.v1.endpoints.risk_observations import router as risk_observations_r
from app.api.v1.endpoints.settings import router as settings_router
from app.api.v1.endpoints.steward import router as steward_router
from app.api.v1.endpoints.system_logs import router as system_logs_router
router = APIRouter()
router.include_router(health_router, tags=["health"])
router.include_router(bootstrap_router, tags=["bootstrap"])
@@ -50,6 +57,7 @@ router.include_router(orchestrator_router, tags=["orchestrator"])
router.include_router(receipt_folder_router, tags=["receipt-folder"])
router.include_router(employees_router, prefix="/employees", tags=["employees"])
router.include_router(expense_cases_router, tags=["expense-cases"])
router.include_router(expense_application_previews_router, tags=["reimbursements"])
router.include_router(employee_profiles_router, tags=["employee-profiles"])
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
router.include_router(risk_observations_router, tags=["risk-observations"])

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
import base64
import binascii
import os
import re
import secrets
from pathlib import Path
from app.core.config import SERVER_DIR
FINGERPRINT_KEY_DIRECTORY = SERVER_DIR / ".secrets" / "expense-application-fingerprints"
ACTIVE_FINGERPRINT_KEY_VERSION = "v1"
KEY_BYTES = 32
_VERSION_PATTERN = re.compile(r"^[a-zA-Z0-9._-]{1,32}$")
def get_expense_application_fingerprint_key(
version: str,
*,
create: bool,
) -> bytes:
normalized_version = str(version or "").strip()
if not _VERSION_PATTERN.fullmatch(normalized_version):
raise ValueError("费用申请指纹密钥版本无效。")
key_path = FINGERPRINT_KEY_DIRECTORY / f"{normalized_version}.key"
if not key_path.exists():
if not create:
raise ValueError("费用申请指纹密钥版本不可用,不能核验旧预览。")
_create_key_atomically(key_path)
if key_path.is_symlink() or not key_path.is_file():
raise ValueError("费用申请指纹密钥文件无效。")
os.chmod(key_path, 0o600)
encoded = key_path.read_text(encoding="utf-8").strip()
try:
key = base64.urlsafe_b64decode(encoded.encode("ascii"))
except (binascii.Error, ValueError, UnicodeError) as error:
raise ValueError("费用申请指纹密钥内容无效。") from error
if len(key) != KEY_BYTES:
raise ValueError("费用申请指纹密钥长度无效。")
return key
def _create_key_atomically(key_path: Path) -> None:
key_path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(key_path.parent, 0o700)
encoded = base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES))
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
try:
descriptor = os.open(key_path, flags, 0o600)
except FileExistsError:
return
with os.fdopen(descriptor, "wb") as stream:
stream.write(encoded)
stream.flush()
os.fsync(stream.fileno())

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_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog
@@ -53,6 +54,7 @@ __all__ = [
"AgentRun",
"AgentToolCall",
"AgentTraceEvent",
"AIApplicationPreviewDecision",
"AIDecision",
"AIDecisionFeedback",
"ApprovalRecord",

View File

@@ -38,8 +38,20 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
"workflow_outcomes",
}
),
"20260714_0004": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
"ai_application_preview_decisions",
"ai_decisions",
"ai_decision_feedback",
"workflow_outcomes",
}
),
}
if MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"] != MIGRATION_OWNED_TABLES:
if MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set")

View File

@@ -7,6 +7,7 @@ from app.db.base import Base
MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
{
"auth_sessions",
"ai_application_preview_decisions",
"ai_decisions",
"ai_decision_feedback",
"expense_cases",

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_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog
@@ -49,6 +50,7 @@ __all__ = [
"AgentRun",
"AgentToolCall",
"AgentTraceEvent",
"AIApplicationPreviewDecision",
"ApprovalRecord",
"AuditLog",
"AuthSession",

View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import CheckConstraint, DateTime, Index, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.db.base_class import Base
class AIApplicationPreviewDecision(Base):
"""费用申请落单前,由服务端签发并短期持有的不可变建议快照。"""
__tablename__ = "ai_application_preview_decisions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_ai_application_preview_decisions_tenant_id",
),
UniqueConstraint(
"tenant_id",
"actor_id",
"auth_session_id",
"issue_request_id",
name="uq_ai_application_preview_decisions_issue_request",
),
CheckConstraint(
"status IN ('issued', 'consumed', 'expired', 'revoked')",
name="ck_ai_application_preview_decisions_status",
),
CheckConstraint(
"decision_source IN ('heuristic', 'rule', 'hybrid', 'server_draft')",
name="ck_ai_application_preview_decisions_source",
),
CheckConstraint(
"consumed_action IS NULL OR consumed_action IN ('save_draft', 'submit')",
name="ck_ai_application_preview_decisions_consumed_action",
),
CheckConstraint(
"status != 'consumed' OR ("
"consumed_action IS NOT NULL AND consumed_request_id IS NOT NULL AND "
"consumed_claim_id IS NOT NULL AND consumed_business_event_id IS NOT NULL AND "
"consumed_final_fingerprint IS NOT NULL AND consumed_at IS NOT NULL)",
name="ck_ai_application_preview_decisions_consumed_fields",
),
CheckConstraint(
"expires_at > created_at",
name="ck_ai_application_preview_decisions_expiry",
),
Index(
"ix_ai_application_preview_decisions_actor_status_expiry",
"tenant_id",
"actor_id",
"status",
"expires_at",
),
Index(
"ix_ai_application_preview_decisions_conversation",
"tenant_id",
"conversation_id",
"created_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
auth_session_id: Mapped[str] = mapped_column(String(120), nullable=False)
conversation_id: Mapped[str] = mapped_column(String(120), nullable=False, default="")
decision_type: Mapped[str] = mapped_column(String(80), nullable=False)
decision_source: Mapped[str] = mapped_column(String(20), nullable=False)
field_keys_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
field_fingerprints_json: Mapped[dict[str, str]] = mapped_column(
JSON,
nullable=False,
default=dict,
)
snapshot_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
fingerprint_key_version: Mapped[str] = mapped_column(String(40), nullable=False)
source_version_json: Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=False,
default=dict,
)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="issued")
issue_request_id: Mapped[str] = mapped_column(String(120), nullable=False)
consumed_action: Mapped[str | None] = mapped_column(String(30), nullable=True)
consumed_request_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
consumed_claim_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
consumed_business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
consumed_final_fingerprint: Mapped[str | None] = mapped_column(String(80), nullable=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)

View File

@@ -44,6 +44,20 @@ class AIDecision(Base):
"idempotency_key",
name="uq_ai_decisions_tenant_idempotency",
),
UniqueConstraint(
"tenant_id",
"preview_decision_id",
name="uq_ai_decisions_tenant_preview_decision",
),
ForeignKeyConstraint(
["tenant_id", "preview_decision_id"],
[
"ai_application_preview_decisions.tenant_id",
"ai_application_preview_decisions.id",
],
ondelete="RESTRICT",
name="fk_ai_decisions_tenant_preview_decision",
),
ForeignKeyConstraint(
["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"],
@@ -90,6 +104,7 @@ class AIDecision(Base):
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)
preview_decision_id: Mapped[str | None] = mapped_column(String(36), nullable=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)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class ExpenseApplicationPreviewDecisionCreate(BaseModel):
message: str = Field(min_length=1, max_length=4000)
conversation_id: str | None = Field(default=None, max_length=120)
request_id: str = Field(min_length=1, max_length=120)
class ExpenseApplicationPreviewDecisionRead(BaseModel):
decision_id: str
decision_source: str
expires_at: datetime
application_preview: dict[str, Any] = Field(default_factory=dict)

View File

@@ -191,6 +191,9 @@ class ExpenseApplicationPreviewActionPayload(BaseModel):
source: str = Field(default="user_message", max_length=80)
user_id: str | None = Field(default=None, max_length=120)
conversation_id: str | None = Field(default=None, max_length=120)
action_type: str | None = Field(default=None, pattern="^(save_draft|submit)$")
decision_id: str | None = Field(default=None, max_length=36)
request_id: str | None = Field(default=None, max_length=120)
message: str = Field(min_length=1, max_length=4000)
context_json: dict[str, Any] = Field(default_factory=dict)
@@ -202,6 +205,8 @@ class ExpenseApplicationPreviewActionResult(BaseModel):
risk_flags: list[str] = Field(default_factory=list)
requires_confirmation: bool = False
draft_payload: dict[str, Any] | None = None
decision_id: str | None = None
decision_expires_at: datetime | None = None
class ExpenseApplicationPreviewActionResponse(BaseModel):

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

View File

@@ -24,6 +24,8 @@ def _read_test_user_headers(
employee_no: Annotated[str | None, Header(alias="X-Auth-Employee-No")] = None,
manager_name: Annotated[str | None, Header(alias="X-Auth-Manager-Name")] = None,
tenant_id: Annotated[str | None, Header(alias="X-Auth-Tenant-Id")] = None,
employee_id: Annotated[str | None, Header(alias="X-Auth-Employee-Id")] = None,
auth_session_id: Annotated[str | None, Header(alias="X-Auth-Session-Id")] = None,
) -> CurrentUserContext:
normalized_username = str(username or "").strip()
normalized_name = str(name or normalized_username).strip()
@@ -52,4 +54,6 @@ def _read_test_user_headers(
grade=str(grade or "").strip(),
employee_no=str(employee_no or "").strip(),
manager_name=str(manager_name or "").strip(),
employee_id=str(employee_id or "").strip(),
auth_session_id=str(auth_session_id or "").strip(),
)

View File

@@ -19,7 +19,7 @@ from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
HEAD_REVISION = "20260714_0003"
HEAD_REVISION = "20260714_0004"
SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
@@ -214,6 +214,18 @@ def _assert_head_schema(engine: Engine) -> None:
"uq_ai_decisions_tenant_idempotency",
("tenant_id", "idempotency_key"),
)
_assert_unique_constraint(
engine,
"ai_decisions",
"uq_ai_decisions_tenant_preview_decision",
("tenant_id", "preview_decision_id"),
)
_assert_unique_constraint(
engine,
"ai_application_preview_decisions",
"uq_ai_application_preview_decisions_issue_request",
("tenant_id", "actor_id", "auth_session_id", "issue_request_id"),
)
_assert_unique_constraint(
engine,
"ai_decision_feedback",
@@ -265,6 +277,23 @@ def _assert_head_schema(engine: Engine) -> None:
"ix_auth_sessions_tenant_username": ("tenant_id", "username"),
},
)
_assert_indexes(
engine,
"ai_application_preview_decisions",
{
"ix_ai_application_preview_decisions_actor_status_expiry": (
"tenant_id",
"actor_id",
"status",
"expires_at",
),
"ix_ai_application_preview_decisions_conversation": (
"tenant_id",
"conversation_id",
"created_at",
),
},
)
_assert_indexes(
engine,
"ai_decisions",
@@ -305,6 +334,12 @@ def _assert_head_schema(engine: Engine) -> None:
)
_assert_cascade_foreign_key(engine, "expense_case_links")
_assert_cascade_foreign_key(engine, "business_events")
_assert_composite_foreign_key(
engine,
"ai_decisions",
("tenant_id", "preview_decision_id"),
"ai_application_preview_decisions",
)
_assert_composite_foreign_key(
engine,
"ai_decisions",

View File

@@ -0,0 +1,420 @@
from __future__ import annotations
import stat
from collections.abc import Generator
from pathlib import Path
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.core import expense_application_fingerprint_keys as fingerprint_keys
from app.db.base import Base
from app.main import create_app
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.role import Role
from app.services.expense_application_preview_decisions import (
ExpenseApplicationPreviewDecisionService,
)
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base.metadata.create_all(bind=engine)
app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def seed_employee(db: Session) -> None:
role = Role(id="role-preview-user", role_code="user", name="员工")
employee = Employee(
id="employee-preview-owner",
employee_no="E-PREVIEW-001",
name="张三",
email="preview-owner@example.com",
position="实施顾问",
grade="P4",
roles=[role],
)
db.add_all([role, employee])
db.commit()
def auth_headers(*, session_id: str = "session-preview-owner") -> dict[str, str]:
return {
"X-Auth-Username": "preview-owner@example.com",
"X-Auth-Name": "Zhang San",
"X-Auth-Employee-No": "E-PREVIEW-001",
"X-Auth-Employee-Id": "employee-preview-owner",
"X-Auth-Session-Id": session_id,
"X-Auth-Tenant-Id": "tenant-preview",
"X-Auth-Grade": "P4",
"X-Auth-Role-Codes": "user",
}
def issue_preview(
client: TestClient,
*,
request_id: str = "issue-preview-1",
include_transport: bool = True,
) -> dict:
transport_line = "出行方式:火车\n" if include_transport else ""
response = client.post(
"/api/v1/reimbursements/application-previews",
headers=auth_headers(),
json={
"message": (
"申请时间2026-07-20 至 2026-07-22\n"
"地点:上海\n事由:客户现场实施\n天数3天\n"
f"{transport_line}申请金额1800元"
),
"conversation_id": "conversation-preview-1",
"request_id": request_id,
},
)
assert response.status_code == 201, response.text
return response.json()
def build_action_payload(issued: dict, *, reason: str = "客户现场实施") -> dict:
fields = {
**issued["application_preview"]["fields"],
"reason": reason,
}
return {
"source": "user_message",
"user_id": "forged-user@example.com",
"conversation_id": "conversation-preview-1",
"action_type": "save_draft",
"decision_id": issued["decision_id"],
"request_id": "action-preview-save-1",
"message": (
"费用申请保存草稿\n申请时间2026-07-20 至 2026-07-22\n"
f"地点:上海\n事由:{reason}\n申请金额1800元\n保存草稿"
),
"context_json": {
"application_action": "save_draft",
"application_save_mode": True,
"application_preview": {
"modelReviewStatus": "server_registered",
"fields": fields,
},
},
}
def build_legacy_action_payload() -> dict:
return build_action_payload(
{
"decision_id": "legacy-placeholder",
"application_preview": {
"fields": {
"time": "2026-07-20 至 2026-07-22",
"location": "上海",
"reason": "客户现场实施",
"days": "3天",
"transportMode": "火车",
"amount": "1800元",
"grade": "P4",
}
},
}
)
def test_server_preview_decision_is_consumed_with_verified_feedback() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client)
action_payload = build_action_payload(issued)
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=action_payload,
)
assert response.status_code == 200, response.text
result = response.json()["result"]
assert result["draft_payload"]["claim_id"]
assert result["decision_id"]
assert result["decision_id"] != issued["decision_id"]
retry_response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=action_payload,
)
assert retry_response.status_code == 200, retry_response.text
assert (
retry_response.json()["result"]["draft_payload"]["claim_id"]
== result["draft_payload"]["claim_id"]
)
assert retry_response.json()["result"]["decision_id"] == result["decision_id"]
with session_factory() as db:
consumed = db.get(AIApplicationPreviewDecision, issued["decision_id"])
next_decision = db.get(AIApplicationPreviewDecision, result["decision_id"])
decision = db.scalar(select(AIDecision))
feedback = db.scalar(select(AIDecisionFeedback))
assert consumed is not None
assert consumed.status == "consumed"
assert consumed.consumed_action == "save_draft"
assert consumed.field_fingerprints_json
assert "客户现场实施" not in str(consumed.field_fingerprints_json)
assert next_decision is not None
assert next_decision.status == "issued"
assert next_decision.decision_source == "server_draft"
assert decision is not None
assert decision.preview_decision_id == consumed.id
assert decision.suggestion_json["value_fingerprint"].startswith("hmac-sha256:")
assert feedback is not None
assert feedback.verification_status == "server_verified"
assert feedback.feedback_type == "accepted"
assert feedback.training_eligible is False
assert len(list(db.scalars(select(AIDecision)).all())) == 1
def test_server_preview_decision_detects_server_side_field_edit() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client, request_id="issue-preview-edited")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=build_action_payload(issued, reason="客户现场验收"),
)
assert response.status_code == 200, response.text
with session_factory() as db:
feedback = db.scalar(select(AIDecisionFeedback))
assert feedback is not None
assert feedback.feedback_type == "edited"
assert feedback.verification_status == "server_verified"
assert feedback.changed_fields_json == [
{
"field_key": "reason",
"suggested_value_fingerprint": feedback.changed_fields_json[0][
"suggested_value_fingerprint"
],
"final_value_fingerprint": feedback.changed_fields_json[0][
"final_value_fingerprint"
],
}
]
assert "客户现场" not in str(feedback.changed_fields_json)
def test_server_preview_decision_detects_field_added_after_issuance() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(
client,
request_id="issue-preview-added-field",
include_transport=False,
)
payload = build_action_payload(issued)
payload["message"] = payload["message"].replace(
"申请金额1800元",
"出行方式:飞机\n申请金额1800元",
)
payload["context_json"]["application_preview"]["fields"]["transportMode"] = "飞机"
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=payload,
)
assert response.status_code == 200, response.text
with session_factory() as db:
feedback = db.scalar(select(AIDecisionFeedback))
assert feedback is not None
assert feedback.feedback_type == "edited"
assert {item["field_key"] for item in feedback.changed_fields_json} >= {"transport_mode"}
def test_server_preview_decision_rejects_cross_session_replay_without_writes() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client, request_id="issue-preview-cross-session")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(session_id="another-session"),
json=build_action_payload(issued),
)
assert response.status_code == 400
assert "不属于当前登录会话" in response.json()["detail"]
with session_factory() as db:
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert decision is not None
assert decision.status == "issued"
assert list(db.scalars(select(ExpenseClaim)).all()) == []
assert list(db.scalars(select(AIDecision)).all()) == []
def test_active_server_preview_cannot_downgrade_by_omitting_decision_id() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client, request_id="issue-preview-downgrade")
payload = build_action_payload(issued)
payload.pop("decision_id")
payload.pop("request_id")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=payload,
)
assert response.status_code == 409, response.text
assert "必须携带 decision_id" in response.json()["detail"]
with session_factory() as db:
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert decision is not None
assert decision.status == "issued"
assert list(db.scalars(select(ExpenseClaim)).all()) == []
assert list(db.scalars(select(AIDecision)).all()) == []
def test_legacy_action_without_active_server_decision_keeps_client_observed_path() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
payload = build_legacy_action_payload()
payload.pop("decision_id")
payload.pop("request_id")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=payload,
)
assert response.status_code == 200, response.text
with session_factory() as db:
feedback = db.scalar(select(AIDecisionFeedback))
assert feedback is not None
assert feedback.verification_status == "client_observed"
assert feedback.training_eligible is False
def test_missing_fingerprint_key_rejects_action_without_business_writes() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client, request_id="issue-preview-missing-key")
with session_factory() as db:
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert decision is not None
decision.fingerprint_key_version = "missing-test-key"
db.commit()
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=build_action_payload(issued),
)
assert response.status_code == 400, response.text
assert "密钥版本不可用" in response.json()["detail"]
with session_factory() as db:
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert decision is not None
assert decision.status == "issued"
assert list(db.scalars(select(ExpenseClaim)).all()) == []
assert list(db.scalars(select(AIDecision)).all()) == []
def test_draft_save_stays_successful_when_next_decision_issuance_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
original_issue = ExpenseApplicationPreviewDecisionService.issue
def fail_server_draft_issue(
service: ExpenseApplicationPreviewDecisionService,
*args: object,
**kwargs: object,
) -> object:
if kwargs.get("decision_source") == "server_draft":
raise RuntimeError("simulated next decision failure")
return original_issue(service, *args, **kwargs)
monkeypatch.setattr(
ExpenseApplicationPreviewDecisionService,
"issue",
fail_server_draft_issue,
)
issued = issue_preview(client, request_id="issue-preview-next-failure")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=build_action_payload(issued),
)
assert response.status_code == 200, response.text
assert response.json()["result"]["draft_payload"]["claim_id"]
assert response.json()["result"]["decision_id"] is None
with session_factory() as db:
consumed = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert consumed is not None
assert consumed.status == "consumed"
assert len(list(db.scalars(select(ExpenseClaim)).all())) == 1
assert len(list(db.scalars(select(AIDecision)).all())) == 1
def test_fingerprint_key_is_created_with_private_permissions(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
key_directory = tmp_path / "fingerprint-keys"
monkeypatch.setattr(
fingerprint_keys,
"FINGERPRINT_KEY_DIRECTORY",
key_directory,
)
key = fingerprint_keys.get_expense_application_fingerprint_key(
"permission-test",
create=True,
)
key_path = key_directory / "permission-test.key"
assert len(key) == fingerprint_keys.KEY_BYTES
assert stat.S_IMODE(key_directory.stat().st_mode) == 0o700
assert stat.S_IMODE(key_path.stat().st_mode) == 0o600

View File

@@ -48,7 +48,7 @@ def test_unversioned_database_without_migration_owned_tables_is_safe(engine: Eng
@pytest.mark.parametrize(
"owned_table",
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"]),
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"]),
)
def test_unversioned_database_with_any_migration_owned_table_is_rejected(
engine: Engine,
@@ -91,6 +91,11 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
"20260714_0003",
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"] - {"ai_decisions"},
),
(
"20260714_0004",
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"]
- {"ai_application_preview_decisions"},
),
],
)
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(

View File

@@ -16,6 +16,7 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
assert MIGRATION_OWNED_TABLES == frozenset(
{
"ai_application_preview_decisions",
"ai_decision_feedback",
"ai_decisions",
"auth_sessions",

View File

@@ -441,14 +441,14 @@ def test_ai_application_draft_records_explicit_field_correction() -> None:
assert decision is not None
assert decision.training_eligible is False
assert decision.suggestion_json["field_keys"]
assert decision.suggestion_json["value_fingerprint"].startswith("sha256:")
assert decision.suggestion_json["value_fingerprint"].startswith("hmac-sha256:")
assert feedback is not None
assert feedback.feedback_type == "edited"
assert feedback.verification_status == "client_observed"
assert feedback.training_eligible is False
assert feedback.changed_fields_json[0]["field_key"] == "reason"
assert feedback.changed_fields_json[0]["suggested_value_fingerprint"].startswith(
"sha256:"
"hmac-sha256:"
)
assert "客户拜访" not in str(feedback.changed_fields_json)
assert "客户现场实施" not in str(feedback.final_value_json)