446 lines
15 KiB
Python
446 lines
15 KiB
Python
from __future__ import annotations
|
||
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock
|
||
|
||
import pytest
|
||
|
||
from src.services import ai_rate_limiter as limiter_module
|
||
from src.services import chat_manager as chat_module
|
||
from src.services import telegram_gateway as gateway_module
|
||
from src.services.chat_manager import ChatManager
|
||
from src.services.telegram_gateway import (
|
||
TelegramGateway,
|
||
_sre_group_message_requires_ai_response,
|
||
)
|
||
|
||
|
||
class _ReceiptBackedCostLimiter:
|
||
async def get_usage_stats(self, provider: str) -> dict:
|
||
return {
|
||
"provider": provider,
|
||
"readback_status": "verified",
|
||
"total_cost_usd": {
|
||
"current": 0.011802 if provider == "claude" else 0.000297,
|
||
},
|
||
"accounting": {
|
||
"complete": True,
|
||
"status": "receipt_backed",
|
||
"degraded": False,
|
||
},
|
||
}
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"message",
|
||
[
|
||
r"new-item -itemtype directory -force -path \wooo\agent99\tmp",
|
||
r"set-content -encoding ascii -path \wooo\agent99\tmp\fingerprint.txt",
|
||
r"add-content -encoding ascii -path \wooo\agent99\tmp\events.log",
|
||
"kubectl get pods -n awoooi-prod",
|
||
"2026-07-19T00:26:31+08:00 ERROR provider timeout",
|
||
'{"event":"agent99_runtime_receipt","status":"pending"}',
|
||
"```powershell\nRestart-Service alertmanager\n```",
|
||
],
|
||
)
|
||
def test_unaddressed_machine_payload_does_not_request_ai(message: str) -> None:
|
||
assert _sre_group_message_requires_ai_response(message) is False
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"message,reply_to_message",
|
||
[
|
||
("kubectl get pods 為什麼失敗?", None),
|
||
("@OpenClawAwoooI_Bot kubectl get pods 失敗", None),
|
||
("set-content 這行是什麼意思", None),
|
||
("目前有哪些異常服務", None),
|
||
(
|
||
"Restart-Service alertmanager",
|
||
{"from": {"is_bot": True, "username": "OpenClawAwoooI_Bot"}},
|
||
),
|
||
],
|
||
)
|
||
def test_explicit_dialogue_about_machine_payload_still_requests_ai(
|
||
message: str,
|
||
reply_to_message: dict | None,
|
||
) -> None:
|
||
assert (
|
||
_sre_group_message_requires_ai_response(message, reply_to_message)
|
||
is True
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"message,expected",
|
||
[
|
||
("Gemini provider 現在可用嗎?", "provider_status"),
|
||
("請重啟 alertmanager", "controlled_action_request"),
|
||
("這個告警的根因是什麼?", "knowledge_query"),
|
||
("目前有哪些異常服務", "incident_status"),
|
||
("幫我整理今天的 SRE 重點", "general_sre_question"),
|
||
],
|
||
)
|
||
def test_group_intent_is_classified_without_model_call(
|
||
message: str,
|
||
expected: str,
|
||
) -> None:
|
||
assert ChatManager.classify_intent(message) == expected
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_group_rag_context_is_bounded_and_private_ip_redacted(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
class _KnowledgeService:
|
||
semantic_search = AsyncMock(
|
||
return_value=[
|
||
(
|
||
SimpleNamespace(
|
||
id="km-alertmanager-001",
|
||
title="Alertmanager runbook",
|
||
content="檢查 192.168.0.110 的 webhook 狀態。" * 80,
|
||
),
|
||
0.91,
|
||
)
|
||
]
|
||
)
|
||
|
||
from src.services import knowledge_service as knowledge_module
|
||
|
||
knowledge_service = _KnowledgeService()
|
||
monkeypatch.setattr(
|
||
knowledge_module,
|
||
"get_knowledge_service",
|
||
lambda: knowledge_service,
|
||
)
|
||
|
||
receipt = await ChatManager().get_rag_context(
|
||
"Alertmanager 192.168.0.110 為什麼異常?"
|
||
)
|
||
|
||
assert receipt["status"] == "ready"
|
||
assert receipt["source_refs"] == ["km-alertmanager-001"]
|
||
assert len(receipt["prompt_context"]) <= 1800
|
||
assert "192.168.0.110" not in receipt["prompt_context"]
|
||
assert "[PRIVATE_IP_REDACTED]" in receipt["prompt_context"]
|
||
query = knowledge_service.semantic_search.await_args.args[0]
|
||
assert "192.168.0.110" not in query
|
||
assert "[PRIVATE_IP_REDACTED]" in query
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_group_route_rejects_unverified_inbound_receipt_before_provider(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
def _provider_call_forbidden():
|
||
raise AssertionError("unverified ingress must not call provider")
|
||
|
||
monkeypatch.setattr(chat_module, "get_ai_executor", _provider_call_forbidden)
|
||
result = await ChatManager().route_chat(
|
||
role="openclaw",
|
||
system_prompt="bounded",
|
||
user_message="目前有哪些異常?",
|
||
route_context={
|
||
"source_channel": "telegram_sre_war_room",
|
||
"canonical_chat_identity": {
|
||
"status": "verified",
|
||
"room": "awoooi_sre_war_room",
|
||
},
|
||
"intent": "incident_status",
|
||
},
|
||
)
|
||
|
||
assert result.success is False
|
||
assert result.fallback_reason == "telegram_identity_receipt_unverified"
|
||
assert result.provider == "none"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_group_machine_payload_stops_before_context_or_provider(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.setattr(gateway_module.settings, "HERMES_NL_ENABLED", False)
|
||
|
||
def _chat_manager_forbidden():
|
||
raise AssertionError("machine payload must not build context or call AI")
|
||
|
||
monkeypatch.setattr(chat_module, "get_chat_manager", _chat_manager_forbidden)
|
||
gateway = object.__new__(TelegramGateway)
|
||
|
||
await gateway._handle_group_message(
|
||
r"new-item -itemtype directory -force -path \wooo\agent99\tmp",
|
||
user_id=99,
|
||
username="operator",
|
||
chat_id=-100123,
|
||
message_id=455,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_nemotron_group_mention_uses_structured_provider_status_router(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Telegram mention must not invent a Gemini lock or call a provider."""
|
||
|
||
readback = {
|
||
"schema_version": "sre_chat_provider_runtime_readback_v1",
|
||
"policy_order": [
|
||
"ollama_gcp_a",
|
||
"ollama_gcp_b",
|
||
"ollama_local",
|
||
"claude",
|
||
"gemini",
|
||
],
|
||
"ollama_health": {
|
||
"ollama_gcp_a": "offline",
|
||
"ollama_gcp_b": "healthy",
|
||
"ollama_local": "not_checked",
|
||
},
|
||
"paid": {
|
||
"claude": {
|
||
"configured": True,
|
||
"authenticated": True,
|
||
"authentication_verified": True,
|
||
"verification_status": "authenticated_verified_by_generation",
|
||
"execution_mode": "canary",
|
||
"canary_percent": 5,
|
||
"route_lease_state": "inactive_disabled_after_canary",
|
||
"runtime_state": (
|
||
"configured_authenticated_canary_waiting_for_run_owned_lease"
|
||
),
|
||
},
|
||
"gemini": {
|
||
"configured": True,
|
||
"authenticated": False,
|
||
"authentication_verified": False,
|
||
"verification_status": "authentication_rejected",
|
||
"execution_mode": "canary",
|
||
"canary_percent": 5,
|
||
"route_lease_state": "inactive_disabled_after_canary",
|
||
"runtime_state": "configured_canary_authentication_rejected",
|
||
},
|
||
},
|
||
"provider_route_switch_performed": False,
|
||
"infrastructure_remediation_write_allowed": False,
|
||
"cross_domain_execution_allowed": False,
|
||
}
|
||
manager = ChatManager()
|
||
monkeypatch.setattr(
|
||
manager,
|
||
"get_system_context",
|
||
AsyncMock(return_value="bounded system context"),
|
||
)
|
||
monkeypatch.setattr(
|
||
manager,
|
||
"get_provider_runtime_readback",
|
||
AsyncMock(return_value=readback),
|
||
)
|
||
monkeypatch.setattr(chat_module, "get_chat_manager", lambda: manager)
|
||
|
||
def _provider_call_forbidden():
|
||
raise AssertionError("provider executor must not run for a status readback")
|
||
|
||
monkeypatch.setattr(chat_module, "get_ai_executor", _provider_call_forbidden)
|
||
monkeypatch.setattr(gateway_module.settings, "HERMES_NL_ENABLED", False)
|
||
|
||
gateway = object.__new__(TelegramGateway)
|
||
send_as_nemotron = AsyncMock(return_value={"ok": True})
|
||
monkeypatch.setattr(gateway, "send_as_nemotron", send_as_nemotron)
|
||
monkeypatch.setattr(
|
||
gateway,
|
||
"mirror_sre_group_message_received",
|
||
AsyncMock(
|
||
return_value={
|
||
"event_id": "event-456",
|
||
"trace_id": "telegram-message:event-456",
|
||
"run_id": "run-456",
|
||
"work_item_id": "AIA-CONV-029",
|
||
}
|
||
),
|
||
)
|
||
monkeypatch.setattr(
|
||
manager,
|
||
"get_rag_context",
|
||
AsyncMock(
|
||
return_value={
|
||
"status": "skipped_deterministic_readback",
|
||
"source_refs": [],
|
||
"prompt_context": "",
|
||
}
|
||
),
|
||
)
|
||
|
||
await gateway._handle_group_message(
|
||
"@NemoTronAwoooI_Bot Gemini API 現在是硬鎖定狀態嗎?",
|
||
user_id=99,
|
||
username="operator",
|
||
chat_id=-100123,
|
||
message_id=456,
|
||
)
|
||
|
||
send_as_nemotron.assert_awaited_once()
|
||
outbound = send_as_nemotron.await_args.kwargs["text"]
|
||
assert "硬鎖" not in outbound
|
||
assert "locked" not in outbound.casefold()
|
||
assert "無法正常回應" not in outbound
|
||
assert "Claude:configured=true|authenticated=true" in outbound
|
||
assert "Gemini:configured=true|authenticated=false" in outbound
|
||
assert "verification=authentication_rejected" in outbound
|
||
assert "canary=canary 5%" in outbound
|
||
assert "route_lease=inactive_disabled_after_canary" in outbound
|
||
assert "provider=runtime_readback" in outbound
|
||
assert "model=deterministic_policy" in outbound
|
||
assert "fallback=provider_status_request_no_generation" in outbound
|
||
assert "cost_receipt=none" in outbound
|
||
assert "trace=telegram-message:event-456" in outbound
|
||
assert "intent=provider_status" in outbound
|
||
assert "rag=skipped_deterministic_readback" in outbound
|
||
assert "identity=verified" in outbound
|
||
assert "policy=advisory_no_runtime_mutation" in outbound
|
||
assert send_as_nemotron.await_args.kwargs == {
|
||
"text": outbound,
|
||
"reply_to_message_id": 456,
|
||
"inbound_chat_id": -100123,
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_group_message_fails_closed_before_rag_or_provider_when_mirror_fails(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.setattr(gateway_module.settings, "HERMES_NL_ENABLED", False)
|
||
manager = ChatManager()
|
||
monkeypatch.setattr(chat_module, "get_chat_manager", lambda: manager)
|
||
monkeypatch.setattr(
|
||
manager,
|
||
"get_system_context",
|
||
AsyncMock(side_effect=AssertionError("context must not be read")),
|
||
)
|
||
monkeypatch.setattr(
|
||
manager,
|
||
"get_rag_context",
|
||
AsyncMock(side_effect=AssertionError("RAG must not be read")),
|
||
)
|
||
|
||
gateway = object.__new__(TelegramGateway)
|
||
monkeypatch.setattr(
|
||
gateway,
|
||
"mirror_sre_group_message_received",
|
||
AsyncMock(return_value=None),
|
||
)
|
||
send_as_openclaw = AsyncMock(return_value={"ok": True})
|
||
monkeypatch.setattr(gateway, "send_as_openclaw", send_as_openclaw)
|
||
|
||
await gateway._handle_group_message(
|
||
"@OpenClawAwoooI_Bot 目前告警狀態?",
|
||
user_id=99,
|
||
username="operator",
|
||
chat_id=-100123,
|
||
message_id=457,
|
||
update_id=9001,
|
||
)
|
||
|
||
send_as_openclaw.assert_awaited_once()
|
||
assert "對話未送入 AI" in send_as_openclaw.await_args.kwargs["text"]
|
||
manager.get_rag_context.assert_not_awaited()
|
||
manager.get_system_context.assert_not_awaited()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_group_message_passes_one_rag_and_trace_context_to_provider(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.setattr(gateway_module.settings, "HERMES_NL_ENABLED", False)
|
||
manager = ChatManager()
|
||
monkeypatch.setattr(chat_module, "get_chat_manager", lambda: manager)
|
||
monkeypatch.setattr(
|
||
manager,
|
||
"get_system_context",
|
||
AsyncMock(return_value="bounded system context"),
|
||
)
|
||
monkeypatch.setattr(
|
||
manager,
|
||
"get_rag_context",
|
||
AsyncMock(
|
||
return_value={
|
||
"status": "ready",
|
||
"source_refs": ["km-incident-001"],
|
||
"prompt_context": "[km-incident-001] bounded evidence",
|
||
}
|
||
),
|
||
)
|
||
call_openclaw = AsyncMock(return_value="AI reply")
|
||
call_nemotron = AsyncMock(
|
||
side_effect=AssertionError("unmentioned message must use one provider route")
|
||
)
|
||
monkeypatch.setattr(manager, "_call_openclaw", call_openclaw)
|
||
monkeypatch.setattr(manager, "_call_nemotron", call_nemotron)
|
||
|
||
gateway = object.__new__(TelegramGateway)
|
||
mirror = AsyncMock(
|
||
return_value={
|
||
"event_id": "event-458",
|
||
"trace_id": "telegram-message:event-458",
|
||
"run_id": "run-458",
|
||
"work_item_id": "AIA-CONV-029",
|
||
}
|
||
)
|
||
monkeypatch.setattr(gateway, "mirror_sre_group_message_received", mirror)
|
||
send_as_openclaw = AsyncMock(return_value={"ok": True})
|
||
monkeypatch.setattr(gateway, "send_as_openclaw", send_as_openclaw)
|
||
|
||
await gateway._handle_group_message(
|
||
"這個告警的根因是什麼?",
|
||
user_id=99,
|
||
username="operator",
|
||
chat_id=-100123,
|
||
message_id=458,
|
||
update_id=9002,
|
||
)
|
||
|
||
manager.get_rag_context.assert_awaited_once()
|
||
call_openclaw.assert_awaited_once()
|
||
system_prompt, question = call_openclaw.await_args.args
|
||
route_context = call_openclaw.await_args.kwargs["route_context"]
|
||
assert question == "這個告警的根因是什麼?"
|
||
assert "KM/RAG 參考證據" in system_prompt
|
||
assert route_context["trace_id"] == "telegram-message:event-458"
|
||
assert route_context["intent"] == "knowledge_query"
|
||
assert route_context["rag_retrieval_receipt"] == {
|
||
"status": "ready",
|
||
"source_refs": ["km-incident-001"],
|
||
}
|
||
assert route_context["canonical_chat_identity"]["status"] == "verified"
|
||
assert route_context["executor_invocation_allowed"] is False
|
||
call_nemotron.assert_not_awaited()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_group_cost_uses_receipt_backed_accounting_not_legacy_keys(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
monkeypatch.setattr(
|
||
limiter_module,
|
||
"get_ai_rate_limiter",
|
||
lambda: _ReceiptBackedCostLimiter(),
|
||
)
|
||
gateway = object.__new__(TelegramGateway)
|
||
send_as_openclaw = AsyncMock(return_value={"ok": True})
|
||
monkeypatch.setattr(gateway, "send_as_openclaw", send_as_openclaw)
|
||
|
||
await gateway._handle_group_command(
|
||
"/cost",
|
||
-100123,
|
||
789,
|
||
full_text="/cost",
|
||
)
|
||
|
||
send_as_openclaw.assert_awaited_once()
|
||
outbound = send_as_openclaw.await_args.kwargs["text"]
|
||
assert "Claude: <b>$0.011802</b> | receipt=verified" in outbound
|
||
assert "Gemini: <b>$0.000297</b> | receipt=verified" in outbound
|
||
assert "合計: <b>$0.012099</b>" in outbound
|
||
assert "receipt-backed" in outbound
|
||
assert "Flash-Lite" not in outbound
|
||
assert "Haiku" not in outbound
|