feat(ai): add tenant-safe hierarchical expense learning
This commit is contained in:
357
server/tests/test_expense_claim_historical_evidence.py
Normal file
357
server/tests/test_expense_claim_historical_evidence.py
Normal file
@@ -0,0 +1,357 @@
|
||||
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",
|
||||
}
|
||||
Reference in New Issue
Block a user