代理人异常行为链识别(RISK-008)落地: - 新增 agent_behavior_service:三条件证据聚合(A 诱导调仓/B AUTH_403_SCOPE 越权试探/C AUTH_403_NOT_OWNER|NOT_ASSIGNED 越权查询),按代理人维度独立出 pattern 单,payload.actor_id 指向代理人,审计仅 INSERT event_type=agent_behavior_detected。 - risk_repository 新增 list_audit_events / find_agent_behavior_alert / merge_agent_behavior_payload(同日同代理人一张单,证据并集)。 - trade_gateway.submit_trade 补 actor_id 透传(代理人发起交易归属发起人,缺省 SYSTEM);simulate 路由传入 auth.actor_id。 - chat_tools 新增 query_agent_behavior 只读 Tool(agent_id 过滤 + 客户脱敏),tool_service 补意图词与摘要。 - scripts/cron/agent_behavior_scan.py 定时扫描脚本。 - 修复 append_alert_event 序列化缺 default=str(C6 evidence 含 datetime 字段)。 - 单测 12 例(_count_induce 边界 / 三条件 / 出单去重 / payload 归属 / Tool 过滤脱敏)。 全量 pytest 482 passed 0 failed(470 基线 + 12 C6)。
255 lines
11 KiB
Python
255 lines
11 KiB
Python
"""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
|