767 lines
28 KiB
Python
767 lines
28 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import uuid
|
|||
|
|
from datetime import UTC, datetime, timedelta
|
|||
|
|
from decimal import Decimal
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
from auth_helpers import install_legacy_header_auth_override
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
from sqlalchemy import select
|
|||
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|||
|
|
|
|||
|
|
from app.api.deps import CurrentUserContext, get_db
|
|||
|
|
from app.main import create_app
|
|||
|
|
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
|||
|
|
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
|
|||
|
|
from app.models.expense_case import BusinessEvent, ExpenseCase
|
|||
|
|
from app.models.financial_record import ExpenseClaim
|
|||
|
|
from app.schemas.expense_application_preview import ExpenseApplicationPreviewDecisionCreate
|
|||
|
|
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
|||
|
|
from app.schemas.orchestrator import OrchestratorRequest
|
|||
|
|
from app.schemas.reimbursement import ExpenseApplicationPreviewActionPayload
|
|||
|
|
from app.services.expense_application_memory import (
|
|||
|
|
MEMORY_CANDIDATE_TTL,
|
|||
|
|
ExpenseApplicationMemoryService,
|
|||
|
|
)
|
|||
|
|
from app.services.expense_application_preview_workflow import (
|
|||
|
|
ExpenseApplicationPreviewWorkflow,
|
|||
|
|
)
|
|||
|
|
from app.services.orchestrator_expense_application_workflow import (
|
|||
|
|
OrchestratorExpenseApplicationWorkflow,
|
|||
|
|
)
|
|||
|
|
from app.test_helpers.db import build_in_memory_session_factory
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _user(
|
|||
|
|
*,
|
|||
|
|
tenant_id: str = "tenant-memory",
|
|||
|
|
employee_id: str = "employee-memory-owner",
|
|||
|
|
) -> CurrentUserContext:
|
|||
|
|
return CurrentUserContext(
|
|||
|
|
username=f"{employee_id}@example.com",
|
|||
|
|
name="记忆测试员工",
|
|||
|
|
role_codes=["user"],
|
|||
|
|
is_admin=False,
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
employee_id=employee_id,
|
|||
|
|
employee_no="E-MEMORY-001",
|
|||
|
|
department_name="交付部",
|
|||
|
|
position="实施顾问",
|
|||
|
|
grade="P4",
|
|||
|
|
auth_session_id=f"session-{employee_id}",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _seed_learning_evidence(
|
|||
|
|
db: Session,
|
|||
|
|
*,
|
|||
|
|
current_user: CurrentUserContext,
|
|||
|
|
index: int,
|
|||
|
|
transport_mode: str = "火车",
|
|||
|
|
evidence_at: datetime | None = None,
|
|||
|
|
approved: bool = False,
|
|||
|
|
expect_memory_entry: bool = True,
|
|||
|
|
) -> tuple[
|
|||
|
|
MemoryEntry | None,
|
|||
|
|
ExpenseCase,
|
|||
|
|
AIDecision,
|
|||
|
|
AIDecisionFeedback,
|
|||
|
|
WorkflowOutcome,
|
|||
|
|
]:
|
|||
|
|
tenant_id = current_user.tenant_id
|
|||
|
|
suffix = f"{tenant_id}-{current_user.employee_id}-{index}-{uuid.uuid4().hex[:6]}"
|
|||
|
|
occurred_at = evidence_at or datetime.now(UTC)
|
|||
|
|
claim = ExpenseClaim(
|
|||
|
|
id=str(uuid.uuid4()),
|
|||
|
|
claim_no=f"CLM-{suffix}"[:50],
|
|||
|
|
employee_name=current_user.name,
|
|||
|
|
department_name=current_user.department_name,
|
|||
|
|
expense_type="travel",
|
|||
|
|
reason="客户现场实施",
|
|||
|
|
location="上海",
|
|||
|
|
amount=Decimal("1800.00"),
|
|||
|
|
occurred_at=occurred_at,
|
|||
|
|
submitted_at=occurred_at,
|
|||
|
|
status="submitted",
|
|||
|
|
approval_stage="审批中",
|
|||
|
|
risk_flags_json=[],
|
|||
|
|
)
|
|||
|
|
expense_case = ExpenseCase(
|
|||
|
|
id=str(uuid.uuid4()),
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
case_no=f"CASE-{suffix}"[:80],
|
|||
|
|
scene_code="travel",
|
|||
|
|
title="差旅申请",
|
|||
|
|
owner_employee_id=current_user.employee_id,
|
|||
|
|
current_stage="application",
|
|||
|
|
status="active",
|
|||
|
|
)
|
|||
|
|
submitted_event = BusinessEvent(
|
|||
|
|
id=str(uuid.uuid4()),
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
expense_case_id=expense_case.id,
|
|||
|
|
aggregate_type="expense_claim",
|
|||
|
|
aggregate_id=claim.id,
|
|||
|
|
event_type="application_submitted",
|
|||
|
|
event_version=1,
|
|||
|
|
idempotency_key=f"submitted:{suffix}"[:120],
|
|||
|
|
correlation_id=f"corr:{suffix}"[:64],
|
|||
|
|
actor_id=current_user.employee_id,
|
|||
|
|
actor_type="user",
|
|||
|
|
payload_json={},
|
|||
|
|
delivery_status="pending",
|
|||
|
|
occurred_at=occurred_at,
|
|||
|
|
)
|
|||
|
|
db.add_all([claim, expense_case, submitted_event])
|
|||
|
|
db.flush()
|
|||
|
|
|
|||
|
|
decision = AIDecision(
|
|||
|
|
id=str(uuid.uuid4()),
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
expense_case_id=expense_case.id,
|
|||
|
|
business_event_id=submitted_event.id,
|
|||
|
|
expense_claim_id=claim.id,
|
|||
|
|
correlation_id=f"corr:{suffix}"[:64],
|
|||
|
|
subject_type="expense_claim",
|
|||
|
|
subject_id=claim.id,
|
|||
|
|
decision_type="expense_application_submit",
|
|||
|
|
decision_source="server_preview",
|
|||
|
|
status="edited",
|
|||
|
|
automation_mode="human_confirmed",
|
|||
|
|
confidence=Decimal("1.0000"),
|
|||
|
|
suggestion_json={},
|
|||
|
|
evidence_json={},
|
|||
|
|
version_json={},
|
|||
|
|
schema_version=1,
|
|||
|
|
training_eligible=True,
|
|||
|
|
idempotency_key=f"decision:{suffix}"[:120],
|
|||
|
|
content_fingerprint=f"sha256:{uuid.uuid4().hex}",
|
|||
|
|
)
|
|||
|
|
feedback = AIDecisionFeedback(
|
|||
|
|
id=str(uuid.uuid4()),
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
decision_id=decision.id,
|
|||
|
|
expense_claim_id=claim.id,
|
|||
|
|
correlation_id=f"corr:{suffix}"[:64],
|
|||
|
|
feedback_type="edited",
|
|||
|
|
action_type="submit",
|
|||
|
|
actor_id=current_user.employee_id,
|
|||
|
|
actor_type="user",
|
|||
|
|
evidence_source="server_preview_action",
|
|||
|
|
verification_status="server_verified",
|
|||
|
|
training_eligible=True,
|
|||
|
|
final_value_json={"transport_mode": transport_mode},
|
|||
|
|
changed_fields_json=[{"field_key": "transport_mode"}],
|
|||
|
|
idempotency_key=f"feedback:{suffix}"[:120],
|
|||
|
|
content_fingerprint=f"sha256:{uuid.uuid4().hex}",
|
|||
|
|
)
|
|||
|
|
outcome = WorkflowOutcome(
|
|||
|
|
id=str(uuid.uuid4()),
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
expense_case_id=expense_case.id,
|
|||
|
|
decision_id=decision.id,
|
|||
|
|
business_event_id=submitted_event.id,
|
|||
|
|
expense_claim_id=claim.id,
|
|||
|
|
correlation_id=f"corr:{suffix}"[:64],
|
|||
|
|
outcome_type="application_submitted",
|
|||
|
|
outcome_status="verified",
|
|||
|
|
actor_id=current_user.employee_id,
|
|||
|
|
actor_type="user",
|
|||
|
|
result_json={},
|
|||
|
|
idempotency_key=f"outcome:{suffix}"[:120],
|
|||
|
|
content_fingerprint=f"sha256:{uuid.uuid4().hex}",
|
|||
|
|
effective_at=occurred_at,
|
|||
|
|
)
|
|||
|
|
db.add_all([decision, feedback, outcome])
|
|||
|
|
if approved:
|
|||
|
|
db.add(
|
|||
|
|
BusinessEvent(
|
|||
|
|
id=str(uuid.uuid4()),
|
|||
|
|
tenant_id=tenant_id,
|
|||
|
|
expense_case_id=expense_case.id,
|
|||
|
|
aggregate_type="expense_claim",
|
|||
|
|
aggregate_id=claim.id,
|
|||
|
|
event_type="application_approved",
|
|||
|
|
event_version=1,
|
|||
|
|
idempotency_key=f"approved:{suffix}"[:120],
|
|||
|
|
correlation_id=f"approval:{suffix}"[:64],
|
|||
|
|
actor_id="approver",
|
|||
|
|
actor_type="user",
|
|||
|
|
payload_json={},
|
|||
|
|
delivery_status="pending",
|
|||
|
|
occurred_at=occurred_at + timedelta(hours=1),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
db.flush()
|
|||
|
|
|
|||
|
|
receipt = ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
|
|||
|
|
current_user=current_user,
|
|||
|
|
decision=decision,
|
|||
|
|
feedback=feedback,
|
|||
|
|
outcome=outcome,
|
|||
|
|
claim=claim,
|
|||
|
|
transport_mode=transport_mode,
|
|||
|
|
)
|
|||
|
|
if not expect_memory_entry:
|
|||
|
|
assert receipt is None
|
|||
|
|
return None, expense_case, decision, feedback, outcome
|
|||
|
|
assert receipt is not None
|
|||
|
|
evidence = db.get(MemoryEvidenceLink, receipt.evidence_id)
|
|||
|
|
assert evidence is not None
|
|||
|
|
evidence.created_at = occurred_at
|
|||
|
|
db.flush()
|
|||
|
|
entry = db.get(MemoryEntry, receipt.memory_id)
|
|||
|
|
assert entry is not None
|
|||
|
|
return entry, expense_case, decision, feedback, outcome
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _seed_active_memory(
|
|||
|
|
db: Session,
|
|||
|
|
*,
|
|||
|
|
current_user: CurrentUserContext,
|
|||
|
|
transport_mode: str = "火车",
|
|||
|
|
) -> tuple[MemoryEntry, list[ExpenseCase]]:
|
|||
|
|
now = datetime.now(UTC)
|
|||
|
|
cases: list[ExpenseCase] = []
|
|||
|
|
entry: MemoryEntry | None = None
|
|||
|
|
for index, (days_ago, approved) in enumerate(((10, True), (5, True), (0, False)), 1):
|
|||
|
|
entry, expense_case, *_ = _seed_learning_evidence(
|
|||
|
|
db,
|
|||
|
|
current_user=current_user,
|
|||
|
|
index=index,
|
|||
|
|
transport_mode=transport_mode,
|
|||
|
|
evidence_at=now - timedelta(days=days_ago),
|
|||
|
|
approved=approved,
|
|||
|
|
)
|
|||
|
|
cases.append(expense_case)
|
|||
|
|
facts: dict[str, object] = {}
|
|||
|
|
applications = ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
|||
|
|
facts,
|
|||
|
|
current_user,
|
|||
|
|
)
|
|||
|
|
assert entry is not None
|
|||
|
|
assert applications and facts["transport_mode"] == transport_mode
|
|||
|
|
db.flush()
|
|||
|
|
db.refresh(entry)
|
|||
|
|
assert entry.status == "active"
|
|||
|
|
return entry, cases
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _add_returned_event(
|
|||
|
|
db: Session,
|
|||
|
|
*,
|
|||
|
|
current_user: CurrentUserContext,
|
|||
|
|
expense_case: ExpenseCase,
|
|||
|
|
) -> None:
|
|||
|
|
approved_at = db.scalar(
|
|||
|
|
select(BusinessEvent.occurred_at).where(
|
|||
|
|
BusinessEvent.expense_case_id == expense_case.id,
|
|||
|
|
BusinessEvent.event_type == "application_approved",
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
assert approved_at is not None
|
|||
|
|
db.add(
|
|||
|
|
BusinessEvent(
|
|||
|
|
id=str(uuid.uuid4()),
|
|||
|
|
tenant_id=current_user.tenant_id,
|
|||
|
|
expense_case_id=expense_case.id,
|
|||
|
|
aggregate_type="expense_claim",
|
|||
|
|
aggregate_id="returned-claim",
|
|||
|
|
event_type="application_returned",
|
|||
|
|
event_version=1,
|
|||
|
|
idempotency_key=f"returned:{uuid.uuid4().hex}",
|
|||
|
|
correlation_id=f"return:{uuid.uuid4().hex}"[:64],
|
|||
|
|
actor_id="approver",
|
|||
|
|
actor_type="user",
|
|||
|
|
payload_json={},
|
|||
|
|
delivery_status="pending",
|
|||
|
|
# 同一时间戳也必须由退回事件保守覆盖批准,不能依赖随机 UUID 排序。
|
|||
|
|
occurred_at=approved_at,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
db.flush()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_memory_activates_after_three_cases_two_approvals_and_seven_days() -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
current_user = _user()
|
|||
|
|
entry, _ = _seed_active_memory(db, current_user=current_user)
|
|||
|
|
|
|||
|
|
assert entry.evidence_count == 3
|
|||
|
|
assert entry.approved_evidence_count == 2
|
|||
|
|
assert entry.active_expires_at is not None
|
|||
|
|
assert entry.activated_at is not None
|
|||
|
|
|
|||
|
|
explicit_facts = {"transport_mode": "汽车"}
|
|||
|
|
assert (
|
|||
|
|
ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
|||
|
|
explicit_facts,
|
|||
|
|
current_user,
|
|||
|
|
)
|
|||
|
|
== []
|
|||
|
|
)
|
|||
|
|
assert explicit_facts["transport_mode"] == "汽车"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_latest_returned_event_reverses_approval_and_suppresses_active_memory() -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
current_user = _user()
|
|||
|
|
entry, cases = _seed_active_memory(db, current_user=current_user)
|
|||
|
|
_add_returned_event(db, current_user=current_user, expense_case=cases[1])
|
|||
|
|
|
|||
|
|
facts: dict[str, object] = {}
|
|||
|
|
assert (
|
|||
|
|
ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
|||
|
|
facts,
|
|||
|
|
current_user,
|
|||
|
|
)
|
|||
|
|
== []
|
|||
|
|
)
|
|||
|
|
db.refresh(entry)
|
|||
|
|
assert entry.approved_evidence_count == 1
|
|||
|
|
assert entry.status == "suppressed"
|
|||
|
|
assert entry.suppressed_at is not None
|
|||
|
|
assert facts == {}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_invalidated_feedback_and_reversed_outcome_remove_active_evidence() -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
current_user = _user()
|
|||
|
|
entry, _ = _seed_active_memory(db, current_user=current_user)
|
|||
|
|
evidence = list(
|
|||
|
|
db.scalars(
|
|||
|
|
select(MemoryEvidenceLink)
|
|||
|
|
.where(MemoryEvidenceLink.memory_entry_id == entry.id)
|
|||
|
|
.order_by(MemoryEvidenceLink.created_at.asc())
|
|||
|
|
).all()
|
|||
|
|
)
|
|||
|
|
assert len(evidence) == 3
|
|||
|
|
invalidated_feedback = db.get(AIDecisionFeedback, evidence[0].feedback_id)
|
|||
|
|
reversed_outcome = db.get(WorkflowOutcome, evidence[1].outcome_id)
|
|||
|
|
assert invalidated_feedback is not None and reversed_outcome is not None
|
|||
|
|
invalidated_feedback.verification_status = "invalidated"
|
|||
|
|
invalidated_feedback.training_eligible = False
|
|||
|
|
reversed_outcome.outcome_status = "reversed"
|
|||
|
|
db.flush()
|
|||
|
|
|
|||
|
|
assert ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
|||
|
|
{}, current_user
|
|||
|
|
) == []
|
|||
|
|
db.refresh(entry)
|
|||
|
|
assert entry.status == "suppressed"
|
|||
|
|
assert entry.evidence_count == 1
|
|||
|
|
assert entry.approved_evidence_count == 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_opposite_evidence_immediately_suppresses_old_active_memory() -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
current_user = _user()
|
|||
|
|
old_entry, _ = _seed_active_memory(db, current_user=current_user)
|
|||
|
|
|
|||
|
|
new_entry, *_ = _seed_learning_evidence(
|
|||
|
|
db,
|
|||
|
|
current_user=current_user,
|
|||
|
|
index=4,
|
|||
|
|
transport_mode="飞机",
|
|||
|
|
)
|
|||
|
|
db.refresh(old_entry)
|
|||
|
|
assert old_entry.status == "suppressed"
|
|||
|
|
assert old_entry.suppressed_at is not None
|
|||
|
|
assert new_entry.status == "candidate"
|
|||
|
|
assert ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
|||
|
|
{}, current_user
|
|||
|
|
) == []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_non_whitelisted_correction_suppresses_old_active_without_storing_value() -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
current_user = _user()
|
|||
|
|
old_entry, _ = _seed_active_memory(db, current_user=current_user)
|
|||
|
|
entry_count = len(list(db.scalars(select(MemoryEntry)).all()))
|
|||
|
|
|
|||
|
|
new_entry, *_ = _seed_learning_evidence(
|
|||
|
|
db,
|
|||
|
|
current_user=current_user,
|
|||
|
|
index=4,
|
|||
|
|
transport_mode="汽车",
|
|||
|
|
expect_memory_entry=False,
|
|||
|
|
)
|
|||
|
|
assert new_entry is None
|
|||
|
|
db.refresh(old_entry)
|
|||
|
|
assert old_entry.status == "suppressed"
|
|||
|
|
assert old_entry.suppressed_at is not None
|
|||
|
|
assert len(list(db.scalars(select(MemoryEntry)).all())) == entry_count
|
|||
|
|
assert ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
|||
|
|
{}, current_user
|
|||
|
|
) == []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_expiry_sets_expired_at_and_replay_does_not_extend_ttl() -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
current_user = _user()
|
|||
|
|
entry, _, decision, feedback, outcome = _seed_learning_evidence(
|
|||
|
|
db,
|
|||
|
|
current_user=current_user,
|
|||
|
|
index=1,
|
|||
|
|
)
|
|||
|
|
original_expiry = datetime.now(UTC) + timedelta(days=30)
|
|||
|
|
entry.candidate_expires_at = original_expiry
|
|||
|
|
db.flush()
|
|||
|
|
claim = db.get(ExpenseClaim, decision.expense_claim_id)
|
|||
|
|
assert claim is not None
|
|||
|
|
|
|||
|
|
replay = ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
|
|||
|
|
current_user=current_user,
|
|||
|
|
decision=decision,
|
|||
|
|
feedback=feedback,
|
|||
|
|
outcome=outcome,
|
|||
|
|
claim=claim,
|
|||
|
|
transport_mode="火车",
|
|||
|
|
)
|
|||
|
|
db.refresh(entry)
|
|||
|
|
assert replay is not None
|
|||
|
|
assert entry.evidence_count == 1
|
|||
|
|
assert entry.candidate_expires_at == original_expiry.replace(tzinfo=None)
|
|||
|
|
|
|||
|
|
old_created_at = datetime.now(UTC) - MEMORY_CANDIDATE_TTL - timedelta(days=2)
|
|||
|
|
entry.created_at = old_created_at
|
|||
|
|
entry.candidate_expires_at = datetime.now(UTC) - timedelta(days=1)
|
|||
|
|
db.flush()
|
|||
|
|
memories = ExpenseApplicationMemoryService(db).list_current_user_memories(
|
|||
|
|
current_user
|
|||
|
|
)
|
|||
|
|
db.refresh(entry)
|
|||
|
|
assert memories.items[0].status == "expired"
|
|||
|
|
assert entry.status == "expired"
|
|||
|
|
assert entry.expired_at is not None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_expired_entry_gets_new_generation_for_new_evidence() -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
current_user = _user()
|
|||
|
|
old_entry, *_ = _seed_learning_evidence(
|
|||
|
|
db,
|
|||
|
|
current_user=current_user,
|
|||
|
|
index=1,
|
|||
|
|
)
|
|||
|
|
old_entry.created_at = datetime.now(UTC) - MEMORY_CANDIDATE_TTL - timedelta(days=2)
|
|||
|
|
old_entry.candidate_expires_at = datetime.now(UTC) - timedelta(days=1)
|
|||
|
|
db.flush()
|
|||
|
|
|
|||
|
|
new_entry, *_ = _seed_learning_evidence(
|
|||
|
|
db,
|
|||
|
|
current_user=current_user,
|
|||
|
|
index=2,
|
|||
|
|
)
|
|||
|
|
db.refresh(old_entry)
|
|||
|
|
assert new_entry.id != old_entry.id
|
|||
|
|
assert new_entry.generation == old_entry.generation + 1
|
|||
|
|
assert new_entry.status == "candidate"
|
|||
|
|
assert old_entry.status == "expired"
|
|||
|
|
assert old_entry.expired_at is not None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_replay_after_revoke_or_suppression_never_recreates_memory() -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
current_user = _user()
|
|||
|
|
revoked_entry, _, decision, feedback, outcome = _seed_learning_evidence(
|
|||
|
|
db,
|
|||
|
|
current_user=current_user,
|
|||
|
|
index=1,
|
|||
|
|
)
|
|||
|
|
claim = db.get(ExpenseClaim, decision.expense_claim_id)
|
|||
|
|
assert claim is not None
|
|||
|
|
assert ExpenseApplicationMemoryService(db).revoke_current_user_memory(
|
|||
|
|
revoked_entry.id,
|
|||
|
|
current_user,
|
|||
|
|
) is not None
|
|||
|
|
entry_count = len(list(db.scalars(select(MemoryEntry)).all()))
|
|||
|
|
|
|||
|
|
revoked_replay = ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
|
|||
|
|
current_user=current_user,
|
|||
|
|
decision=decision,
|
|||
|
|
feedback=feedback,
|
|||
|
|
outcome=outcome,
|
|||
|
|
claim=claim,
|
|||
|
|
transport_mode="火车",
|
|||
|
|
)
|
|||
|
|
assert revoked_replay is not None
|
|||
|
|
assert revoked_replay.status == "revoked"
|
|||
|
|
assert len(list(db.scalars(select(MemoryEntry)).all())) == entry_count
|
|||
|
|
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
current_user = _user()
|
|||
|
|
suppressed_entry, _ = _seed_active_memory(db, current_user=current_user)
|
|||
|
|
evidence = db.scalar(
|
|||
|
|
select(MemoryEvidenceLink)
|
|||
|
|
.where(MemoryEvidenceLink.memory_entry_id == suppressed_entry.id)
|
|||
|
|
.order_by(MemoryEvidenceLink.created_at.asc())
|
|||
|
|
)
|
|||
|
|
assert evidence is not None
|
|||
|
|
decision = db.get(AIDecision, evidence.decision_id)
|
|||
|
|
feedback = db.get(AIDecisionFeedback, evidence.feedback_id)
|
|||
|
|
outcome = db.get(WorkflowOutcome, evidence.outcome_id)
|
|||
|
|
assert decision is not None and feedback is not None and outcome is not None
|
|||
|
|
claim = db.get(ExpenseClaim, decision.expense_claim_id)
|
|||
|
|
assert claim is not None
|
|||
|
|
_seed_learning_evidence(
|
|||
|
|
db,
|
|||
|
|
current_user=current_user,
|
|||
|
|
index=4,
|
|||
|
|
transport_mode="飞机",
|
|||
|
|
)
|
|||
|
|
db.refresh(suppressed_entry)
|
|||
|
|
assert suppressed_entry.status == "suppressed"
|
|||
|
|
entry_count = len(list(db.scalars(select(MemoryEntry)).all()))
|
|||
|
|
|
|||
|
|
suppressed_replay = ExpenseApplicationMemoryService(
|
|||
|
|
db
|
|||
|
|
).record_transport_edit_evidence(
|
|||
|
|
current_user=current_user,
|
|||
|
|
decision=decision,
|
|||
|
|
feedback=feedback,
|
|||
|
|
outcome=outcome,
|
|||
|
|
claim=claim,
|
|||
|
|
transport_mode="火车",
|
|||
|
|
)
|
|||
|
|
assert suppressed_replay is not None
|
|||
|
|
assert suppressed_replay.status == "suppressed"
|
|||
|
|
assert len(list(db.scalars(select(MemoryEntry)).all())) == entry_count
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_memory_isolated_by_tenant_and_owner_and_revoke_clears_value() -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
owner = _user()
|
|||
|
|
entry, _ = _seed_active_memory(db, current_user=owner)
|
|||
|
|
service = ExpenseApplicationMemoryService(db)
|
|||
|
|
|
|||
|
|
assert service.list_current_user_memories(_user(employee_id="employee-other")).items == []
|
|||
|
|
assert service.list_current_user_memories(
|
|||
|
|
_user(tenant_id="tenant-other")
|
|||
|
|
).items == []
|
|||
|
|
assert service.revoke_current_user_memory(
|
|||
|
|
entry.id,
|
|||
|
|
_user(employee_id="employee-other"),
|
|||
|
|
) is None
|
|||
|
|
|
|||
|
|
revoked = service.revoke_current_user_memory(entry.id, owner)
|
|||
|
|
assert revoked is not None
|
|||
|
|
db.refresh(entry)
|
|||
|
|
assert entry.status == "revoked"
|
|||
|
|
assert entry.value_json == {}
|
|||
|
|
assert entry.value_fingerprint == ""
|
|||
|
|
assert service.apply_active_transport_memory({}, owner) == []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_memory_rejects_same_tenant_evidence_from_another_owner() -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
owner = _user()
|
|||
|
|
_, _, decision, feedback, outcome = _seed_learning_evidence(
|
|||
|
|
db,
|
|||
|
|
current_user=owner,
|
|||
|
|
index=1,
|
|||
|
|
)
|
|||
|
|
claim = db.get(ExpenseClaim, decision.expense_claim_id)
|
|||
|
|
assert claim is not None
|
|||
|
|
|
|||
|
|
other_owner = _user(employee_id="employee-other")
|
|||
|
|
with pytest.raises(PermissionError, match="当前登录人"):
|
|||
|
|
ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
|
|||
|
|
current_user=other_owner,
|
|||
|
|
decision=decision,
|
|||
|
|
feedback=feedback,
|
|||
|
|
outcome=outcome,
|
|||
|
|
claim=claim,
|
|||
|
|
transport_mode="火车",
|
|||
|
|
)
|
|||
|
|
assert (
|
|||
|
|
ExpenseApplicationMemoryService(db)
|
|||
|
|
.list_current_user_memories(other_owner)
|
|||
|
|
.items
|
|||
|
|
== []
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize(
|
|||
|
|
("field_name", "invalid_value"),
|
|||
|
|
[
|
|||
|
|
("event_type", "claim_draft_updated"),
|
|||
|
|
("aggregate_type", "expense_case"),
|
|||
|
|
("aggregate_id", "another-claim"),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
def test_memory_rejects_non_submit_or_wrong_claim_business_event(
|
|||
|
|
field_name: str,
|
|||
|
|
invalid_value: str,
|
|||
|
|
) -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
current_user = _user()
|
|||
|
|
_, _, decision, feedback, outcome = _seed_learning_evidence(
|
|||
|
|
db,
|
|||
|
|
current_user=current_user,
|
|||
|
|
index=1,
|
|||
|
|
)
|
|||
|
|
business_event = db.get(BusinessEvent, decision.business_event_id)
|
|||
|
|
claim = db.get(ExpenseClaim, decision.expense_claim_id)
|
|||
|
|
assert business_event is not None
|
|||
|
|
assert claim is not None
|
|||
|
|
setattr(business_event, field_name, invalid_value)
|
|||
|
|
db.flush()
|
|||
|
|
|
|||
|
|
with pytest.raises(ValueError, match="提交业务事件语义不一致"):
|
|||
|
|
ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
|
|||
|
|
current_user=current_user,
|
|||
|
|
decision=decision,
|
|||
|
|
feedback=feedback,
|
|||
|
|
outcome=outcome,
|
|||
|
|
claim=claim,
|
|||
|
|
transport_mode="火车",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_memory_read_failure_degrades_without_mutating_facts(
|
|||
|
|
monkeypatch: pytest.MonkeyPatch,
|
|||
|
|
) -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
service = ExpenseApplicationMemoryService(db)
|
|||
|
|
facts: dict[str, object] = {}
|
|||
|
|
|
|||
|
|
def fail_resolve(_current_user: CurrentUserContext) -> MemoryEntry | None:
|
|||
|
|
raise RuntimeError("memory unavailable")
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(service, "_resolve_active_entry", fail_resolve)
|
|||
|
|
assert service.apply_active_transport_memory(facts, _user()) == []
|
|||
|
|
assert facts == {}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_preview_and_orchestrator_apply_active_memory() -> None:
|
|||
|
|
with build_in_memory_session_factory()() as db:
|
|||
|
|
current_user = _user()
|
|||
|
|
_seed_active_memory(db, current_user=current_user)
|
|||
|
|
|
|||
|
|
preview = ExpenseApplicationPreviewWorkflow(db).issue(
|
|||
|
|
ExpenseApplicationPreviewDecisionCreate(
|
|||
|
|
message=(
|
|||
|
|
"申请时间:2026-07-20 至 2026-07-22\n"
|
|||
|
|
"地点:上海\n事由:客户现场实施\n天数:3天"
|
|||
|
|
),
|
|||
|
|
conversation_id="conversation-memory-preview",
|
|||
|
|
request_id="request-memory-preview",
|
|||
|
|
),
|
|||
|
|
current_user,
|
|||
|
|
)
|
|||
|
|
assert preview.application_preview["fields"]["transportMode"] == "火车"
|
|||
|
|
assert preview.application_preview["fields"]["amount"]
|
|||
|
|
assert preview.application_preview["fields"]["transportEstimatedAmount"]
|
|||
|
|
assert "交通" in preview.application_preview["fields"]["policyEstimate"]
|
|||
|
|
assert preview.application_preview["memoryApplications"][0]["status"] == "applied"
|
|||
|
|
|
|||
|
|
outcome = OrchestratorExpenseApplicationWorkflow(db).execute(
|
|||
|
|
payload=OrchestratorRequest(
|
|||
|
|
source="user_message",
|
|||
|
|
user_id=current_user.username,
|
|||
|
|
message=(
|
|||
|
|
"申请时间:2026-08-01 至 2026-08-03\n"
|
|||
|
|
"地点:北京\n事由:客户现场验收\n天数:3天\n申请金额:2000元"
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
current_user=current_user,
|
|||
|
|
run_id="run-memory-orchestrator",
|
|||
|
|
conversation_id="conversation-memory-orchestrator",
|
|||
|
|
ontology=OntologyParseResult(
|
|||
|
|
scenario="expense",
|
|||
|
|
intent="operate",
|
|||
|
|
permission=OntologyPermission(
|
|||
|
|
level="approval_required",
|
|||
|
|
allowed=True,
|
|||
|
|
reason="test",
|
|||
|
|
),
|
|||
|
|
confidence=1.0,
|
|||
|
|
run_id="run-memory-orchestrator",
|
|||
|
|
),
|
|||
|
|
context_json={},
|
|||
|
|
selected_capability_codes=[],
|
|||
|
|
)
|
|||
|
|
assert outcome is not None
|
|||
|
|
assert outcome.result["application_preview"]["fields"]["transportMode"] == "火车"
|
|||
|
|
assert outcome.result["application_preview"]["memoryApplications"][0][
|
|||
|
|
"status"
|
|||
|
|
] == "applied"
|
|||
|
|
assert "补充出行方式" not in str(outcome.result)
|
|||
|
|
|
|||
|
|
edited_fields = dict(preview.application_preview["fields"])
|
|||
|
|
edited_fields["transportMode"] = "飞机"
|
|||
|
|
action = ExpenseApplicationPreviewWorkflow(db).execute(
|
|||
|
|
ExpenseApplicationPreviewActionPayload(
|
|||
|
|
source="user_message",
|
|||
|
|
user_id=current_user.username,
|
|||
|
|
conversation_id="conversation-memory-preview",
|
|||
|
|
action_type="submit",
|
|||
|
|
decision_id=preview.decision_id,
|
|||
|
|
request_id="request-memory-submit",
|
|||
|
|
message=(
|
|||
|
|
"申请时间:2026-07-20 至 2026-07-22\n"
|
|||
|
|
"地点:上海\n事由:客户现场实施\n天数:3天\n"
|
|||
|
|
"出行方式:飞机\n申请金额:1800元\n确认提交"
|
|||
|
|
),
|
|||
|
|
context_json={
|
|||
|
|
"application_preview": {
|
|||
|
|
"modelReviewStatus": "server_registered",
|
|||
|
|
"fields": edited_fields,
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
current_user,
|
|||
|
|
)
|
|||
|
|
assert action.result.learning_receipts
|
|||
|
|
assert action.result.learning_receipts[0]["value"] == "飞机"
|
|||
|
|
assert action.result.learning_receipts[0]["status"] == "candidate"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_memory_api_enforces_owner_and_allows_owner_revoke() -> None:
|
|||
|
|
session_factory: sessionmaker[Session] = build_in_memory_session_factory()
|
|||
|
|
with session_factory() as db:
|
|||
|
|
owner = _user()
|
|||
|
|
entry, _ = _seed_active_memory(db, current_user=owner)
|
|||
|
|
memory_id = entry.id
|
|||
|
|
db.commit()
|
|||
|
|
|
|||
|
|
app = create_app()
|
|||
|
|
install_legacy_header_auth_override(app)
|
|||
|
|
|
|||
|
|
def override_db():
|
|||
|
|
with session_factory() as db:
|
|||
|
|
yield db
|
|||
|
|
|
|||
|
|
app.dependency_overrides[get_db] = override_db
|
|||
|
|
client = TestClient(app)
|
|||
|
|
owner_headers = {
|
|||
|
|
"X-Auth-Username": owner.username,
|
|||
|
|
"X-Auth-Name": "Memory Owner",
|
|||
|
|
"X-Auth-Employee-Id": owner.employee_id,
|
|||
|
|
"X-Auth-Tenant-Id": owner.tenant_id,
|
|||
|
|
"X-Auth-Role-Codes": "user",
|
|||
|
|
}
|
|||
|
|
other_headers = {**owner_headers, "X-Auth-Employee-Id": "employee-other"}
|
|||
|
|
|
|||
|
|
assert client.get(
|
|||
|
|
"/api/v1/expense-application-memories/me",
|
|||
|
|
headers=other_headers,
|
|||
|
|
).json() == {"items": []}
|
|||
|
|
assert client.delete(
|
|||
|
|
f"/api/v1/expense-application-memories/{memory_id}",
|
|||
|
|
headers=other_headers,
|
|||
|
|
).status_code == 404
|
|||
|
|
|
|||
|
|
response = client.delete(
|
|||
|
|
f"/api/v1/expense-application-memories/{memory_id}",
|
|||
|
|
headers=owner_headers,
|
|||
|
|
)
|
|||
|
|
assert response.status_code == 200, response.text
|
|||
|
|
with session_factory() as db:
|
|||
|
|
revoked_entry = db.scalar(select(MemoryEntry).where(MemoryEntry.id == memory_id))
|
|||
|
|
assert revoked_entry is not None
|
|||
|
|
assert revoked_entry.status == "revoked"
|
|||
|
|
assert revoked_entry.value_json == {}
|