feat(ai): issue verified application preview decisions
This commit is contained in:
@@ -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")
|
||||
230
server/src/app/api/v1/endpoints/expense_application_previews.py
Normal file
230
server/src/app/api/v1/endpoints/expense_application_previews.py
Normal 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
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -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],
|
||||
|
||||
@@ -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"])
|
||||
|
||||
56
server/src/app/core/expense_application_fingerprint_keys.py
Normal file
56
server/src/app/core/expense_application_fingerprint_keys.py
Normal 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())
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
105
server/src/app/models/ai_application_preview.py
Normal file
105
server/src/app/models/ai_application_preview.py
Normal 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(),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
19
server/src/app/schemas/expense_application_preview.py
Normal file
19
server/src/app/schemas/expense_application_preview.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
420
server/tests/test_expense_application_preview_decisions.py
Normal file
420
server/tests/test_expense_application_preview_decisions.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user