358 lines
12 KiB
Python
358 lines
12 KiB
Python
|
|
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",
|
||
|
|
}
|