diff --git a/docs/unified-daily-notification.md b/docs/unified-daily-notification.md index 9525b22e..1a198f54 100644 --- a/docs/unified-daily-notification.md +++ b/docs/unified-daily-notification.md @@ -176,11 +176,25 @@ links come from the existing run report; absent evidence stays explicitly unknown. This format change does not change execution, dependency pins or notification schedules and does not establish deployed adoption or delivery. -Incident attention messages retain allowlisted completed block causes from -`execution_blocked_reason` or `no_op_reason`; unknown provider text retains the -fixed incident classification. The pinned shared compact renderer receives the -report's existing `started_at` and `console_run_url`, just like newer renderers. -It validates the supplied HTTPS link and removes query strings/fragments. A -missing or unsafe source link remains unavailable; a monitor run or guessed -console route is never substituted for a business run. These display fields do -not change routing, incident identity, risk decisions or execution permission. +Incident attention messages retain structured reason codes from +`execution_blocked_reason` or `no_op_reason`, with local operator explanations +for known block classes. Unknown structured codes remain visible after shared +redaction and direct the operator to risk records; arbitrary provider prose +retains the fixed incident classification. A blocked cycle is displayed as a +protection block, while other abnormal classifications retain operational +uncertainty. Snapshot diagnostics do not authorize execution or clear a block. + +Account display uses the candidate producer's target-bound opaque identity, +masked by the shared renderer. Missing or invalid identity names the required +`DIGEST_OPAQUE_ACCOUNT_UID` / `IBKR_DIGEST_OPAQUE_ACCOUNT_UID` and/or +`DIGEST_TARGET_ID` / `IBKR_DIGEST_TARGET_ID` fields. Service/project names and +identities bound to a different runtime target are never substituted. + +The shared renderer receives the report's existing `started_at` and the first +valid explicit `console_run_url`, `actions_run_url`, `cloud_run_url` or +`strategy_cycle_url` from the report or execution evidence. Shared validation +requires HTTPS and removes query strings/fragments. An absent or unsafe source +link is displayed as unavailable (`RUN_LINK_MISSING`); an unrelated Actions run +or guessed console route is never substituted for a business run. These display +fields do not change routing, incident identity, risk decisions or execution +permission, and do not establish deployed adoption. diff --git a/notifications/unified_adapter.py b/notifications/unified_adapter.py index c99f1c00..b8da812f 100644 --- a/notifications/unified_adapter.py +++ b/notifications/unified_adapter.py @@ -479,25 +479,72 @@ def _cycle_attention_notification(kind, execution, report, diagnostics=""): os.environ.get("QSL_NOTIFY_LANG") or os.environ.get("NOTIFY_LANG")) profile = report.get("strategy_profile") or (report.get("runtime_target") or {}).get("strategy_profile") profile = profile if isinstance(profile, str) and profile in STRATEGY_CATALOG.definitions else "unknown" - # Only fixed producer codes may cross the private execution boundary. - known_blocks = {"empty_position_decision", "strategy_risk_rejected", - "account_brake_source_or_submit_unavailable", "account_brake_submission_not_confirmed"} - reason = next((value for key in ("execution_blocked_reason", "no_op_reason") - if isinstance(value := execution.get(key), str) and value in known_blocks), kind) + # Structured codes only; arbitrary provider prose stays inside diagnostics. + reason = kind + for key in ("execution_blocked_reason", "no_op_reason"): + value = execution.get(key) + if isinstance(value, str) and re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(?::[a-zA-Z0-9_]+)?", value) and len(value) <= 160: + reason = redact_notification_diagnostics(value) + break + blocked = kind == "blocked_run" decision = shared.AttentionDecision( - level=shared.AttentionLevel.ACTION, reason_codes=("operational_uncertain",), + level=shared.AttentionLevel.ACTION, + reason_codes=("new_risk_prohibited" if blocked else "operational_uncertain",), dd_ratio=None, should_notify=True, next_step_key="open_console_confirm") - arguments = dict(locale=locale, platform="ibkr", account_alias="unknown", + # Use the same target-bound opaque identity as the candidate producer. + # Never substitute a service/project name or another target's environment. + try: + identity, _ = with_run(report, _identity, report.get("runtime_target")) + except (ValueError, TypeError, AttributeError): + identity = {} + missing = [field for field in ("opaque_account_uid", "target_id") + if not isinstance(identity.get(field), str) or identity[field].strip().lower() in {"", "unknown", "none", "account", "账户"}] + alias = "unknown" if missing else identity["opaque_account_uid"] + arguments = dict(locale=locale, platform="ibkr", account_alias=alias, strategy_profile=profile, decision=decision, guard_reason_codes=(reason,)) - # The pinned compact renderer already supports these evidence fields and - # validates/strips URLs. Keep that shared boundary instead of dropping the - # link and appending a second run-time section in the compatibility path. + from quant_platform_kit.risk.attention_display import attention_details + run_url = None + for source in (report, execution): + for field in ("console_run_url", "actions_run_url", "cloud_run_url", "strategy_cycle_url"): + value = source.get(field) + if isinstance(value, str): + detail = attention_details(locale="en", daily_loss_context=None, occurred_at=None, run_url=value)[-1] + if detail.startswith("Run details: https://"): + run_url = value + break + if run_url: + break renderer = getattr(shared, "render_attention_notification", shared.render_attention_compact) - text = renderer(**arguments, occurred_at=report.get("started_at"), run_url=report.get("console_run_url")) + text = renderer(**arguments, occurred_at=report.get("started_at"), run_url=run_url) + # Extend the pinned renderer's vocabulary locally; do not change its guards. + from quant_platform_kit.common.operational_notification_localization import operational_notification_text + explanations = { + "blocked_run": ("保护规则阻断本次运行,请查看风控记录", "A protection rule blocked this run; review risk records"), + "empty_position_decision": ("空持仓决策受到保护规则限制,请查看风控记录", "Empty-position decision blocked by protection rules; review risk records"), + "strategy_risk_rejected": ("策略风控拒绝本次操作,请查看风控记录", "Strategy risk checks rejected this action; review risk records"), + "rejected": ("策略风控拒绝本次操作,请查看风控记录", "Strategy risk checks rejected this action; review risk records"), + "account_brake_source_or_submit_unavailable": ("账户保护数据或提交服务不可用,请查看风控记录", "Account protection source or submission service unavailable; review risk records"), + "account_brake_submission_not_confirmed": ("账户保护提交未确认,请查看风控记录", "Account protection submission unconfirmed; review risk records"), + } + explanation = explanations.get(reason.partition(":")[0]) + if explanation: + text = text.replace(f"{reason}: " + operational_notification_text(locale, "attention_reason_unknown"), + f"{reason}: " + explanation[0 if locale == "zh" else 1]) + text = text.replace(operational_notification_text(locale, "attention_reason_unknown"), + "未知原因码,请查看风控记录" if locale == "zh" else "Unknown reason code; review risk records") + if missing: + names = {"opaque_account_uid": "DIGEST_OPAQUE_ACCOUNT_UID/IBKR_DIGEST_OPAQUE_ACCOUNT_UID", "target_id": "DIGEST_TARGET_ID/IBKR_DIGEST_TARGET_ID"} + label = "账户未绑定(缺少或无效:" if locale == "zh" else "Account unbound (missing or invalid: " + label += ", ".join(names[field] for field in missing) + (")" if locale == "zh" else ")") + text = text.replace("账户未知" if locale == "zh" else "Account unknown", label, 1) + text = text.replace("未提供运行链接", "运行详情不可用(RUN_LINK_MISSING)").replace( + "Run link not provided", "Run details unavailable (RUN_LINK_MISSING)") # Carry only the caller's decision diagnostics, never its legacy dashboard/title. if diagnostics: label = "诊断" if locale == "zh" else "Diagnostics" text += f"\n{label}: {redact_notification_diagnostics(diagnostics)}" + text += ("\n快照决策不代表执行获准;本次运行仍需核对阻断或故障记录。" if locale == "zh" else + "\nA snapshot decision does not authorize execution; review this run's block or fault records.") text = redact_sensitive_text(text) return RenderedNotification(text, text) diff --git a/tests/test_attention_source_evidence.py b/tests/test_attention_source_evidence.py index e0ceeedf..93149790 100644 --- a/tests/test_attention_source_evidence.py +++ b/tests/test_attention_source_evidence.py @@ -20,7 +20,7 @@ def test_blocked_attention_preserves_known_source_cause(monkeypatch, field, code assert execution == original -@pytest.mark.parametrize("value", ["private account detail", "synthetic_private_code", None, [], {}]) +@pytest.mark.parametrize("value", ["private account detail", "private code with spaces", None, [], {}]) def test_unrecognized_source_reason_retains_fixed_fallback(monkeypatch, value): monkeypatch.setenv("NOTIFY_LANG", "en") body = adapter._cycle_attention_notification("blocked_run", {"no_op_reason": value}, {}).detailed_text diff --git a/tests/test_daily_etf_attention.py b/tests/test_daily_etf_attention.py new file mode 100644 index 00000000..77888650 --- /dev/null +++ b/tests/test_daily_etf_attention.py @@ -0,0 +1,99 @@ +"""Source-only presentation regressions; no notification delivery or broker calls.""" +from copy import deepcopy + +import pytest + +from notifications import unified_adapter as adapter + + +@pytest.fixture(autouse=True) +def isolated_identity(monkeypatch): + monkeypatch.setenv("QSL_NOTIFY_LANG", "zh") + for key in ("DIGEST_OPAQUE_ACCOUNT_UID", "IBKR_DIGEST_OPAQUE_ACCOUNT_UID", "DIGEST_TARGET_ID", "IBKR_DIGEST_TARGET_ID", "RUNTIME_TARGET_JSON", "DIGEST_TARGET_BINDINGS_JSON"): + monkeypatch.delenv(key, raising=False) + + +def render(execution=None, report=None, diagnostics=""): + return adapter._cycle_attention_notification("blocked_run", execution or {}, report or {}, diagnostics).detailed_text + + +@pytest.mark.parametrize("code,description", [ + ("blocked_run", "保护规则阻断"), + ("empty_position_decision", "空持仓决策"), + ("strategy_risk_rejected", "策略风控拒绝"), + ("account_brake_source_or_submit_unavailable", "账户保护数据或提交服务不可用"), + ("account_brake_submission_not_confirmed", "账户保护提交未确认"), + ("rejected:too_many_positions", "策略风控拒绝"), +]) +def test_known_block_explanation(code, description): + body = render({"execution_blocked_reason": code}) + assert code in body and description in body + assert "无法解释此原因码" not in body + + +def test_unknown_structured_code_remains_visible(): + body = render({"execution_blocked_reason": "fake_unknown_guard"}) + assert "fake_unknown_guard" in body and "查看风控记录" in body + + +def test_authoritative_bound_identity(monkeypatch): + monkeypatch.setenv("DIGEST_OPAQUE_ACCOUNT_UID", "fake-account-42") + monkeypatch.setenv("DIGEST_TARGET_ID", "fake-target") + body = render(report={"strategy_profile": "global_etf_rotation"}) + assert "••42" in body and "账户未知" not in body + assert "fake-account-42" not in body + + +def test_missing_identity_names_field(): + body = render() + assert "账户未绑定" in body and "DIGEST_OPAQUE_ACCOUNT_UID" in body + + +def test_continue_snapshot_does_not_override_block(): + execution = {"execution_status": "blocked", "snapshot_guard_decision": "proceed", "execution_blocked_reason": "empty_position_decision", "positions": []} + original = deepcopy(execution) + body = render(execution, diagnostics="无需调仓 · 快照决策=继续") + assert "operational_uncertain" not in body + assert "快照决策不代表执行获准" in body + assert adapter._kind(execution) == "blocked_run" and execution == original + + +@pytest.mark.parametrize("field", ["console_run_url", "actions_run_url", "cloud_run_url", "strategy_cycle_url"]) +def test_explicit_source_run_link(field): + body = render(report={field: "https://runs.example.invalid/fake-cycle?token=fake-secret#private"}) + assert "https://runs.example.invalid/fake-cycle" in body + assert "fake-secret" not in body and "#private" not in body + + +@pytest.mark.parametrize("url", [None, "javascript:fake", "https://user:pass@runs.example.invalid/fake"]) +def test_no_link_is_explicitly_unavailable(url): + body = render(report={"console_run_url": url, "run_id": "fake-run"}) + assert "运行详情不可用" in body + assert "https://" not in body + + +def test_unknown_code_and_diagnostics_redacted(): + body = render({"execution_blocked_reason": "rejected:U00000001"}, diagnostics="account_id=U00000001 token=fake-secret") + assert "U00000001" not in body and "fake-secret" not in body + assert "rejected:" in body + + +@pytest.mark.parametrize("invalid", ["unknown", "none", " "]) +def test_placeholder_identity_stays_unbound(monkeypatch, invalid): + monkeypatch.setenv("DIGEST_OPAQUE_ACCOUNT_UID", invalid) + monkeypatch.setenv("DIGEST_TARGET_ID", "fake-target") + assert "DIGEST_OPAQUE_ACCOUNT_UID" in render() + + +def test_other_targets_identity_is_never_displayed(monkeypatch): + import json + monkeypatch.setenv("DIGEST_OPAQUE_ACCOUNT_UID", "fake-other-99") + monkeypatch.setenv("DIGEST_TARGET_ID", "fake-other-target") + monkeypatch.setenv("RUNTIME_TARGET_JSON", json.dumps({"service_name": "fake-other", "strategy_profile": "global_etf_rotation", "account_scope": "fake-other"})) + body = render(report={"runtime_target": {"service_name": "fake-current", "strategy_profile": "global_etf_rotation", "account_scope": "fake-current"}}) + assert "账户未绑定" in body and "••99" not in body + + +def test_invalid_first_link_uses_later_explicit_evidence(): + body = render(report={"console_run_url": "javascript:fake", "actions_run_url": "https://runs.example.invalid/fake"}) + assert "https://runs.example.invalid/fake" in body and "RUN_LINK_MISSING" not in body