Files
group_xinghuo_jinrong/tests/test_agent_behavior_service.py
T

255 lines
11 KiB
Python
Raw Normal View History

2026-09-07 19:53:39 +08:00
"""C6 · RISK-008 代理人行为链单测(A-12 验收覆盖)。
纯函数 _count_induce 边界 + detect_hits 三条件 + scan_and_alert 出单/同日去重 +
payload.actor_id 归属 + query_agent_behavior Tool 过滤/脱敏。
数据源:单 sqlite 引擎同时承载 Core 与 Agent 表(_ddl 已含 core_trade/audit_log/
risk_alert),单测共用 RiskRepository(engine) + CoreReadOnlyRepository(engine)。
"""
from __future__ import annotations
from datetime import datetime, timedelta
from sqlalchemy import text
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.service.risk.agent_behavior_service import (
BehaviorThresholds,
_count_induce,
detect_hits,
scan_and_alert,
)
from app.service.risk.chat_tools import query_agent_behavior
def _seed_trade(engine, trade_id, customer_id, trade_type, product_id, traded_at) -> None:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type,"
" amount, trade_status, traded_at) VALUES (:tid, :cid, :pid, :tt, 1000,"
" 'confirmed', :ta)"
),
{
"tid": trade_id, "cid": customer_id, "pid": product_id,
"tt": trade_type, "ta": traded_at,
},
)
def _seed_trade_request(make, trade_id, actor_id, customer_id, hours_ago=1.0) -> None:
make(
event_type="trade_request", actor_id=actor_id, customer_id=customer_id,
input_summary={"trade_id": trade_id}, decision="trade_accepted", hours_ago=hours_ago,
)
# ---------- _count_induce 纯函数边界 ----------
def test_count_induce_one_pair():
evts = [
{"trade_type": "redeem", "product_id": "P1", "traded_at": datetime(2026, 1, 1, 0, 0)},
{"trade_type": "subscribe", "product_id": "P2", "traded_at": datetime(2026, 1, 1, 0, 30)},
{"trade_type": "redeem", "product_id": "P3", "traded_at": datetime(2026, 1, 1, 1, 0)},
]
assert _count_induce(evts) == 1 # 仅第一对配对,第二个 redeem 无 subscribe
def test_count_induce_three_pairs():
base = datetime(2026, 1, 1, 0, 0)
evts = []
for i in range(3):
r = base + timedelta(minutes=10 * i)
s = r + timedelta(minutes=30)
evts.append({"trade_type": "redeem", "product_id": "P1", "traded_at": r})
evts.append({"trade_type": "subscribe", "product_id": "P2", "traded_at": s})
assert _count_induce(evts) == 3
def test_count_induce_same_product_not_count():
evts = [
{"trade_type": "redeem", "product_id": "P1", "traded_at": datetime(2026, 1, 1, 0, 0)},
{"trade_type": "subscribe", "product_id": "P1", "traded_at": datetime(2026, 1, 1, 0, 30)},
]
assert _count_induce(evts) == 0
def test_count_induce_window_boundary():
r = datetime(2026, 1, 1, 0, 0)
s_in = r + timedelta(seconds=7200) # 恰好 2h 不同产品 → 计入(含边界)
s_out = r + timedelta(seconds=7201) # 超 2h 1s → 不计
evts_in = [
{"trade_type": "redeem", "product_id": "P1", "traded_at": r},
{"trade_type": "subscribe", "product_id": "P2", "traded_at": s_in},
]
evts_out = [
{"trade_type": "redeem", "product_id": "P1", "traded_at": r},
{"trade_type": "subscribe", "product_id": "P2", "traded_at": s_out},
]
assert _count_induce(evts_in) == 1
assert _count_induce(evts_out) == 0
# ---------- 条件 A:本人 / SYSTEM 排除 + 3 次触发 ----------
def test_condition_a_self_and_system_excluded(sqlite_engine, backdated_audit_event):
now = datetime.now()
# 本人交易:actor_id == customer_id
_seed_trade(sqlite_engine, "TRD-TEST-SELF-R", "CUST-1", "redeem", "P1", now - timedelta(hours=1))
_seed_trade(sqlite_engine, "TRD-TEST-SELF-S", "CUST-1", "subscribe", "P2", now - timedelta(minutes=30))
backdated_audit_event(
event_type="trade_request", actor_id="CUST-1", customer_id="CUST-1",
input_summary={"trade_id": "TRD-TEST-SELF-R"}, hours_ago=1,
)
# SYSTEM 无归属
_seed_trade(sqlite_engine, "TRD-TEST-SYS-R", "CUST-2", "redeem", "P1", now - timedelta(hours=1))
_seed_trade(sqlite_engine, "TRD-TEST-SYS-S", "CUST-2", "subscribe", "P2", now - timedelta(minutes=30))
backdated_audit_event(
event_type="trade_request", actor_id="SYSTEM", customer_id="CUST-2",
input_summary={"trade_id": "TRD-TEST-SYS-R"}, hours_ago=1,
)
repo = RiskRepository(engine=sqlite_engine)
core = CoreReadOnlyRepository(engine=sqlite_engine)
assert detect_hits(now, core, repo, BehaviorThresholds()) == {}
def test_condition_a_three_triggers(sqlite_engine, backdated_audit_event):
now = datetime.now()
base = now - timedelta(hours=1)
for i in range(3):
r_t = base + timedelta(minutes=10 * i)
s_t = r_t + timedelta(minutes=30)
_seed_trade(sqlite_engine, f"TRD-TEST-A{i}-R", "CUST-1", "redeem", "P1", r_t)
_seed_trade(sqlite_engine, f"TRD-TEST-A{i}-S", "CUST-1", "subscribe", "P2", s_t)
# 代理人发起赎回 + 申购两笔请求(a_timeline 需同时含 redeem/subscribe 才能配对诱导)
_seed_trade_request(backdated_audit_event, f"TRD-TEST-A{i}-R", "STAFF-A", "CUST-1", hours_ago=1)
_seed_trade_request(backdated_audit_event, f"TRD-TEST-A{i}-S", "STAFF-A", "CUST-1", hours_ago=1)
repo = RiskRepository(engine=sqlite_engine)
core = CoreReadOnlyRepository(engine=sqlite_engine)
hits = detect_hits(now, core, repo, BehaviorThresholds())
assert "STAFF-A" in hits
assert hits["STAFF-A"]["A"][0]["detail"]["induce_count"] == 3
# ---------- 条件 B / C 边界 ----------
def test_condition_b_boundary(sqlite_engine, backdated_audit_event):
repo = RiskRepository(engine=sqlite_engine)
core = CoreReadOnlyRepository(engine=sqlite_engine)
now = datetime.now()
for _ in range(4): # 4 次不触发
backdated_audit_event(
event_type="authz", actor_id="STAFF-B", customer_id="CUST-1",
input_summary={"code": "AUTH_403_SCOPE"}, decision="forbidden", hours_ago=1,
)
assert "STAFF-B" not in detect_hits(now, core, repo, BehaviorThresholds())
backdated_audit_event( # 第 5 次触发
event_type="authz", actor_id="STAFF-B", customer_id="CUST-1",
input_summary={"code": "AUTH_403_SCOPE"}, decision="forbidden", hours_ago=1,
)
hits = detect_hits(now, core, repo, BehaviorThresholds())
assert "STAFF-B" in hits
assert len(hits["STAFF-B"]["B"]) == 5
def test_condition_c_boundary_and_union(sqlite_engine, backdated_audit_event):
repo = RiskRepository(engine=sqlite_engine)
core = CoreReadOnlyRepository(engine=sqlite_engine)
now = datetime.now()
for i in range(9): # 9 次(双 code 并集)不触发
code = "AUTH_403_NOT_OWNER" if i % 2 == 0 else "AUTH_403_NOT_ASSIGNED"
backdated_audit_event(
event_type="authz", actor_id="STAFF-C", customer_id="CUST-1",
input_summary={"code": code}, decision="forbidden", hours_ago=1,
)
assert "STAFF-C" not in detect_hits(now, core, repo, BehaviorThresholds())
backdated_audit_event( # 第 10 次触发
event_type="authz", actor_id="STAFF-C", customer_id="CUST-1",
input_summary={"code": "AUTH_403_NOT_OWNER"}, decision="forbidden", hours_ago=1,
)
hits = detect_hits(now, core, repo, BehaviorThresholds())
assert "STAFF-C" in hits
assert len(hits["STAFF-C"]["C"]) == 10
def test_condition_b_wrong_code_excluded(sqlite_engine, backdated_audit_event):
repo = RiskRepository(engine=sqlite_engine)
core = CoreReadOnlyRepository(engine=sqlite_engine)
now = datetime.now()
for _ in range(5):
backdated_audit_event(
event_type="authz", actor_id="STAFF-X", customer_id="CUST-1",
input_summary={"code": "AUTH_403_OTHER"}, decision="forbidden", hours_ago=1,
)
assert "STAFF-X" not in detect_hits(now, core, repo, BehaviorThresholds())
# ---------- scan_and_alert 出单 / 同日去重 / payload ----------
def _seed_agent_behavior_scenario(engine, make, actor_id="STAFF-A", customer_id="CUST-1") -> None:
now = datetime.now()
base = now - timedelta(hours=1)
for i in range(3):
r_t = base + timedelta(minutes=10 * i)
s_t = r_t + timedelta(minutes=30)
_seed_trade(engine, f"TRD-TEST-Z{i}-R", customer_id, "redeem", "P1", r_t)
_seed_trade(engine, f"TRD-TEST-Z{i}-S", customer_id, "subscribe", "P2", s_t)
# 代理人发起赎回 + 申购两笔请求
_seed_trade_request(make, f"TRD-TEST-Z{i}-R", actor_id, customer_id, hours_ago=1)
_seed_trade_request(make, f"TRD-TEST-Z{i}-S", actor_id, customer_id, hours_ago=1)
def test_scan_creates_one_alert_per_actor_per_day(sqlite_engine, backdated_audit_event):
_seed_agent_behavior_scenario(sqlite_engine, backdated_audit_event)
repo = RiskRepository(engine=sqlite_engine)
core = CoreReadOnlyRepository(engine=sqlite_engine)
r1 = scan_and_alert(core_ro=core, risk_repo=repo)
assert len(r1["created"]) == 1
assert len(r1["appended"]) == 0
alert_id = r1["created"][0]["alert_id"]
# 同日二次扫描:并入不新建
r2 = scan_and_alert(core_ro=core, risk_repo=repo)
assert len(r2["created"]) == 0
assert len(r2["appended"]) == 1
alert = repo.get_alert(alert_id)
assert alert["payload"]["alert_subtype"] == ["agent_behavior"]
assert alert["payload"]["actor_id"] == "STAFF-A"
assert alert["payload"]["subtypes_hit"] == ["A"]
def test_payload_actor_id_points_to_agent(sqlite_engine, backdated_audit_event):
_seed_agent_behavior_scenario(
sqlite_engine, backdated_audit_event, actor_id="STAFF-AGENT-1", customer_id="CUST-9"
)
repo = RiskRepository(engine=sqlite_engine)
core = CoreReadOnlyRepository(engine=sqlite_engine)
r = scan_and_alert(core_ro=core, risk_repo=repo)
alert = repo.get_alert(r["created"][0]["alert_id"])
assert alert["payload"]["actor_id"] == "STAFF-AGENT-1"
assert alert["customer_id"] == "CUST-9" # 涉及客户众数(仅一个)
assert alert["risk_score"] == 75
assert alert["triggered_rules"] == ["RISK-008"]
# ---------- query_agent_behavior Tool ----------
def test_query_agent_behavior_tool_filters_and_masks(sqlite_engine, backdated_audit_event):
_seed_agent_behavior_scenario(
sqlite_engine, backdated_audit_event, actor_id="STAFF-Q", customer_id="CUST-7"
)
repo = RiskRepository(engine=sqlite_engine)
core = CoreReadOnlyRepository(engine=sqlite_engine)
scan_and_alert(core_ro=core, risk_repo=repo)
# 不带 agent_id:返回该单
out_all = query_agent_behavior("CUST-7", core_ro=core, risk_repo=repo)
assert out_all["count"] == 1
assert out_all["items"][0]["actor_id"] == "STAFF-Q"
assert out_all["items"][0]["customers"][0]["customer_id"] == "CUST-7"
# 错误 agent_id:过滤为空
out_none = query_agent_behavior("CUST-7", core_ro=core, risk_repo=repo, agent_id="STAFF-OTHER")
assert out_none["count"] == 0
# 正确 agent_id:命中
out_hit = query_agent_behavior("CUST-7", core_ro=core, risk_repo=repo, agent_id="STAFF-Q")
assert out_hit["count"] == 1