feat(ai): add expense application feedback ledger

This commit is contained in:
caoxiaozhu
2026-07-14 11:10:55 +08:00
parent 5ed34c2b8f
commit a662cfe6c3
24 changed files with 2159 additions and 34 deletions

View File

@@ -9,19 +9,21 @@ import pytest
from alembic.config import Config
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.engine import Engine, make_url
from sqlalchemy.exc import IntegrityError
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.schema_ownership import MIGRATION_OWNED_TABLES
from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
HEAD_REVISION = "20260713_0002"
HEAD_REVISION = "20260714_0003"
SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
def _normalize_probe_component(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
@@ -143,6 +145,25 @@ def _assert_cascade_foreign_key(engine: Engine, table_name: str) -> None:
assert str(matching[0].get("options", {}).get("ondelete", "")).upper() == "CASCADE"
def _assert_composite_foreign_key(
engine: Engine,
table_name: str,
constrained_columns: tuple[str, ...],
referred_table: str,
referred_columns: tuple[str, ...] = ("tenant_id", "id"),
) -> None:
foreign_keys = inspect(engine).get_foreign_keys(table_name, schema="public")
matching = [
item
for item in foreign_keys
if tuple(item["constrained_columns"]) == constrained_columns
and item["referred_table"] == referred_table
and tuple(item["referred_columns"]) == referred_columns
]
assert len(matching) == 1
assert str(matching[0].get("options", {}).get("ondelete", "")).upper() == "RESTRICT"
def _assert_head_schema(engine: Engine) -> None:
names = _table_names(engine)
assert MIGRATION_OWNED_TABLES.issubset(names)
@@ -163,6 +184,12 @@ def _assert_head_schema(engine: Engine) -> None:
"uq_expense_case_links_resource",
("resource_type", "resource_id"),
)
_assert_unique_constraint(
engine,
"business_events",
"uq_business_events_tenant_case_id",
("tenant_id", "expense_case_id", "id"),
)
_assert_unique_constraint(
engine,
"business_events",
@@ -175,6 +202,30 @@ def _assert_head_schema(engine: Engine) -> None:
"uq_auth_sessions_token_hash",
("token_hash",),
)
_assert_unique_constraint(
engine,
"ai_decisions",
"uq_ai_decisions_tenant_case_id",
("tenant_id", "expense_case_id", "id"),
)
_assert_unique_constraint(
engine,
"ai_decisions",
"uq_ai_decisions_tenant_idempotency",
("tenant_id", "idempotency_key"),
)
_assert_unique_constraint(
engine,
"ai_decision_feedback",
"uq_ai_decision_feedback_tenant_idempotency",
("tenant_id", "idempotency_key"),
)
_assert_unique_constraint(
engine,
"workflow_outcomes",
"uq_workflow_outcomes_tenant_idempotency",
("tenant_id", "idempotency_key"),
)
_assert_indexes(
engine,
@@ -214,8 +265,85 @@ def _assert_head_schema(engine: Engine) -> None:
"ix_auth_sessions_tenant_username": ("tenant_id", "username"),
},
)
_assert_indexes(
engine,
"ai_decisions",
{
"ix_ai_decisions_tenant_case_time": (
"tenant_id",
"expense_case_id",
"created_at",
),
"ix_ai_decisions_tenant_subject": (
"tenant_id",
"subject_type",
"subject_id",
),
},
)
_assert_indexes(
engine,
"ai_decision_feedback",
{
"ix_ai_decision_feedback_tenant_decision_time": (
"tenant_id",
"decision_id",
"created_at",
),
},
)
_assert_indexes(
engine,
"workflow_outcomes",
{
"ix_workflow_outcomes_tenant_case_time": (
"tenant_id",
"expense_case_id",
"effective_at",
),
},
)
_assert_cascade_foreign_key(engine, "expense_case_links")
_assert_cascade_foreign_key(engine, "business_events")
_assert_composite_foreign_key(
engine,
"ai_decisions",
("tenant_id", "expense_case_id"),
"expense_cases",
)
_assert_composite_foreign_key(
engine,
"ai_decisions",
("tenant_id", "expense_case_id", "business_event_id"),
"business_events",
("tenant_id", "expense_case_id", "id"),
)
_assert_composite_foreign_key(
engine,
"ai_decision_feedback",
("tenant_id", "decision_id"),
"ai_decisions",
)
_assert_composite_foreign_key(
engine,
"workflow_outcomes",
("tenant_id", "expense_case_id"),
"expense_cases",
)
_assert_composite_foreign_key(
engine,
"workflow_outcomes",
("tenant_id", "expense_case_id", "decision_id"),
"ai_decisions",
("tenant_id", "expense_case_id", "id"),
)
_assert_composite_foreign_key(
engine,
"workflow_outcomes",
("tenant_id", "expense_case_id", "business_event_id"),
"business_events",
("tenant_id", "expense_case_id", "id"),
)
def _assert_runtime_cascade(engine: Engine) -> None:
@@ -271,6 +399,194 @@ def _assert_runtime_cascade(engine: Engine) -> None:
) == 0
def _assert_learning_ledger_tenant_boundary(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO expense_cases (
id, tenant_id, case_no, scene_code, title, current_stage, status
) VALUES
(
'learning-probe-case', 'learning-probe', 'CASE-LEARNING-PROBE',
'travel', '学习闭环迁移验证', 'claiming', 'active'
),
(
'learning-probe-case-b', 'learning-probe', 'CASE-LEARNING-PROBE-B',
'travel', '学习闭环同租户第二费用单', 'claiming', 'active'
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO business_events (
id, tenant_id, expense_case_id, aggregate_type, aggregate_id,
event_type, event_version, idempotency_key, correlation_id,
actor_id, actor_type, payload_json, delivery_status, delivery_attempts
) VALUES (
'learning-probe-event', 'learning-probe', 'learning-probe-case',
'expense_claim', 'learning-probe-claim', 'claim_draft_created', 1,
'learning-probe-event-key', 'learning-probe-correlation',
'learning-probe-user', 'user', '{}', 'pending', 0
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO ai_decisions (
id, tenant_id, expense_case_id, business_event_id,
expense_claim_id, agent_run_id, correlation_id, subject_type,
subject_id, decision_type, decision_source, status,
automation_mode, confidence, suggestion_json, evidence_json,
version_json, schema_version, idempotency_key, content_fingerprint
) VALUES (
'learning-probe-decision', 'learning-probe', 'learning-probe-case',
'learning-probe-event', 'learning-probe-claim', NULL,
'learning-probe-correlation', 'expense_claim', 'learning-probe-claim',
'expense_application_prefill', 'hybrid', 'executed', 'prefill', 0.9,
'{}', '{}', '{}', 1, 'learning-probe-decision-key',
'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO ai_decision_feedback (
id, tenant_id, decision_id, expense_claim_id, correlation_id,
feedback_type, action_type, actor_id, actor_type, evidence_source,
verification_status, final_value_json, changed_fields_json, idempotency_key,
content_fingerprint
) VALUES (
'learning-probe-feedback', 'learning-probe', 'learning-probe-decision',
'learning-probe-claim', 'learning-probe-correlation', 'accepted',
'save_draft', 'learning-probe-user', 'user',
'client_action_confirmation', 'client_observed', '{}', '[]',
'learning-probe-feedback-key',
'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
)
"""
)
)
connection.execute(
text(
"""
INSERT INTO workflow_outcomes (
id, tenant_id, expense_case_id, decision_id, business_event_id,
expense_claim_id, correlation_id, outcome_type, outcome_status,
actor_id, actor_type, result_json, idempotency_key,
content_fingerprint
) VALUES (
'learning-probe-outcome', 'learning-probe', 'learning-probe-case',
'learning-probe-decision', 'learning-probe-event',
'learning-probe-claim', 'learning-probe-correlation', 'draft_saved',
'recorded', 'learning-probe-user', 'user', '{}',
'learning-probe-outcome-key',
'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'
)
"""
)
)
with pytest.raises(IntegrityError):
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO ai_decisions (
id, tenant_id, expense_case_id, expense_claim_id,
correlation_id, subject_type, subject_id, decision_type,
decision_source, status, automation_mode, suggestion_json,
evidence_json, version_json, schema_version, idempotency_key,
content_fingerprint
) VALUES (
'cross-tenant-decision', 'other-tenant', 'learning-probe-case',
'cross-tenant-claim', 'cross-tenant-correlation', 'expense_claim',
'cross-tenant-claim', 'expense_application_prefill', 'heuristic',
'executed', 'prefill', '{}', '{}', '{}', 1,
'cross-tenant-key',
'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'
)
"""
)
)
with pytest.raises(IntegrityError):
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO ai_decision_feedback (
id, tenant_id, decision_id, expense_claim_id, correlation_id,
feedback_type, action_type, actor_id, actor_type, evidence_source,
verification_status, training_eligible, final_value_json,
changed_fields_json, idempotency_key, content_fingerprint
) VALUES (
'unverified-training-feedback', 'learning-probe',
'learning-probe-decision', 'learning-probe-claim',
'unverified-training-correlation', 'accepted', 'save_draft',
'learning-probe-user', 'user', 'client_action_confirmation',
'client_observed', TRUE, '{}', '[]',
'unverified-training-feedback-key',
'sha256:9999999999999999999999999999999999999999999999999999999999999999'
)
"""
)
)
with pytest.raises(IntegrityError):
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO ai_decisions (
id, tenant_id, expense_case_id, business_event_id,
expense_claim_id, correlation_id, subject_type, subject_id,
decision_type, decision_source, status, automation_mode,
suggestion_json, evidence_json, version_json, schema_version,
idempotency_key, content_fingerprint
) VALUES (
'cross-case-event-decision', 'learning-probe',
'learning-probe-case-b', 'learning-probe-event',
'cross-case-event-claim', 'cross-case-event-correlation',
'expense_claim', 'cross-case-event-claim',
'expense_application_prefill', 'heuristic', 'executed',
'prefill', '{}', '{}', '{}', 1,
'cross-case-event-key',
'sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'
)
"""
)
)
with pytest.raises(IntegrityError):
with engine.begin() as connection:
connection.execute(
text(
"""
INSERT INTO workflow_outcomes (
id, tenant_id, expense_case_id, decision_id,
expense_claim_id, correlation_id, outcome_type, outcome_status,
actor_id, actor_type, result_json, idempotency_key,
content_fingerprint
) VALUES (
'cross-case-decision-outcome', 'learning-probe',
'learning-probe-case-b', 'learning-probe-decision',
'cross-case-decision-claim', 'cross-case-decision-correlation',
'draft_saved', 'recorded', 'learning-probe-user', 'user', '{}',
'cross-case-decision-key',
'sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'
)
"""
)
)
def _create_legacy_sentinel(engine: Engine) -> None:
with engine.begin() as connection:
connection.execute(
@@ -337,9 +653,12 @@ def test_alembic_migration_cycle_on_disposable_postgres(
_upgrade_head(migration_database_url)
_assert_head_schema(engine)
assert validate_migration_state(engine).revision == HEAD_REVISION
create_legacy_schema(engine)
assert "expense_claims" in _table_names(engine)
_upgrade_head(migration_database_url)
_assert_head_schema(engine)
_assert_learning_ledger_tenant_boundary(engine)
_assert_runtime_cascade(engine)
_create_legacy_sentinel(engine)