Files
X-Financial/server/tests/test_expense_claim_historical_evidence.py

358 lines
12 KiB
Python
Raw Normal View History

from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from unittest.mock import MagicMock
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.models.few_shot_sample import FewShotSample
from app.models.financial_record import ExpenseClaim
from app.schemas.ontology import OntologyParseResult
from app.schemas.user_agent import UserAgentRequest
from app.services.expense_claim_historical_evidence import (
ExpenseClaimHistoricalEvidenceService,
build_user_agent_historical_evidence_notice,
)
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
from app.services.expense_claim_pre_review_decision import pre_review_public_payload
from app.services.few_shot_retrieval import FewShotRetriever
from app.services.few_shot_store import FewShotStore
from app.services.user_agent_application import UserAgentApplicationMixin
def _session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
return sessionmaker(bind=engine)()
def _claim(*, claim_id: str = "claim-history") -> ExpenseClaim:
return ExpenseClaim(
id=claim_id,
claim_no=f"RE-{claim_id}",
employee_id="employee-history",
employee_name="张三",
department_id="department-history",
department_name="市场部",
project_code="PRJ-HISTORY",
expense_type="travel",
reason="客户现场差旅",
location="上海",
amount=Decimal("888.00"),
currency="CNY",
invoice_count=1,
occurred_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
status="draft",
approval_stage="待提交",
risk_flags_json=[],
)
def _finding() -> dict:
return {
"risk_id": "risk-history",
"rule_code": "TRAVEL-001",
"rule_version": "v2",
"severity": "high",
"disposition": "fix",
"resolution_status": "unresolved",
"actionability": "fixable_by_submitter",
"source": "submission_review",
"business_stage": "reimbursement",
"risk_domain": "policy",
"visibility_scope": "employee",
"item_ids": [],
"message": "住宿金额超过差旅标准。",
"remediation": {
"action": "补充说明",
"target_item_ids": [],
"required_fields": [],
"alternative_action": None,
},
}
def test_runtime_retrieval_explicitly_passes_tenant_scene_and_rule_identity(
monkeypatch,
) -> None:
with _session() as db:
retriever = MagicMock()
retriever.retrieve_for_expense_case.return_value = [
{
"sample_id": "sample-confirmed",
"label": "confirmed",
"score": 0.96,
"scene": "expense_reimbursement",
"policy_ref": "TRAVEL-001",
"rule_version": "v2",
"stale": False,
"conclusion": "该类超标案例经复核确认成立。",
}
]
monkeypatch.setattr(
ExpenseClaimHistoricalEvidenceService,
"_build_retriever",
lambda _service: retriever,
)
evidence = ExpenseClaimHistoricalEvidenceService(db).retrieve(
_claim(),
tenant_id="tenant-a",
business_stage="reimbursement",
findings=[_finding()],
)
retriever.retrieve_for_expense_case.assert_called_once_with(
tenant_id="tenant-a",
scene="expense_reimbursement",
policy_ref="TRAVEL-001",
rule_version="v2",
query="travel\n客户现场差旅\n上海\n住宿金额超过差旅标准。",
top_k=3,
)
assert evidence[0]["label_text"] == "历史已确认,仅供复核"
assert evidence[0]["advisory_only"] is True
assert evidence[0]["summary"] == "历史相似案例经人工复核确认风险成立。"
assert "sample_id" not in evidence[0]
assert "conclusion" not in evidence[0]
assert "该类超标案例经复核确认成立" not in repr(evidence[0])
def test_runtime_historical_evidence_db_recheck_never_exposes_other_tenant(
monkeypatch,
) -> None:
with _session() as db:
db.add_all(
[
_sample(
sample_id="sample-a",
tenant_id="tenant-a",
label="confirmed",
),
_sample(
sample_id="sample-b",
tenant_id="tenant-b",
label="false_positive",
),
]
)
db.commit()
store = MagicMock(spec=FewShotStore)
hits = [
{"sample_id": "sample-b", "score": 0.99},
{"sample_id": "sample-a", "score": 0.91},
]
store.search.side_effect = [hits, hits]
monkeypatch.setattr(
ExpenseClaimHistoricalEvidenceService,
"_build_retriever",
lambda _service: FewShotRetriever(store, db),
)
evidence = ExpenseClaimHistoricalEvidenceService(db).retrieve(
_claim(),
tenant_id="tenant-a",
business_stage="reimbursement",
findings=[_finding()],
)
assert len(evidence) == 1
assert evidence[0]["label"] == "confirmed"
assert "sample_id" not in evidence[0]
assert "conclusion" not in evidence[0]
assert "tenant-a 的历史结论" not in repr(evidence)
assert "tenant-b 的历史结论" not in repr(evidence)
assert all(
call.kwargs["tenant_id"] == "tenant-a"
for call in store.search.call_args_list
)
def test_retrieval_failure_returns_empty_and_hard_decision_is_unchanged(
monkeypatch,
) -> None:
with _session() as db:
monkeypatch.setattr(
ExpenseClaimHistoricalEvidenceService,
"_build_retriever",
MagicMock(side_effect=RuntimeError("qdrant unavailable")),
)
service = _PreReviewHarness(db)
with_evidence = service._refresh_claim_pre_review_flags(
_claim(claim_id="claim-with-evidence"),
is_application_claim=False,
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
tenant_id="tenant-a",
)
without_evidence = service._refresh_claim_pre_review_flags(
_claim(claim_id="claim-with-evidence"),
is_application_claim=False,
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
tenant_id="",
)
assert with_evidence is not None
assert without_evidence is not None
for key in (
"review_id",
"input_fingerprint",
"rule_set_fingerprint",
"review_context_fingerprint",
"decision",
"passed",
"blocking_count",
"blocking_risk_count",
"findings",
):
assert with_evidence[key] == without_evidence[key]
assert with_evidence["historical_case_evidence"] == []
assert without_evidence["historical_case_evidence"] == []
def test_evidence_changes_only_advisory_payload_not_rule_decision(monkeypatch) -> None:
with _session() as db:
retriever = MagicMock()
retriever.retrieve_for_expense_case.return_value = [
{
"sample_id": "sample-false-positive",
"label": "false_positive",
"score": 0.87,
"scene": "expense_reimbursement",
"policy_ref": "TRAVEL-001",
"rule_version": "v1",
"stale": True,
"conclusion": "历史相似案例经人工复核为误报。",
}
]
monkeypatch.setattr(
ExpenseClaimHistoricalEvidenceService,
"_build_retriever",
lambda _service: retriever,
)
service = _PreReviewHarness(db)
with_evidence = service._refresh_claim_pre_review_flags(
_claim(claim_id="same-claim"),
is_application_claim=False,
reviewed_at=datetime(2026, 7, 16, 9, 0, tzinfo=UTC),
tenant_id="tenant-a",
)
without_evidence = service._refresh_claim_pre_review_flags(
_claim(claim_id="same-claim"),
is_application_claim=False,
reviewed_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
tenant_id="",
)
assert with_evidence is not None
assert without_evidence is not None
for key in (
"review_id",
"decision",
"passed",
"blocking_count",
"blocking_risk_count",
"findings",
):
assert with_evidence[key] == without_evidence[key]
assert with_evidence["historical_case_evidence"][0]["label_text"] == (
"历史误报,仅供复核"
)
public_payload = pre_review_public_payload(with_evidence)
assert public_payload is not None
public_evidence = public_payload["historical_case_evidence"][0]
assert public_evidence["advisory_only"] is True
assert public_evidence["version_status"] == "stale"
assert public_evidence["summary"] == "历史相似案例经人工复核判定为误报。"
assert "sample_id" not in public_evidence
assert "conclusion" not in public_evidence
assert "历史相似案例经人工复核为误报" not in repr(public_evidence)
assert "claim_no" not in repr(public_evidence)
def test_user_agent_displays_only_fixed_historical_labels() -> None:
claim = _claim()
claim.risk_flags_json = [
{
"source": "ai_pre_review",
"historical_case_evidence": [
{
"label": "confirmed",
"sample_id": "sensitive-sample-id",
"conclusion": "客户甲与员工乙的人工复核原文",
"claim_no": "RE-SENSITIVE",
},
{"label": "false_positive"},
],
}
]
service = UserAgentApplicationMixin()
notice = build_user_agent_historical_evidence_notice(claim)
answer = service._build_expense_application_answer(
UserAgentRequest(
run_id="history-labels",
user_id="employee@example.com",
message="提交申请",
ontology=OntologyParseResult(run_id="history-labels"),
),
facts={
"application_no": "AP-20260716-001",
"manager_name": "直属领导",
"historical_case_evidence_notice": notice,
},
step="submitted",
)
assert "历史已确认,仅供复核" in answer
assert "历史误报,仅供复核" in answer
assert "sensitive-sample-id" not in answer
assert "客户甲与员工乙" not in answer
assert "RE-SENSITIVE" not in answer
def _sample(*, sample_id: str, tenant_id: str, label: str) -> FewShotSample:
return FewShotSample(
id=sample_id,
tenant_id=tenant_id,
sample_key=f"key-{sample_id}",
scene="expense_reimbursement",
policy_ref="TRAVEL-001",
rule_version="v2",
domain="expense",
risk_type="travel_limit",
risk_level="high",
label=label,
case_text="住宿金额超过差旅标准",
conclusion_text=f"{tenant_id} 的历史结论",
payload_json={},
status="active",
)
class _PreReviewHarness(ExpenseClaimPreReviewMixin):
def __init__(self, db: Session) -> None:
self.db = db
@staticmethod
def _run_ai_submission_review(_claim: ExpenseClaim) -> dict:
return {
"risk_flags": [
{
"source": "submission_review",
"severity": "high",
"actionability": "fixable_by_submitter",
"rule_code": "TRAVEL-001",
"rule_version": "v2",
"message": "住宿金额超过差旅标准。",
}
],
"rule_set_fingerprint": "rules-v2",
}