"""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