feat(ai): add tenant-safe hierarchical expense learning
This commit is contained in:
@@ -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:
|
||||
|
||||
505
server/tests/test_expense_application_hierarchical_memory.py
Normal file
505
server/tests/test_expense_application_hierarchical_memory.py
Normal 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
|
||||
279
server/tests/test_expense_application_memory_admin_api.py
Normal file
279
server/tests/test_expense_application_memory_admin_api.py
Normal 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"]
|
||||
357
server/tests/test_expense_claim_historical_evidence.py
Normal file
357
server/tests/test_expense_claim_historical_evidence.py
Normal 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",
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
183
server/tests/test_hierarchical_expense_memory_foundation.py
Normal file
183
server/tests/test_hierarchical_expense_memory_foundation.py
Normal 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
|
||||
@@ -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"},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
231
server/tests/test_tenant_safe_few_shot_foundation.py
Normal file
231
server/tests/test_tenant_safe_few_shot_foundation.py
Normal 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
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user