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", }