feat(ai): add tenant-safe hierarchical expense learning

This commit is contained in:
caoxiaozhu
2026-07-16 14:30:41 +08:00
parent 6bdf65bc24
commit ee88a36baf
65 changed files with 6909 additions and 232 deletions

View File

@@ -3,7 +3,10 @@ from __future__ import annotations
import os
import re
from collections.abc import Iterator
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from alembic.config import Config
@@ -14,16 +17,44 @@ from sqlalchemy.pool import NullPool
from alembic import command
from app.core.config import get_settings
from app.db.migration_preflight import MigrationPreflightError, validate_migration_state
from app.db.migration_preflight import (
LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES,
MigrationPreflightError,
validate_migration_state,
)
from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
from app.models.ai_memory import MemoryEntry
from app.models.risk_observation import RiskObservation
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
HEAD_REVISION = "20260716_0006"
HEAD_REVISION = "20260716_0009"
SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
class _UnsupportedDialectOperationGuard:
def __init__(self, dialect_name: str = "sqlite") -> None:
self.bind = SimpleNamespace(dialect=SimpleNamespace(name=dialect_name))
self.mutation_calls: list[str] = []
def get_bind(self) -> SimpleNamespace:
return self.bind
def __getattr__(self, name: str) -> Any:
self.mutation_calls.append(name)
raise AssertionError(f"unsupported dialect attempted migration operation: {name}")
def _load_migration_module(filename: str) -> Any:
path = SERVER_DIR / "alembic" / "versions" / filename
spec = spec_from_file_location(f"migration_test_{path.stem}", path)
assert spec is not None and spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _normalize_probe_component(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
@@ -53,13 +84,9 @@ def _require_disposable_probe_url(raw_url: str) -> str:
host = _normalize_probe_component(parsed.host or "")
database = _normalize_probe_component(parsed.database or "")
if not _is_disposable_probe_host(host):
raise RuntimeError(
"迁移测试数据库主机名必须使用 migration-probe 或 disposable-probe 前缀"
)
raise RuntimeError("迁移测试数据库主机名必须使用 migration-probe 或 disposable-probe 前缀")
if not _is_disposable_probe_database(database):
raise RuntimeError(
"迁移测试数据库名必须使用 migration-probe 或 disposable-probe 前缀"
)
raise RuntimeError("迁移测试数据库名必须使用 migration-probe 或 disposable-probe 前缀")
return raw_url
@@ -93,13 +120,21 @@ def _alembic_config(database_url: str) -> Config:
def _upgrade_head(database_url: str) -> None:
_upgrade_revision(database_url, "head")
def _upgrade_revision(database_url: str, revision: str) -> None:
get_settings.cache_clear()
command.upgrade(_alembic_config(database_url), "head")
command.upgrade(_alembic_config(database_url), revision)
def _downgrade_base(database_url: str) -> None:
_downgrade_revision(database_url, "base")
def _downgrade_revision(database_url: str, revision: str) -> None:
get_settings.cache_clear()
command.downgrade(_alembic_config(database_url), "base")
command.downgrade(_alembic_config(database_url), revision)
def _table_names(engine: Engine) -> set[str]:
@@ -132,6 +167,28 @@ def _assert_indexes(
assert indexes.get(index_name) == expected_columns
def _assert_postgresql_index_predicate(
engine: Engine,
table_name: str,
index_name: str,
*expected_fragments: str,
) -> None:
with engine.connect() as connection:
index_definition = str(
connection.scalar(
text(
"SELECT indexdef FROM pg_indexes "
"WHERE schemaname = 'public' "
"AND tablename = :table_name AND indexname = :index_name"
),
{"table_name": table_name, "index_name": index_name},
)
or ""
).lower()
for fragment in expected_fragments:
assert fragment.lower() in index_definition
def _assert_check_constraint(
engine: Engine,
table_name: str,
@@ -176,6 +233,20 @@ def _assert_composite_foreign_key(
assert str(matching[0].get("options", {}).get("ondelete", "")).upper() == "RESTRICT"
def _assert_no_foreign_key(
engine: Engine,
table_name: str,
constrained_columns: tuple[str, ...],
referred_table: str,
) -> None:
foreign_keys = inspect(engine).get_foreign_keys(table_name, schema="public")
assert not any(
tuple(item["constrained_columns"]) == constrained_columns
and item["referred_table"] == referred_table
for item in foreign_keys
)
def _assert_head_schema(engine: Engine) -> None:
names = _table_names(engine)
assert MIGRATION_OWNED_TABLES.issubset(names)
@@ -280,11 +351,48 @@ def _assert_head_schema(engine: Engine) -> None:
"uq_attachment_association_jobs_owner_dedupe",
("tenant_id", "owner_username", "dedupe_key", "generation"),
)
_assert_unique_constraint(
engine,
"risk_observations",
"uq_risk_observations_tenant_key",
("tenant_id", "observation_key"),
)
_assert_unique_constraint(
engine,
"few_shot_samples",
"uq_few_shot_samples_tenant_key",
("tenant_id", "sample_key"),
)
_assert_check_constraint(
engine,
"memory_entries",
"ck_memory_entries_expired_fields",
)
_assert_check_constraint(
engine,
"memory_entries",
"ck_memory_entries_scope_origin",
)
_assert_check_constraint(
engine,
"memory_entries",
"ck_memory_entries_enterprise_scope",
)
_assert_check_constraint(
engine,
"memory_entries",
"ck_memory_entries_management_audit",
)
_assert_check_constraint(
engine,
"memory_entries",
"ck_memory_entries_management_idempotency_pair",
)
_assert_check_constraint(
engine,
"memory_entries",
"ck_memory_entries_revoke_idempotency_pair",
)
_assert_check_constraint(
engine,
"attachment_association_jobs",
@@ -304,6 +412,16 @@ def _assert_head_schema(engine: Engine) -> None:
"ix_expense_cases_tenant_status": ("tenant_id", "status"),
},
)
_assert_postgresql_index_predicate(
engine,
"memory_entries",
"uq_memory_entries_active_scope",
"status",
"'active'",
"scope_type",
"department",
"enterprise",
)
_assert_indexes(
engine,
"expense_case_links",
@@ -416,6 +534,21 @@ def _assert_head_schema(engine: Engine) -> None:
"field_key",
"status",
),
"uq_memory_entries_management_request": (
"tenant_id",
"management_request_id",
),
"uq_memory_entries_revoke_request": (
"tenant_id",
"revoke_request_id",
),
"uq_memory_entries_active_scope": (
"tenant_id",
"scope_type",
"scope_id",
"scene",
"field_key",
),
},
)
_assert_indexes(
@@ -429,6 +562,30 @@ def _assert_head_schema(engine: Engine) -> None:
),
},
)
_assert_indexes(
engine,
"risk_observations",
{
"ix_risk_observations_tenant_status": (
"tenant_id",
"status",
"created_at",
),
},
)
_assert_indexes(
engine,
"few_shot_samples",
{
"ix_few_shot_samples_tenant_rule_lookup": (
"tenant_id",
"scene",
"policy_ref",
"rule_version",
"status",
),
},
)
_assert_cascade_foreign_key(engine, "expense_case_links")
_assert_cascade_foreign_key(engine, "business_events")
_assert_composite_foreign_key(
@@ -443,6 +600,12 @@ def _assert_head_schema(engine: Engine) -> None:
("tenant_id", "expense_case_id"),
"expense_cases",
)
_assert_no_foreign_key(
engine,
"risk_observations",
("claim_id",),
"expense_claims",
)
_assert_composite_foreign_key(
engine,
"ai_decisions",
@@ -556,15 +719,19 @@ def _assert_runtime_cascade(engine: Engine) -> None:
"""
)
)
connection.execute(
text("DELETE FROM expense_cases WHERE id = 'migration-probe-case'")
connection.execute(text("DELETE FROM expense_cases WHERE id = 'migration-probe-case'"))
assert (
connection.scalar(
text("SELECT COUNT(*) FROM expense_case_links WHERE id = 'migration-probe-link'")
)
== 0
)
assert (
connection.scalar(
text("SELECT COUNT(*) FROM business_events WHERE id = 'migration-probe-event'")
)
== 0
)
assert connection.scalar(
text("SELECT COUNT(*) FROM expense_case_links WHERE id = 'migration-probe-link'")
) == 0
assert connection.scalar(
text("SELECT COUNT(*) FROM business_events WHERE id = 'migration-probe-event'")
) == 0
def _assert_learning_ledger_tenant_boundary(engine: Engine) -> None:
@@ -777,26 +944,273 @@ def _create_legacy_sentinel(engine: Engine) -> None:
)
def _create_hierarchical_memory_downgrade_probe(engine: Engine) -> None:
"""验证 0007 在真实组织记忆存在时仍可安全降级。"""
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO memory_entries (
id, tenant_id, scope_type, scope_id, origin_type,
managed_by, managed_at, management_reason, policy_version,
scene, field_key, generation, value_json, value_fingerprint,
status, evidence_count, approved_evidence_count, confidence,
last_evidence_at, candidate_expires_at,
activated_at, active_expires_at
) VALUES (
'hierarchical-memory-downgrade-probe',
'migration-probe', 'enterprise', 'migration-probe',
'admin_managed', 'migration-admin', CURRENT_TIMESTAMP,
'验证组织记忆降级', 'expense_application_transport_org_memory.v1',
'travel_application', 'transport_mode', 1,
CAST('{"value":"火车"}' AS JSON),
'sha256:hierarchical-memory-downgrade-probe',
'active', 0, 0, 1,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '180 days',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '180 days'
)
"""
)
)
assert connection.scalar(
text(
"SELECT COUNT(*) FROM memory_entries "
"WHERE id = 'hierarchical-memory-downgrade-probe'"
)
) == 1
def _create_duplicate_active_organization_memory_probe(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO memory_entries (
id, tenant_id, scope_type, scope_id, origin_type,
managed_by, managed_at, management_reason, policy_version,
scene, field_key, generation, value_json, value_fingerprint,
status, evidence_count, approved_evidence_count, confidence,
last_evidence_at, candidate_expires_at,
activated_at, active_expires_at
) VALUES
(
'duplicate-active-organization-a',
'migration-probe', 'department', 'department-probe',
'admin_managed', 'migration-admin', CURRENT_TIMESTAMP,
'验证组织生效记忆重复预检', 'expense_application_transport_org_memory.v1',
'travel_application', 'transport_mode', 1,
CAST('{"value":"火车"}' AS JSON),
'sha256:duplicate-active-organization-a',
'active', 0, 0, 1,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '180 days',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '180 days'
),
(
'duplicate-active-organization-b',
'migration-probe', 'department', 'department-probe',
'admin_managed', 'migration-admin', CURRENT_TIMESTAMP,
'验证组织生效记忆重复预检', 'expense_application_transport_org_memory.v1',
'travel_application', 'transport_mode', 2,
CAST('{"value":"飞机"}' AS JSON),
'sha256:duplicate-active-organization-b',
'active', 0, 0, 1,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '180 days',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '180 days'
)
"""
)
)
def _delete_duplicate_active_organization_memory_probe(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
text(
"DELETE FROM memory_entries "
"WHERE id IN ("
"'duplicate-active-organization-a', "
"'duplicate-active-organization-b'"
")"
)
)
def _create_enriched_few_shot_downgrade_probe(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO few_shot_samples (
id, tenant_id, sample_key, scene,
policy_ref, rule_version, payload_json
) VALUES (
'enriched-few-shot-downgrade-probe', 'default',
'enriched:few-shot:downgrade-probe', 'expense_reimbursement',
'TRAVEL-001', 'v2', CAST('{}' AS JSON)
)
"""
)
)
def _delete_enriched_few_shot_downgrade_probe(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
text(
"DELETE FROM few_shot_samples "
"WHERE id = 'enriched-few-shot-downgrade-probe'"
)
)
def _create_historical_case_downgrade_probe(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO risk_observations (
id, tenant_id, observation_key, subject_type, subject_key,
risk_type, risk_signal, risk_level,
contribution_scores_json, baseline_json, evidence_json,
graph_node_keys_json, graph_edge_keys_json, policy_refs_json,
similar_case_claim_ids_json, ontology_json, decision_trace_json
) VALUES (
'historical-downgrade-observation', 'default',
'historical:downgrade:observation', 'expense_claim',
'claim:historical-downgrade', 'duplicate_invoice',
'duplicate_invoice', 'high', CAST('{}' AS JSON), CAST('{}' AS JSON),
CAST('[]' AS JSON), CAST('[]' AS JSON), CAST('[]' AS JSON),
CAST('[]' AS JSON), CAST('[]' AS JSON), CAST('{}' AS JSON),
CAST('{}' AS JSON)
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO risk_observation_feedback (
id, observation_id, feedback_type, payload_json
) VALUES (
'historical-downgrade-feedback',
'historical-downgrade-observation', 'confirm', CAST('{}' AS JSON)
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO few_shot_samples (
id, tenant_id, sample_key, source_observation_id,
scene, policy_ref, rule_version, payload_json
) VALUES (
'historical-downgrade-sample', 'default',
'historical:downgrade:sample', 'historical-downgrade-observation',
'expense_reimbursement', '', '', CAST('{}' AS JSON)
)
"""
)
)
def _assert_historical_case_downgrade_probe(engine: Engine) -> None:
inspector = inspect(engine)
risk_columns = {
str(item["name"]) for item in inspector.get_columns("risk_observations")
}
sample_columns = {
str(item["name"]) for item in inspector.get_columns("few_shot_samples")
}
assert "tenant_id" not in risk_columns
assert {"tenant_id", "policy_ref", "rule_version"}.isdisjoint(sample_columns)
assert not any(
item["constrained_columns"] == ["claim_id"]
and item["referred_table"] == "expense_claims"
and item["referred_columns"] == ["id"]
for item in inspector.get_foreign_keys("risk_observations")
)
with engine.connect() as connection:
assert connection.scalar(
text(
"SELECT COUNT(*) FROM risk_observations "
"WHERE id = 'historical-downgrade-observation'"
)
) == 1
assert connection.scalar(
text(
"SELECT COUNT(*) FROM risk_observation_feedback "
"WHERE id = 'historical-downgrade-feedback'"
)
) == 1
assert connection.scalar(
text(
"SELECT COUNT(*) FROM few_shot_samples "
"WHERE id = 'historical-downgrade-sample'"
)
) == 1
def _assert_legacy_sentinel(engine: Engine) -> None:
assert LEGACY_PROBE_TABLE in _table_names(engine)
with engine.connect() as connection:
payload = connection.scalar(
text(
f"SELECT payload FROM {LEGACY_PROBE_TABLE} "
"WHERE id = 'legacy-sentinel'"
)
text(f"SELECT payload FROM {LEGACY_PROBE_TABLE} WHERE id = 'legacy-sentinel'")
)
assert payload == "must-survive-migration-cycle"
def _assert_base_schema(engine: Engine) -> None:
names = _table_names(engine)
assert not MIGRATION_OWNED_TABLES.intersection(names)
unsafe_owned_tables = (
MIGRATION_OWNED_TABLES - LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES
).intersection(names)
assert not unsafe_owned_tables
assert LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES.issubset(names)
assert "alembic_version" in names
with engine.connect() as connection:
assert connection.scalar(text("SELECT COUNT(*) FROM alembic_version")) == 0
@pytest.mark.parametrize(
("filename", "direction"),
[
("20260716_0008_tenant_safe_historical_cases.py", "upgrade"),
("20260716_0008_tenant_safe_historical_cases.py", "downgrade"),
("20260716_0009_organization_memory_idempotency.py", "upgrade"),
("20260716_0009_organization_memory_idempotency.py", "downgrade"),
],
)
def test_postgresql_only_migrations_reject_other_dialects_before_mutation(
filename: str,
direction: str,
) -> None:
migration = _load_migration_module(filename)
operation_guard = _UnsupportedDialectOperationGuard()
migration.op = operation_guard
with pytest.raises(RuntimeError, match="only supports PostgreSQL"):
getattr(migration, direction)()
assert operation_guard.mutation_calls == []
def test_head_model_declares_soft_claim_reference_and_organization_only_active_index() -> None:
claim_column = RiskObservation.__table__.c.claim_id
assert not claim_column.foreign_keys
claim_relationship = inspect(RiskObservation).relationships["claim"]
assert claim_relationship.viewonly is True
active_scope_index = next(
index
for index in MemoryEntry.__table__.indexes
if index.name == "uq_memory_entries_active_scope"
)
predicate = str(active_scope_index.dialect_options["postgresql"]["where"])
assert "status = 'active'" in predicate
assert "scope_type IN ('department', 'enterprise')" in predicate
@pytest.mark.parametrize(
"database_url",
[
@@ -818,6 +1232,23 @@ def test_alembic_migration_cycle_on_disposable_postgres(
assert _table_names(engine) == set(), "迁移测试必须从全新空库开始"
assert validate_migration_state(engine).revision is None
_upgrade_revision(migration_database_url, "20260716_0008")
_create_duplicate_active_organization_memory_probe(engine)
with pytest.raises(RuntimeError, match="duplicate active memories"):
_upgrade_head(migration_database_url)
assert validate_migration_state(engine).revision == "20260716_0008"
memory_columns = {
str(item["name"])
for item in inspect(engine).get_columns("memory_entries", schema="public")
}
assert {
"management_request_id",
"management_payload_fingerprint",
"revoke_request_id",
"revoke_payload_fingerprint",
}.isdisjoint(memory_columns)
_delete_duplicate_active_organization_memory_probe(engine)
_upgrade_head(migration_database_url)
_assert_head_schema(engine)
assert validate_migration_state(engine).revision == HEAD_REVISION
@@ -830,10 +1261,28 @@ def test_alembic_migration_cycle_on_disposable_postgres(
_assert_runtime_cascade(engine)
_create_legacy_sentinel(engine)
_downgrade_revision(migration_database_url, "20260716_0008")
_create_enriched_few_shot_downgrade_probe(engine)
with pytest.raises(RuntimeError, match="contain policy_ref or rule_version data"):
_downgrade_revision(migration_database_url, "20260716_0007")
assert validate_migration_state(engine).revision == "20260716_0008"
with engine.connect() as connection:
assert connection.execute(
text(
"SELECT policy_ref, rule_version FROM few_shot_samples "
"WHERE id = 'enriched-few-shot-downgrade-probe'"
)
).one() == ("TRAVEL-001", "v2")
_delete_enriched_few_shot_downgrade_probe(engine)
_upgrade_head(migration_database_url)
_create_hierarchical_memory_downgrade_probe(engine)
_create_historical_case_downgrade_probe(engine)
_downgrade_base(migration_database_url)
_assert_base_schema(engine)
_assert_legacy_sentinel(engine)
_assert_historical_case_downgrade_probe(engine)
assert validate_migration_state(engine).revision is None
with engine.begin() as connection:

View File

@@ -0,0 +1,505 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import select
from app.api.deps import CurrentUserContext
from app.models.ai_memory import MemoryEntry
from app.models.organization import OrganizationUnit
from app.schemas.expense_application_memory import (
ExpenseApplicationOrganizationMemoryCreate,
ExpenseApplicationOrganizationMemoryRevoke,
ExpenseApplicationOrganizationMemoryUpdate,
)
from app.services.expense_application_memory import ExpenseApplicationMemoryService
from app.services.expense_application_memory_admin import (
ExpenseApplicationOrganizationMemoryService,
OrganizationMemoryConflictError,
OrganizationMemoryNotFoundError,
)
from app.services.organization_memory_locks import (
organization_memory_advisory_lock_id,
organization_memory_operation_locks,
)
from app.test_helpers.db import build_in_memory_session_factory
def _user(
*,
tenant_id: str = "tenant-hierarchy",
department_id: str = "department-delivery",
is_admin: bool = False,
) -> CurrentUserContext:
return CurrentUserContext(
username="memory-admin@example.com" if is_admin else "memory-user@example.com",
name="记忆管理员" if is_admin else "记忆员工",
role_codes=["admin"] if is_admin else ["user"],
is_admin=is_admin,
tenant_id=tenant_id,
employee_id="memory-admin" if is_admin else "memory-user",
employee_no="E-MEMORY",
department_id=department_id,
department_name="交付部",
)
def _active_organization_memory(
*,
tenant_id: str,
scope_type: str,
scope_id: str,
value: str,
generation: int,
activated_at: datetime | None = None,
expires_at: datetime | None = None,
) -> MemoryEntry:
now = datetime.now(UTC)
activated_at = activated_at or now
expires_at = expires_at or now + timedelta(days=180)
return MemoryEntry(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
origin_type="admin_managed",
managed_by="memory-admin",
managed_at=now,
management_reason="测试组织记忆",
policy_version="expense_application_transport_org_memory.v1",
scene="travel_application",
field_key="transport_mode",
generation=generation,
value_json={"value": value},
value_fingerprint=f"fingerprint-{scope_type}-{generation}-{value}",
status="active",
evidence_count=0,
approved_evidence_count=0,
confidence=Decimal("1.0000"),
last_evidence_at=now,
candidate_expires_at=expires_at,
activated_at=activated_at,
active_expires_at=expires_at,
created_at=activated_at,
updated_at=now,
)
def test_enterprise_precedes_department_and_explicit_input_precedes_all() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
db.add_all(
[
_active_organization_memory(
tenant_id=current_user.tenant_id,
scope_type="enterprise",
scope_id=current_user.tenant_id,
value="飞机",
generation=1,
),
_active_organization_memory(
tenant_id=current_user.tenant_id,
scope_type="department",
scope_id=current_user.department_id,
value="火车",
generation=1,
),
]
)
db.flush()
facts: dict[str, object] = {}
applications = ExpenseApplicationMemoryService(db).apply_active_transport_memory(
facts, current_user
)
assert facts == {"transport_mode": "飞机"}
assert applications[0].scope_type == "enterprise"
assert applications[0].priority == 300
assert applications[0].source == "enterprise_policy_memory"
assert applications[0].can_revoke is False
assert applications[0].conflicts[0].reason == "lower_priority_overridden"
assert applications[0].conflicts[0].scope_type == "department"
explicit_facts = {"transport_mode": "轮船"}
assert (
ExpenseApplicationMemoryService(db).apply_active_transport_memory(
explicit_facts, current_user
)
== []
)
assert explicit_facts == {"transport_mode": "轮船"}
def test_same_level_conflict_fails_closed_and_is_explainable() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
db.add_all(
[
_active_organization_memory(
tenant_id=current_user.tenant_id,
scope_type="enterprise",
scope_id=current_user.tenant_id,
value="飞机",
generation=1,
),
_active_organization_memory(
tenant_id=current_user.tenant_id,
scope_type="enterprise",
scope_id=current_user.tenant_id,
value="火车",
generation=2,
),
]
)
db.flush()
facts: dict[str, object] = {}
applications = ExpenseApplicationMemoryService(db).apply_active_transport_memory(
facts, current_user
)
assert facts == {}
assert len(applications) == 1
assert applications[0].status == "conflict"
assert applications[0].memory_id == ""
assert applications[0].scope_id == ""
assert applications[0].value == ""
assert len(applications[0].conflicts) == 2
assert {
conflict.reason for conflict in applications[0].conflicts
} == {"same_priority_conflict"}
for conflict in applications[0].model_dump()["conflicts"]:
assert "memory_id" not in conflict
assert "scope_id" not in conflict
assert "value" not in conflict
def test_expiry_and_time_decay_never_apply_cross_tenant_or_expire_early() -> None:
with build_in_memory_session_factory()() as db:
current_user = _user()
now = datetime.now(UTC)
expired = _active_organization_memory(
tenant_id=current_user.tenant_id,
scope_type="department",
scope_id=current_user.department_id,
value="火车",
generation=1,
activated_at=now - timedelta(days=181),
expires_at=now - timedelta(days=1),
)
other_tenant = _active_organization_memory(
tenant_id="tenant-other",
scope_type="enterprise",
scope_id="tenant-other",
value="轮船",
generation=1,
)
decayed = _active_organization_memory(
tenant_id=current_user.tenant_id,
scope_type="enterprise",
scope_id=current_user.tenant_id,
value="飞机",
generation=1,
activated_at=now - timedelta(days=100),
expires_at=now + timedelta(days=100),
)
db.add_all([expired, other_tenant, decayed])
db.flush()
facts: dict[str, object] = {}
applications = ExpenseApplicationMemoryService(db).apply_active_transport_memory(
facts, current_user
)
db.refresh(expired)
assert expired.status == "expired"
assert facts == {"transport_mode": "飞机"}
assert 0.49 <= applications[0].effective_confidence <= 0.51
assert applications[0].memory_id == decayed.id
def test_organization_memory_lifecycle_is_audited_versioned_and_tenant_safe() -> None:
with build_in_memory_session_factory()() as db:
admin = _user(is_admin=True)
service = ExpenseApplicationOrganizationMemoryService(db)
created = service.create_organization_memory(
ExpenseApplicationOrganizationMemoryCreate(
scope_type="enterprise",
value="火车",
expires_in_days=90,
reason="统一优先使用高铁",
request_id="create-enterprise-memory-1",
),
admin,
)
assert created.scope_id == admin.tenant_id
assert created.generation == 1
assert created.origin_type == "admin_managed"
assert created.managed_by == admin.employee_id
assert created.management_reason == "统一优先使用高铁"
replayed_create = service.create_organization_memory(
ExpenseApplicationOrganizationMemoryCreate(
scope_type="enterprise",
value="火车",
expires_in_days=90,
reason="统一优先使用高铁",
request_id="create-enterprise-memory-1",
),
admin,
)
assert replayed_create.id == created.id
with pytest.raises(OrganizationMemoryConflictError, match="已有生效记忆"):
service.create_organization_memory(
ExpenseApplicationOrganizationMemoryCreate(
scope_type="enterprise",
value="火车",
expires_in_days=90,
reason="重复创建企业基线",
request_id="create-enterprise-memory-2",
),
admin,
)
updated = service.update_organization_memory(
created.id,
ExpenseApplicationOrganizationMemoryUpdate(
value="飞机",
expires_in_days=120,
expected_generation=1,
reason="适配跨区域差旅",
request_id="update-enterprise-memory-1",
),
admin,
)
assert updated.id != created.id
assert updated.generation == 2
old_entry = db.get(MemoryEntry, created.id)
assert old_entry is not None
assert old_entry.status == "suppressed"
assert old_entry.superseded_by_id == updated.id
replayed_update = service.update_organization_memory(
created.id,
ExpenseApplicationOrganizationMemoryUpdate(
value="飞机",
expires_in_days=120,
expected_generation=1,
reason="适配跨区域差旅",
request_id="update-enterprise-memory-1",
),
admin,
)
assert replayed_update.id == updated.id
with pytest.raises(OrganizationMemoryConflictError, match="刷新后重试"):
service.update_organization_memory(
created.id,
ExpenseApplicationOrganizationMemoryUpdate(
value="轮船",
expected_generation=1,
reason="使用旧记录地址更新",
request_id="update-enterprise-memory-old-id",
),
admin,
)
with pytest.raises(OrganizationMemoryConflictError, match="刷新后重试"):
service.update_organization_memory(
updated.id,
ExpenseApplicationOrganizationMemoryUpdate(
expected_generation=1,
reason="使用陈旧版本更新",
request_id="update-enterprise-memory-stale",
),
admin,
)
with pytest.raises(OrganizationMemoryNotFoundError):
service.revoke_organization_memory(
updated.id,
ExpenseApplicationOrganizationMemoryRevoke(
expected_generation=2,
reason="越权租户撤销",
request_id="revoke-other-tenant-memory",
),
_user(tenant_id="tenant-other", is_admin=True),
)
revoked = service.revoke_organization_memory(
updated.id,
ExpenseApplicationOrganizationMemoryRevoke(
expected_generation=2,
reason="企业政策已调整",
request_id="revoke-enterprise-memory-1",
),
admin,
)
assert revoked.memory_id == updated.id
revoked_entry = db.get(MemoryEntry, updated.id)
assert revoked_entry is not None
assert revoked_entry.status == "revoked"
assert revoked_entry.value_json == {"value": "飞机"}
assert revoked_entry.revoked_reason == "企业政策已调整"
replayed_revoke = service.revoke_organization_memory(
updated.id,
ExpenseApplicationOrganizationMemoryRevoke(
expected_generation=2,
reason="企业政策已调整",
request_id="revoke-enterprise-memory-1",
),
admin,
)
assert replayed_revoke == revoked
with pytest.raises(OrganizationMemoryConflictError, match="请求内容不一致"):
service.revoke_organization_memory(
updated.id,
ExpenseApplicationOrganizationMemoryRevoke(
expected_generation=2,
reason="同一键却改变请求体",
request_id="revoke-enterprise-memory-1",
),
admin,
)
assert ExpenseApplicationMemoryService(db).revoke_current_user_memory(
updated.id, admin
) is None
def test_department_memory_requires_existing_department_and_admin() -> None:
with build_in_memory_session_factory()() as db:
department = OrganizationUnit(
id="department-delivery",
unit_code="DELIVERY",
name="交付部",
unit_type="department",
)
division = OrganizationUnit(
id="division-east",
unit_code="EAST",
name="华东大区",
unit_type="division",
)
db.add_all([department, division])
db.flush()
service = ExpenseApplicationOrganizationMemoryService(db)
payload = ExpenseApplicationOrganizationMemoryCreate(
scope_type="department",
scope_id=department.id,
value="火车",
reason="部门短途差旅基线",
request_id="create-department-memory-1",
)
with pytest.raises(PermissionError, match="平台管理员"):
service.create_organization_memory(payload, _user())
with pytest.raises(ValueError, match="department 类型"):
service.create_organization_memory(
payload.model_copy(update={"scope_id": division.id}),
_user(is_admin=True),
)
created = service.create_organization_memory(payload, _user(is_admin=True))
assert created.scope_id == department.id
assert created.scope_label == "部门规则(交付部)"
memories = service.list_organization_memories(_user(is_admin=True))
assert [item.id for item in memories.items] == [created.id]
assert db.scalar(
select(MemoryEntry).where(MemoryEntry.id == created.id)
) is not None
def test_expired_organization_memory_revoke_returns_conflict_and_marks_expired() -> None:
with build_in_memory_session_factory()() as db:
admin = _user(is_admin=True)
now = datetime.now(UTC)
entry = _active_organization_memory(
tenant_id=admin.tenant_id,
scope_type="enterprise",
scope_id=admin.tenant_id,
value="火车",
generation=1,
activated_at=now - timedelta(days=100),
expires_at=now - timedelta(days=1),
)
db.add(entry)
db.commit()
with pytest.raises(OrganizationMemoryConflictError, match="已过期"):
ExpenseApplicationOrganizationMemoryService(db).revoke_organization_memory(
entry.id,
ExpenseApplicationOrganizationMemoryRevoke(
expected_generation=1,
reason="撤销已过期规则",
request_id="revoke-expired-memory-1",
),
admin,
)
db.refresh(entry)
assert entry.status == "expired"
assert entry.expired_at is not None
def test_postgres_organization_mutations_use_signed_transaction_advisory_locks() -> None:
executed: list[object] = []
class _Dialect:
name = "postgresql"
class _Bind:
dialect = _Dialect()
class _RecordingSession:
@staticmethod
def get_bind() -> _Bind:
return _Bind()
@staticmethod
def execute(statement: object) -> None:
executed.append(statement)
session = _RecordingSession()
with organization_memory_operation_locks( # type: ignore[arg-type]
session,
"scope-lock",
"request-lock",
):
assert len(executed) == 2
assert all("pg_advisory_xact_lock" in str(statement) for statement in executed)
lock_id = organization_memory_advisory_lock_id("scope-lock")
assert -(2**63) <= lock_id < 2**63
assert lock_id == organization_memory_advisory_lock_id("scope-lock")
def test_new_generation_rejects_unexpected_active_set_before_suppression() -> None:
with build_in_memory_session_factory()() as db:
admin = _user(is_admin=True)
active = _active_organization_memory(
tenant_id=admin.tenant_id,
scope_type="enterprise",
scope_id=admin.tenant_id,
value="火车",
generation=1,
)
db.add(active)
db.commit()
now = datetime.now(UTC)
with pytest.raises(OrganizationMemoryConflictError, match="生效版本已变化"):
ExpenseApplicationOrganizationMemoryService(db)._new_active_generation(
entry_id=str(uuid.uuid4()),
tenant_id=admin.tenant_id,
scope_type="enterprise",
scope_id=admin.tenant_id,
value="飞机",
expires_at=now + timedelta(days=180),
reason="模拟并发创建",
request_id="unexpected-active-generation",
request_fingerprint="hmac-sha256:" + "0" * 64,
expected_active_ids=set(),
current_user=admin,
now=now,
)
db.refresh(active)
assert active.status == "active"
assert active.superseded_by_id is None

View File

@@ -0,0 +1,279 @@
from __future__ import annotations
from collections.abc import Generator
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.models.ai_memory import MemoryEntry
from app.models.organization import OrganizationUnit
def _build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
with session_factory() as db:
db.add(
OrganizationUnit(
id="department-finance",
unit_code="FINANCE",
name="财务部",
unit_type="department",
)
)
db.commit()
app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
with session_factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def _headers(*, tenant_id: str = "tenant-a", admin: bool = True) -> dict[str, str]:
return {
"X-Auth-Username": "admin@example.com" if admin else "employee@example.com",
"X-Auth-Name": "Tenant Admin" if admin else "Employee",
"X-Auth-Tenant-Id": tenant_id,
"X-Auth-Is-Admin": "true" if admin else "false",
"X-Auth-Role-Codes": "manager" if admin else "user",
}
def test_organization_memory_api_requires_platform_admin() -> None:
client, _ = _build_client()
response = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(admin=False),
json={
"scope_type": "enterprise",
"value": "火车",
"expires_in_days": 180,
"reason": "统一差旅基线",
"request_id": "create-enterprise-memory-denied",
},
)
assert response.status_code == 403
def test_organization_memory_api_lifecycle_and_tenant_isolation() -> None:
client, session_factory = _build_client()
created = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(),
json={
"scope_type": "enterprise",
"value": "火车",
"expires_in_days": 180,
"reason": "统一差旅基线",
"request_id": "create-enterprise-memory-a",
},
)
assert created.status_code == 201, created.text
first = created.json()
assert first["scope_id"] == "tenant-a"
assert first["generation"] == 1
assert first["can_revoke"] is True
replayed = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(),
json={
"scope_type": "enterprise",
"value": "火车",
"expires_in_days": 180,
"reason": "统一差旅基线",
"request_id": "create-enterprise-memory-a",
},
)
assert replayed.status_code == 201
assert replayed.json()["id"] == first["id"]
changed_create_payload = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(),
json={
"scope_type": "enterprise",
"value": "飞机",
"expires_in_days": 180,
"reason": "同键不同请求体",
"request_id": "create-enterprise-memory-a",
},
)
assert changed_create_payload.status_code == 409
duplicate = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(),
json={
"scope_type": "enterprise",
"value": "轮船",
"expires_in_days": 180,
"reason": "不应静默覆盖",
"request_id": "create-enterprise-memory-b",
},
)
assert duplicate.status_code == 409
updated = client.put(
f"/api/v1/expense-application-memories/organization/{first['id']}",
headers=_headers(),
json={
"value": "飞机",
"expires_in_days": 120,
"expected_generation": 1,
"reason": "制度版本调整",
"request_id": "update-enterprise-memory-a",
},
)
assert updated.status_code == 200, updated.text
second = updated.json()
assert second["generation"] == 2
assert second["value"] == "飞机"
changed_update_payload = client.put(
f"/api/v1/expense-application-memories/organization/{first['id']}",
headers=_headers(),
json={
"value": "轮船",
"expires_in_days": 120,
"expected_generation": 1,
"reason": "同键不同请求体",
"request_id": "update-enterprise-memory-a",
},
)
assert changed_update_payload.status_code == 409
stale_old_id = client.put(
f"/api/v1/expense-application-memories/organization/{first['id']}",
headers=_headers(),
json={
"value": "轮船",
"expected_generation": 1,
"reason": "旧地址更新",
"request_id": "update-enterprise-memory-old-id",
},
)
assert stale_old_id.status_code == 409
stale = client.put(
f"/api/v1/expense-application-memories/organization/{second['id']}",
headers=_headers(),
json={
"value": "轮船",
"expected_generation": 1,
"reason": "陈旧页面更新",
"request_id": "update-enterprise-memory-stale",
},
)
assert stale.status_code == 409
assert client.get(
"/api/v1/expense-application-memories/organization",
headers=_headers(tenant_id="tenant-b"),
).json() == {"items": []}
hidden = client.post(
f"/api/v1/expense-application-memories/organization/{second['id']}/revoke",
headers=_headers(tenant_id="tenant-b"),
json={
"expected_generation": 2,
"reason": "越权尝试",
"request_id": "revoke-other-tenant",
},
)
assert hidden.status_code == 404
revoked = client.post(
f"/api/v1/expense-application-memories/organization/{second['id']}/revoke",
headers=_headers(),
json={
"expected_generation": 2,
"reason": "改用新制度",
"request_id": "revoke-enterprise-memory-a",
},
)
assert revoked.status_code == 200, revoked.text
replayed_revoke = client.post(
f"/api/v1/expense-application-memories/organization/{second['id']}/revoke",
headers=_headers(),
json={
"expected_generation": 2,
"reason": "改用新制度",
"request_id": "revoke-enterprise-memory-a",
},
)
assert replayed_revoke.status_code == 200
assert replayed_revoke.json() == revoked.json()
changed_revoke_payload = client.post(
f"/api/v1/expense-application-memories/organization/{second['id']}/revoke",
headers=_headers(),
json={
"expected_generation": 2,
"reason": "同键不同请求体",
"request_id": "revoke-enterprise-memory-a",
},
)
assert changed_revoke_payload.status_code == 409
with session_factory() as db:
entries = list(
db.scalars(
select(MemoryEntry)
.where(MemoryEntry.tenant_id == "tenant-a")
.order_by(MemoryEntry.generation.asc())
).all()
)
assert [entry.status for entry in entries] == ["suppressed", "revoked"]
assert entries[0].superseded_by_id == entries[1].id
assert entries[0].management_request_id == "create-enterprise-memory-a"
assert entries[0].management_payload_fingerprint.startswith("hmac-sha256:")
assert entries[1].management_request_id == "update-enterprise-memory-a"
assert entries[1].revoke_request_id == "revoke-enterprise-memory-a"
assert entries[1].revoke_payload_fingerprint.startswith("hmac-sha256:")
def test_department_memory_api_requires_stable_department_id() -> None:
client, _ = _build_client()
invalid = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(),
json={
"scope_type": "department",
"scope_id": "财务部",
"value": "火车",
"expires_in_days": 90,
"reason": "部门差旅基线",
"request_id": "create-department-invalid",
},
)
assert invalid.status_code == 422
created = client.post(
"/api/v1/expense-application-memories/organization",
headers=_headers(),
json={
"scope_type": "department",
"scope_id": "department-finance",
"value": "火车",
"expires_in_days": 90,
"reason": "部门差旅基线",
"request_id": "create-department-finance",
},
)
assert created.status_code == 201, created.text
assert created.json()["scope_id"] == "department-finance"
assert "财务部" in created.json()["scope_label"]

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

View File

@@ -1,9 +1,7 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import datetime
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@@ -55,6 +53,7 @@ def _observation(db: Session, key: str = "risk:c1:dup") -> RiskObservation:
)
db.flush()
obs = RiskObservation(
tenant_id="tenant-a",
observation_key=key,
subject_type="expense_claim",
subject_key="claim:c1",
@@ -91,6 +90,7 @@ def test_ingest_confirmed_persists_sample_and_calls_store() -> None:
)
assert sample is not None
assert sample.label == "confirmed"
assert sample.tenant_id == "tenant-a"
assert sample.sample_key == f"obs:{obs.id}"
assert "重复发票" in sample.case_text
assert "确认重复发票" in sample.conclusion_text
@@ -98,13 +98,38 @@ def test_ingest_confirmed_persists_sample_and_calls_store() -> None:
fake_store.upsert.assert_called_once()
def test_ingest_extracts_business_scene_and_rule_identity() -> None:
with _build_session() as db:
obs = _observation(db, key="risk:c1:identity")
obs.feedback_status = "confirmed"
obs.control_stage = "reimbursement"
obs.policy_refs_json = [{"rule_code": "TRAVEL-001"}]
obs.decision_trace_json = {"rule_version": "v2.3"}
service = FewShotIngestionService(db)
with patch.object(
service,
"_store",
return_value=MagicMock(upsert=MagicMock(return_value=None)),
):
sample = service.ingest_observation_feedback(
obs,
MagicMock(feedback_type="confirm", comment="确认", actor="audit"),
)
assert sample is not None
assert sample.scene == "expense_reimbursement"
assert sample.policy_ref == "TRAVEL-001"
assert sample.rule_version == "v2.3"
def test_ingest_false_positive_also_persisted() -> None:
with _build_session() as db:
obs = _observation(db, key="risk:c2:fp")
obs.feedback_status = "false_positive"
db.commit()
service = FewShotIngestionService(db)
with patch.object(service, "_store", return_value=MagicMock(upsert=MagicMock(return_value=None))):
with patch.object(
service, "_store", return_value=MagicMock(upsert=MagicMock(return_value=None))
):
sample = service.ingest_observation_feedback(
obs,
MagicMock(feedback_type="false_positive", comment="", actor="audit"),
@@ -164,6 +189,7 @@ def test_create_feedback_hook_triggers_ingestion() -> None:
service.create_feedback(
obs.observation_key,
RiskObservationFeedbackCreate(feedback_type="confirm", actor="audit"),
tenant_id="tenant-a",
)
assert len(ingest_calls) == 1
assert ingest_calls[0][1] == "confirm"
@@ -178,7 +204,10 @@ def test_create_feedback_hook_skipped_for_comment_feedback() -> None:
) as mock_ingest:
service.create_feedback(
obs.observation_key,
RiskObservationFeedbackCreate(feedback_type="comment", action="note", actor="audit"),
RiskObservationFeedbackCreate(
feedback_type="comment", action="note", actor="audit"
),
tenant_id="tenant-a",
)
mock_ingest.assert_not_called()
@@ -195,6 +224,7 @@ def test_create_feedback_hook_swallows_ingestion_failure() -> None:
feedback = service.create_feedback(
obs.observation_key,
RiskObservationFeedbackCreate(feedback_type="confirm", actor="audit"),
tenant_id="tenant-a",
)
assert feedback.feedback_type == "confirm"
@@ -210,5 +240,6 @@ def test_create_feedback_hook_respects_feature_flag(monkeypatch: pytest.MonkeyPa
service.create_feedback(
obs.observation_key,
RiskObservationFeedbackCreate(feedback_type="confirm", actor="audit"),
tenant_id="tenant-a",
)
mock_ingest.assert_not_called()

View File

@@ -1,12 +1,11 @@
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from unittest.mock import MagicMock
from app.services.few_shot_retrieval import FewShotRetriever
from app.services.few_shot_store import FewShotStore
from app.services.risk_rule_generation import RiskRuleGenerationService
from app.services.risk_rule_generation_prompt import build_risk_rule_compiler_messages
@@ -36,7 +35,9 @@ def test_retrieve_returns_injection_blocks_with_token_budget() -> None:
]
retriever = FewShotRetriever(store)
blocks = retriever.retrieve_for_risk_rule_generation(
domain="expense", natural_language="同一发票重复报销"
tenant_id="tenant-a",
domain="expense",
natural_language="同一发票重复报销",
)
assert len(blocks) == 2
assert blocks[0]["score"] == 0.9
@@ -51,7 +52,13 @@ def test_retrieve_returns_injection_blocks_with_token_budget() -> None:
def test_retrieve_empty_case_text_returns_empty() -> None:
store = MagicMock(spec=FewShotStore)
retriever = FewShotRetriever(store)
assert retriever.retrieve_for_risk_rule_generation(natural_language="") == []
assert (
retriever.retrieve_for_risk_rule_generation(
tenant_id="tenant-a",
natural_language="",
)
== []
)
store.search.assert_not_called()
@@ -62,7 +69,10 @@ def test_retrieve_truncates_overlong_conclusion() -> None:
_hit(0.9, "confirmed", long_text),
]
retriever = FewShotRetriever(store)
blocks = retriever.retrieve_for_risk_rule_generation(natural_language="x")
blocks = retriever.retrieve_for_risk_rule_generation(
tenant_id="tenant-a",
natural_language="x",
)
assert len(blocks) == 1
# 超长结论应被截断到单条上限
from app.services.few_shot_retrieval import SINGLE_SAMPLE_MAX_CHARS
@@ -70,6 +80,45 @@ def test_retrieve_truncates_overlong_conclusion() -> None:
assert len(blocks[0]["conclusion"]) <= SINGLE_SAMPLE_MAX_CHARS
def test_rule_generation_forwards_authenticated_tenant_to_retriever(monkeypatch) -> None:
fake_retriever = MagicMock()
fake_retriever.retrieve_for_risk_rule_generation.return_value = []
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "true")
monkeypatch.setattr(
FewShotRetriever,
"from_session",
classmethod(lambda _cls, _session: fake_retriever),
)
service = RiskRuleGenerationService(MagicMock())
service._retrieve_few_shot_samples(
tenant_id="tenant-smart-learning",
domain="expense",
natural_language="重复发票风险规则",
)
fake_retriever.retrieve_for_risk_rule_generation.assert_called_once_with(
tenant_id="tenant-smart-learning",
domain="expense",
natural_language="重复发票风险规则",
)
def test_rule_generation_without_tenant_disables_historical_injection(monkeypatch) -> None:
from_session = MagicMock()
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "true")
monkeypatch.setattr(FewShotRetriever, "from_session", from_session)
result = RiskRuleGenerationService(MagicMock())._retrieve_few_shot_samples(
tenant_id=None,
domain="expense",
natural_language="重复发票风险规则",
)
assert result == []
from_session.assert_not_called()
def test_build_prompt_merges_few_shot_into_examples() -> None:
samples = [
{
@@ -89,7 +138,14 @@ def test_build_prompt_merges_few_shot_into_examples() -> None:
expense_category=None,
expense_category_label="",
natural_language="重复发票规则",
available_fields=[{"key": "attachment.invoice_no", "label": "发票号", "type": "string", "source": "attachment"}],
available_fields=[
{
"key": "attachment.invoice_no",
"label": "发票号",
"type": "string",
"source": "attachment",
}
],
few_shot_samples=samples,
)
assert len(messages) == 2

View File

@@ -0,0 +1,183 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import _authenticate_bearer_user
from app.db.base import Base
from app.models.ai_memory import MemoryEntry
from app.models.employee import Employee
from app.services.auth import AuthService
from app.services.auth_sessions import AuthSessionService
from app.services.employee import EmployeeService
def _build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)()
def _memory_entry(
*,
scope_type: str,
scope_id: str,
tenant_id: str = "tenant-a",
origin_type: str = "learned",
with_management_audit: bool = False,
) -> MemoryEntry:
now = datetime.now(UTC)
return MemoryEntry(
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
origin_type=origin_type,
managed_by="admin@example.com" if with_management_audit else None,
managed_at=now if with_management_audit else None,
management_reason="统一差旅交通基线" if with_management_audit else None,
policy_version="travel-policy-v1" if with_management_audit else None,
value_json={"value": "火车"},
value_fingerprint=f"{scope_type}:{scope_id}",
status="active",
evidence_count=0,
approved_evidence_count=0,
confidence=Decimal("1.0000"),
last_evidence_at=now,
candidate_expires_at=now + timedelta(days=90),
activated_at=now,
active_expires_at=now + timedelta(days=180),
created_at=now,
updated_at=now,
)
def test_memory_entry_accepts_learned_user_and_audited_organization_scopes() -> None:
with _build_session() as db:
learned_entry = _memory_entry(
scope_type="user",
scope_id="employee-a",
)
learned_entry.policy_version = "expense-application-memory.v1"
db.add(learned_entry)
db.add(
_memory_entry(
scope_type="department",
scope_id="department-a",
origin_type="admin_managed",
with_management_audit=True,
)
)
db.add(
_memory_entry(
scope_type="enterprise",
scope_id="tenant-a",
origin_type="admin_managed",
with_management_audit=True,
)
)
db.commit()
assert db.query(MemoryEntry).count() == 3
@pytest.mark.parametrize(
("entry", "expected_constraint"),
[
(
_memory_entry(
scope_type="department",
scope_id="department-a",
origin_type="learned",
),
"ck_memory_entries_scope_origin",
),
(
_memory_entry(
scope_type="enterprise",
scope_id="another-tenant",
origin_type="admin_managed",
with_management_audit=True,
),
"ck_memory_entries_enterprise_scope",
),
(
_memory_entry(
scope_type="department",
scope_id="department-a",
origin_type="admin_managed",
),
"ck_memory_entries_management_audit",
),
],
)
def test_memory_entry_rejects_invalid_scope_origin_and_management_audit(
entry: MemoryEntry,
expected_constraint: str,
) -> None:
with _build_session() as db:
db.add(entry)
with pytest.raises(IntegrityError, match=expected_constraint):
db.commit()
def test_admin_managed_memory_rejects_partially_missing_audit_fields() -> None:
with _build_session() as db:
entry = _memory_entry(
scope_type="department",
scope_id="department-a",
origin_type="admin_managed",
with_management_audit=True,
)
entry.management_reason = " "
db.add(entry)
with pytest.raises(IntegrityError, match="ck_memory_entries_management_audit"):
db.commit()
def test_authenticated_session_restores_tenant_and_stable_department_id() -> None:
with _build_session() as db:
employee_snapshot = EmployeeService(db).list_employees()[0]
employee = db.get(Employee, employee_snapshot.id)
assert employee is not None
authenticated_at_login = AuthService(db)._build_employee_user(employee)
authenticated_at_login.tenant_id = "tenant-session-a"
access_token, auth_session = AuthSessionService(db).issue(
authenticated_at_login,
metric_session_id="metric-session-a",
)
db.commit()
restored_user = AuthService(db).get_session_user(auth_session)
current_user = _authenticate_bearer_user(db, f"Bearer {access_token}")
assert restored_user is not None
assert restored_user.tenant_id == "tenant-session-a"
assert restored_user.department_id == employee.organization_unit_id
assert current_user.tenant_id == "tenant-session-a"
assert current_user.department_id == employee.organization_unit_id
assert current_user.department_name == employee.organization_unit.name
def test_platform_admin_has_no_department_scope() -> None:
with _build_session() as db:
record = type(
"AdminRecord",
(),
{"account": "admin", "email": "admin@example.com"},
)()
admin_user = AuthService(db)._build_admin_user(record)
assert admin_user.department_id is None

View File

@@ -7,6 +7,7 @@ from sqlalchemy import Column, Integer, MetaData, Table, create_engine, text
from sqlalchemy.engine import Engine
from app.db.migration_preflight import (
LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES,
MIGRATION_OWNED_TABLES_BY_REVISION,
MigrationPreflightError,
validate_migration_state,
@@ -46,9 +47,29 @@ def test_unversioned_database_without_migration_owned_tables_is_safe(engine: Eng
assert state.owned_tables == frozenset()
def test_unversioned_database_can_adopt_legacy_historical_case_tables(engine: Engine) -> None:
_create_tables(engine, LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES)
state = validate_migration_state(engine)
assert state.revision is None
assert state.owned_tables == LEGACY_ADOPTABLE_HISTORICAL_CASE_TABLES
def test_revision_0007_accepts_partial_legacy_historical_case_tables(engine: Engine) -> None:
expected = MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0007"]
adopted = frozenset({"risk_observations", "risk_observation_feedback"})
_create_tables(engine, expected | adopted)
_create_version_table(engine, "20260716_0007")
state = validate_migration_state(engine)
assert state.owned_tables == expected | adopted
@pytest.mark.parametrize(
"owned_table",
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0006"]),
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0007"]),
)
def test_unversioned_database_with_any_migration_owned_table_is_rejected(
engine: Engine,
@@ -102,8 +123,19 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
),
(
"20260716_0006",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0006"]
- {"attachment_association_jobs"},
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0006"] - {"attachment_association_jobs"},
),
(
"20260716_0007",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0007"] - {"memory_entries"},
),
(
"20260716_0008",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0008"] - {"few_shot_samples"},
),
(
"20260716_0009",
MIGRATION_OWNED_TABLES_BY_REVISION["20260716_0009"] - {"memory_entries"},
),
],
)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime
from decimal import Decimal
from types import SimpleNamespace
import pytest
from auth_helpers import install_legacy_header_auth_override
@@ -17,9 +18,11 @@ from app.api.deps import get_db
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
from app.db.base import Base
from app.models.employee import Employee
from app.models.expense_case import ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim
from app.models.risk_observation import RiskObservation
from app.schemas.risk_observation import RiskObservationFeedbackCreate
from app.services.hermes_risk_scanner import HermesRiskScannerService
from app.services.risk_observations import RiskObservationService
@@ -179,6 +182,7 @@ def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback()
assert "top_departments" in dashboard_response.json()
assert feedback_response.status_code == 200
assert feedback_response.json()["feedback_type"] == "false_positive"
assert feedback_response.json()["actor"] == "Test Admin"
updated_detail_response = client.get("/api/v1/risk-observations/risk:c1:duplicate_invoice")
assert updated_detail_response.status_code == 200
@@ -192,6 +196,237 @@ def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback()
assert observation.feedback_status == "false_positive"
def test_risk_observation_endpoints_enforce_tenant_scope_and_authenticated_actor() -> None:
client, session_factory = _build_client()
with session_factory() as db:
service = RiskObservationService(db)
tenant_a = service.upsert_observation(
{
**_observation_payload("risk:tenant-a:duplicate_invoice"),
"claim_id": "shared-claim",
},
tenant_id="tenant-a",
execution_log_id="shared-execution-log",
)
tenant_b = service.upsert_observation(
{
**_observation_payload("risk:tenant-b:duplicate_invoice"),
"claim_id": "shared-claim",
},
tenant_id="tenant-b",
execution_log_id="shared-execution-log",
)
tenant_a_id = tenant_a.id
tenant_b_id = tenant_b.id
db.commit()
tenant_a_headers = {
"X-Auth-Username": "auditor-a",
"X-Auth-Name": "Tenant A Auditor",
"X-Auth-Tenant-Id": "tenant-a",
}
tenant_b_headers = {
"X-Auth-Username": "auditor-b",
"X-Auth-Name": "Tenant B Auditor",
"X-Auth-Tenant-Id": "tenant-b",
}
list_response = client.get("/api/v1/risk-observations", headers=tenant_a_headers)
detail_response = client.get(
f"/api/v1/risk-observations/{tenant_a_id}",
headers=tenant_a_headers,
)
foreign_detail_response = client.get(
f"/api/v1/risk-observations/{tenant_b_id}",
headers=tenant_a_headers,
)
claim_response = client.get(
"/api/v1/risk-observations/claim/shared-claim",
headers=tenant_a_headers,
)
execution_log_response = client.get(
"/api/v1/risk-observations/execution-log/shared-execution-log",
headers=tenant_a_headers,
)
dashboard_response = client.get(
"/api/v1/risk-observations/dashboard",
headers=tenant_a_headers,
)
foreign_feedback_response = client.post(
f"/api/v1/risk-observations/{tenant_b_id}/feedback",
headers=tenant_a_headers,
json={"feedback_type": "confirm", "actor": "伪造管理员"},
)
own_feedback_response = client.post(
f"/api/v1/risk-observations/{tenant_a_id}/feedback",
headers=tenant_a_headers,
json={"feedback_type": "confirm", "actor": "伪造管理员"},
)
assert list_response.status_code == 200
assert list_response.json()["total"] == 1
assert list_response.json()["items"][0]["tenant_id"] == "tenant-a"
assert detail_response.status_code == 200
assert detail_response.json()["tenant_id"] == "tenant-a"
assert foreign_detail_response.status_code == 404
assert claim_response.status_code == 200
assert [item["tenant_id"] for item in claim_response.json()] == ["tenant-a"]
assert execution_log_response.status_code == 200
assert [item["tenant_id"] for item in execution_log_response.json()] == ["tenant-a"]
assert dashboard_response.status_code == 200
assert dashboard_response.json()["total_observations"] == 1
assert foreign_feedback_response.status_code == 404
assert own_feedback_response.status_code == 200
assert own_feedback_response.json()["actor"] == "Tenant A Auditor"
tenant_b_detail = client.get(
f"/api/v1/risk-observations/{tenant_b_id}",
headers=tenant_b_headers,
)
assert tenant_b_detail.status_code == 200
assert tenant_b_detail.json()["status"] == "pending_review"
assert tenant_b_detail.json()["feedback_items"] == []
def test_risk_observation_service_scopes_history_and_same_key_upserts_by_tenant() -> None:
with _build_session() as db:
service = RiskObservationService(db)
tenant_a = service.upsert_observation(
_observation_payload("risk:shared:duplicate_invoice"),
tenant_id="tenant-a",
)
tenant_b = service.upsert_observation(
_observation_payload("risk:shared:duplicate_invoice"),
tenant_id="tenant-b",
)
service.create_feedback(
tenant_a.id,
RiskObservationFeedbackCreate(feedback_type="confirm", actor="untrusted"),
tenant_id="tenant-a",
actor="trusted-auditor",
)
tenant_a_items, tenant_a_total = service.list_observations(tenant_id="tenant-a")
tenant_b_items, tenant_b_total = service.list_observations(tenant_id="tenant-b")
tenant_a_history = service.build_history_stats(
tenant_id="tenant-a",
risk_signals={"duplicate_invoice"},
)
tenant_b_history = service.build_history_stats(
tenant_id="tenant-b",
risk_signals={"duplicate_invoice"},
)
assert tenant_a.id != tenant_b.id
assert tenant_a_total == tenant_b_total == 1
assert [item.tenant_id for item in tenant_a_items] == ["tenant-a"]
assert [item.tenant_id for item in tenant_b_items] == ["tenant-b"]
assert tenant_a_history[0].confirmed_count == 1
assert tenant_b_history[0].confirmed_count == 0
assert tenant_a.feedback_items[0].actor == "trusted-auditor"
assert service.get_observation(tenant_b.id, tenant_id="tenant-a") is None
def test_risk_observation_rejects_explicit_tenant_mismatching_claim_link() -> None:
with _build_session() as db:
claim = _claim_orm("claim-tenant-boundary", "BX-TENANT-BOUNDARY")
expense_case = ExpenseCase(
id="case-tenant-boundary",
tenant_id="tenant-a",
case_no="CASE-TENANT-BOUNDARY",
scene_code="reimbursement",
title="租户边界测试",
current_stage="claiming",
status="active",
)
link = ExpenseCaseLink(
id="link-tenant-boundary",
tenant_id="tenant-a",
expense_case_id=expense_case.id,
resource_type="expense_claim",
resource_id=claim.id,
relation_type="claim",
)
db.add_all([claim, expense_case, link])
db.flush()
with pytest.raises(PermissionError, match="tenant does not match"):
RiskObservationService(db).upsert_observation(
{
**_observation_payload("risk:tenant-boundary"),
"claim_id": claim.id,
},
tenant_id="tenant-b",
)
assert db.query(RiskObservation).filter_by(
observation_key="risk:tenant-boundary"
).one_or_none() is None
def test_hermes_global_scan_builds_graphs_inside_each_tenant(
monkeypatch: pytest.MonkeyPatch,
) -> None:
with _build_session() as db:
claims = [
_claim_orm("claim-tenant-a", "BX-TENANT-A"),
_claim_orm("claim-tenant-b", "BX-TENANT-B"),
]
cases = [
ExpenseCase(
id=f"case-tenant-{suffix}",
tenant_id=f"tenant-{suffix}",
case_no=f"CASE-TENANT-{suffix.upper()}",
scene_code="reimbursement",
title="租户隔离图扫描",
current_stage="claiming",
status="active",
)
for suffix in ("a", "b")
]
links = [
ExpenseCaseLink(
id=f"link-tenant-{suffix}",
tenant_id=f"tenant-{suffix}",
expense_case_id=cases[index].id,
resource_type="expense_claim",
resource_id=claims[index].id,
relation_type="claim",
)
for index, suffix in enumerate(("a", "b"))
]
db.add_all([*claims, *cases, *links])
db.flush()
evaluated_claim_sets: list[set[str]] = []
history_tenants: list[str] = []
def fake_evaluate(context):
evaluated_claim_sets.append(set(context.target_claim_ids))
return SimpleNamespace(observations=[], nodes=[], edges=[])
def fake_history(_self, *, tenant_id=None, **_kwargs):
history_tenants.append(str(tenant_id))
return []
scanner = HermesRiskScannerService(db)
monkeypatch.setattr(scanner, "_fetch_unscanned_claims", lambda: claims)
monkeypatch.setattr(
"app.services.hermes_risk_scanner.evaluate_financial_risk_graph",
fake_evaluate,
)
monkeypatch.setattr(RiskObservationService, "build_history_stats", fake_history)
summary = scanner.scan_global_risks()
assert evaluated_claim_sets == [
{"claim-tenant-a"},
{"claim-tenant-b"},
]
assert history_tenants == ["tenant-a", "tenant-b"]
assert summary["scanned_claim_count"] == 2
def test_risk_observation_feedback_pool_fields_and_replay_set_contract() -> None:
with _build_session() as db:
service = RiskObservationService(db)

View File

@@ -119,8 +119,16 @@ def test_regenerate_risk_rule_endpoint_returns_updated_detail(tmp_path, monkeypa
client, session_factory = build_client()
asset_id = _create_rule(session_factory, tmp_path)
def fake_regenerate(self, target_asset_id, body, *, actor, request_id=None):
del body, request_id
def fake_regenerate(
self,
target_asset_id,
body,
*,
tenant_id=None,
actor,
request_id=None,
):
del body, request_id, tenant_id
asset = self.db.get(AgentAsset, target_asset_id)
assert asset is not None
config = dict(asset.config_json or {})

View File

@@ -26,6 +26,9 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
"expense_cases",
"memory_entries",
"memory_evidence_links",
"risk_observations",
"risk_observation_feedback",
"few_shot_samples",
"workflow_outcomes",
}
)

View File

@@ -0,0 +1,231 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from sqlalchemy import create_engine, select
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.risk_observation import RiskObservation
from app.services.few_shot_retrieval import FewShotRetriever
from app.services.few_shot_store import FewShotStore, stable_vector_id
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 _observation(*, tenant_id: str, key: str) -> RiskObservation:
return RiskObservation(
tenant_id=tenant_id,
observation_key=key,
subject_type="expense_claim",
subject_key="claim:1",
risk_type="duplicate_invoice",
risk_signal="duplicate_invoice",
risk_level="high",
)
def _sample(
*,
sample_id: str,
tenant_id: str,
sample_key: str,
version: str,
) -> FewShotSample:
return FewShotSample(
id=sample_id,
tenant_id=tenant_id,
sample_key=sample_key,
scene="expense_reimbursement",
policy_ref="TRAVEL-001",
rule_version=version,
label="confirmed",
case_text="同一发票重复报销",
conclusion_text=f"历史结论 {version}",
payload_json={"risk_signal": "duplicate_invoice"},
status="active",
)
def test_keys_are_unique_inside_tenant_but_reusable_across_tenants() -> None:
with _session() as db:
db.add_all(
[
_observation(tenant_id="tenant-a", key="same-key"),
_observation(tenant_id="tenant-b", key="same-key"),
_sample(
sample_id="sample-a",
tenant_id="tenant-a",
sample_key="same-sample",
version="v1",
),
_sample(
sample_id="sample-b",
tenant_id="tenant-b",
sample_key="same-sample",
version="v1",
),
]
)
db.commit()
assert len(db.scalars(select(RiskObservation)).all()) == 2
assert len(db.scalars(select(FewShotSample)).all()) == 2
def test_vector_id_is_stable_and_tenant_scoped() -> None:
first = stable_vector_id(tenant_id="tenant-a", sample_id="sample-1")
repeated = stable_vector_id(tenant_id="tenant-a", sample_id="sample-1")
other_tenant = stable_vector_id(tenant_id="tenant-b", sample_id="sample-1")
assert first == repeated
assert first != other_tenant
def test_store_upsert_replaces_legacy_vector_and_writes_tenant_payload() -> None:
provider = MagicMock()
provider.embed.return_value = [[0.1, 0.2]]
store = FewShotStore(provider)
client = MagicMock()
store._client = client
sample = SimpleNamespace(
id="sample-1",
tenant_id="tenant-a",
sample_key="key",
scene="expense_reimbursement",
policy_ref="TRAVEL-001",
rule_version="v2",
label="false_positive",
domain="expense",
risk_type="duplicate_invoice",
risk_level="high",
status="active",
case_text="案例",
conclusion_text="改判为误报",
payload_json={},
vector_id="legacy-random-vector-id",
)
with patch.object(store, "_ensure_collection", return_value=True):
vector_id = store.upsert(sample)
assert vector_id == stable_vector_id(tenant_id="tenant-a", sample_id="sample-1")
client.delete.assert_called_once()
points = client.upsert.call_args.kwargs["points"]
point = points[0]
assert point["id"] == vector_id
assert point["payload"]["tenant_id"] == "tenant-a"
assert point["payload"]["rule_version"] == "v2"
assert point["payload"]["label"] == "false_positive"
assert points[1]["id"] == "legacy-random-vector-id"
assert points[1]["payload"]["label"] == "false_positive"
def test_existing_collection_receives_required_payload_indexes() -> None:
provider = MagicMock()
store = FewShotStore(provider)
client = MagicMock()
client.get_collection.return_value = SimpleNamespace()
store._client = client
assert store._ensure_collection() is True
fields = {call.kwargs["field_name"] for call in client.create_payload_index.call_args_list}
assert {"tenant_id", "scene", "policy_ref", "rule_version", "status"} <= fields
client.create_collection.assert_not_called()
def test_search_always_filters_tenant_and_supports_rule_identity() -> None:
provider = MagicMock()
provider.embed.return_value = [[0.1, 0.2]]
store = FewShotStore(provider)
client = MagicMock()
client.query_points.return_value = SimpleNamespace(points=[])
store._client = client
with patch.object(store, "_ensure_collection", return_value=True):
assert (
store.search(
"重复发票",
tenant_id="tenant-a",
scene="expense_reimbursement",
policy_ref="TRAVEL-001",
rule_version="v2",
)
== []
)
filter_payload = client.query_points.call_args.kwargs["query_filter"].model_dump()
must = filter_payload["must"]
assert any(item["key"] == "tenant_id" and item["match"]["value"] == "tenant-a" for item in must)
assert any(item["key"] == "scene" for item in must)
assert any(item["key"] == "policy_ref" for item in must)
assert any(item["key"] == "rule_version" for item in must)
def test_expense_case_retrieval_db_rechecks_tenant_status_and_marks_old_version() -> None:
with _session() as db:
current = _sample(
sample_id="current",
tenant_id="tenant-a",
sample_key="current-key",
version="v2",
)
stale = _sample(
sample_id="stale",
tenant_id="tenant-a",
sample_key="stale-key",
version="v1",
)
other_tenant = _sample(
sample_id="other",
tenant_id="tenant-b",
sample_key="other-key",
version="v2",
)
db.add_all([current, stale, other_tenant])
db.commit()
store = MagicMock(spec=FewShotStore)
store.search.side_effect = [
[{"sample_id": "current", "score": 0.95}],
[
{"sample_id": "current", "score": 0.95},
{"sample_id": "stale", "score": 0.8},
{"sample_id": "other", "score": 0.99},
],
]
retriever = FewShotRetriever(store, db)
evidence = retriever.retrieve_for_expense_case(
tenant_id="tenant-a",
scene="expense_reimbursement",
policy_ref="TRAVEL-001",
rule_version="v2",
query="重复发票",
top_k=3,
)
assert [item["sample_id"] for item in evidence] == ["current", "stale"]
assert evidence[0]["version_status"] == "matched"
assert evidence[0]["advisory_only"] is True
assert evidence[1]["version_status"] == "stale"
assert evidence[1]["stale"] is True
def test_qdrant_unavailable_fails_closed() -> None:
store = FewShotStore(MagicMock())
with patch.object(store, "_ensure_collection", return_value=False):
assert store.search("案例", tenant_id="tenant-a") == []
assert store.upsert(SimpleNamespace(tenant_id="tenant-a", id="sample-1")) is None

View File

@@ -159,6 +159,42 @@ def test_untrusted_application_path_does_not_write_learning_ledger() -> None:
assert list(db.scalars(select(WorkflowOutcome)).all()) == []
def test_non_default_tenant_direct_submit_creates_same_tenant_case_link() -> None:
session_factory = build_session_factory()
with session_factory() as db:
request = build_request(
run_id="application-direct-submit-tenant",
tenant_id="tenant-direct-submit",
)
service = UserAgentService(db)
submitted = service._create_expense_application_record(
request,
build_facts(),
submit=True,
learning_current_user=service._build_application_current_user(request),
)
link = db.scalar(
select(ExpenseCaseLink).where(
ExpenseCaseLink.resource_type == "expense_claim",
ExpenseCaseLink.resource_id == submitted.id,
)
)
event = db.scalar(
select(BusinessEvent).where(
BusinessEvent.aggregate_type == "expense_claim",
BusinessEvent.aggregate_id == submitted.id,
BusinessEvent.event_type == "application_submitted",
)
)
assert submitted.status == "submitted"
assert link is not None
assert link.tenant_id == "tenant-direct-submit"
assert event is not None
assert event.tenant_id == "tenant-direct-submit"
assert event.expense_case_id == link.expense_case_id
@pytest.mark.parametrize(
"preview_patch",
[