feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
@@ -14,6 +14,7 @@ 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.employee import Employee
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.expense_application_preview import ExpenseApplicationPreviewDecisionCreate
|
||||
@@ -345,9 +346,10 @@ def test_invalidated_feedback_and_reversed_outcome_remove_active_evidence() -> N
|
||||
reversed_outcome.outcome_status = "reversed"
|
||||
db.flush()
|
||||
|
||||
assert ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
||||
{}, current_user
|
||||
) == []
|
||||
assert (
|
||||
ExpenseApplicationMemoryService(db).apply_active_transport_memory({}, current_user)
|
||||
== []
|
||||
)
|
||||
db.refresh(entry)
|
||||
assert entry.status == "suppressed"
|
||||
assert entry.evidence_count == 1
|
||||
@@ -369,9 +371,10 @@ def test_opposite_evidence_immediately_suppresses_old_active_memory() -> None:
|
||||
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
|
||||
) == []
|
||||
assert (
|
||||
ExpenseApplicationMemoryService(db).apply_active_transport_memory({}, current_user)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_non_whitelisted_correction_suppresses_old_active_without_storing_value() -> None:
|
||||
@@ -392,9 +395,10 @@ def test_non_whitelisted_correction_suppresses_old_active_without_storing_value(
|
||||
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
|
||||
) == []
|
||||
assert (
|
||||
ExpenseApplicationMemoryService(db).apply_active_transport_memory({}, current_user)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_expiry_sets_expired_at_and_replay_does_not_extend_ttl() -> None:
|
||||
@@ -428,9 +432,7 @@ def test_expiry_sets_expired_at_and_replay_does_not_extend_ttl() -> None:
|
||||
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
|
||||
)
|
||||
memories = ExpenseApplicationMemoryService(db).list_current_user_memories(current_user)
|
||||
db.refresh(entry)
|
||||
assert memories.items[0].status == "expired"
|
||||
assert entry.status == "expired"
|
||||
@@ -472,10 +474,13 @@ def test_replay_after_revoke_or_suppression_never_recreates_memory() -> None:
|
||||
)
|
||||
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
|
||||
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(
|
||||
@@ -515,9 +520,7 @@ def test_replay_after_revoke_or_suppression_never_recreates_memory() -> None:
|
||||
assert suppressed_entry.status == "suppressed"
|
||||
entry_count = len(list(db.scalars(select(MemoryEntry)).all()))
|
||||
|
||||
suppressed_replay = ExpenseApplicationMemoryService(
|
||||
db
|
||||
).record_transport_edit_evidence(
|
||||
suppressed_replay = ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
|
||||
current_user=current_user,
|
||||
decision=decision,
|
||||
feedback=feedback,
|
||||
@@ -537,13 +540,14 @@ def test_memory_isolated_by_tenant_and_owner_and_revoke_clears_value() -> None:
|
||||
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
|
||||
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
|
||||
@@ -576,10 +580,7 @@ def test_memory_rejects_same_tenant_evidence_from_another_owner() -> None:
|
||||
transport_mode="火车",
|
||||
)
|
||||
assert (
|
||||
ExpenseApplicationMemoryService(db)
|
||||
.list_current_user_memories(other_owner)
|
||||
.items
|
||||
== []
|
||||
ExpenseApplicationMemoryService(db).list_current_user_memories(other_owner).items == []
|
||||
)
|
||||
|
||||
|
||||
@@ -638,13 +639,29 @@ def test_memory_read_failure_degrades_without_mutating_facts(
|
||||
def test_preview_and_orchestrator_apply_active_memory() -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
current_user = _user()
|
||||
manager = Employee(
|
||||
id="employee-memory-manager",
|
||||
tenant_id=current_user.tenant_id,
|
||||
employee_no="E-MEMORY-MANAGER",
|
||||
name="记忆测试经理",
|
||||
email="employee-memory-manager@example.com",
|
||||
)
|
||||
employee = Employee(
|
||||
id=current_user.employee_id,
|
||||
tenant_id=current_user.tenant_id,
|
||||
employee_no=current_user.employee_no,
|
||||
name=current_user.name,
|
||||
email=current_user.username,
|
||||
manager=manager,
|
||||
)
|
||||
db.add_all([manager, employee])
|
||||
db.flush()
|
||||
_seed_active_memory(db, current_user=current_user)
|
||||
|
||||
preview = ExpenseApplicationPreviewWorkflow(db).issue(
|
||||
ExpenseApplicationPreviewDecisionCreate(
|
||||
message=(
|
||||
"申请时间:2026-07-20 至 2026-07-22\n"
|
||||
"地点:上海\n事由:客户现场实施\n天数:3天"
|
||||
"申请时间:2026-07-20 至 2026-07-22\n地点:上海\n事由:客户现场实施\n天数:3天"
|
||||
),
|
||||
conversation_id="conversation-memory-preview",
|
||||
request_id="request-memory-preview",
|
||||
@@ -685,9 +702,7 @@ def test_preview_and_orchestrator_apply_active_memory() -> None:
|
||||
)
|
||||
assert outcome is not None
|
||||
assert outcome.result["application_preview"]["fields"]["transportMode"] == "火车"
|
||||
assert outcome.result["application_preview"]["memoryApplications"][0][
|
||||
"status"
|
||||
] == "applied"
|
||||
assert outcome.result["application_preview"]["memoryApplications"][0]["status"] == "applied"
|
||||
assert "补充出行方式" not in str(outcome.result)
|
||||
|
||||
edited_fields = dict(preview.application_preview["fields"])
|
||||
@@ -749,10 +764,13 @@ def test_memory_api_enforces_owner_and_allows_owner_revoke() -> None:
|
||||
"/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
|
||||
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}",
|
||||
|
||||
Reference in New Issue
Block a user