feat(telegram): add traceable SRE group chat routing

This commit is contained in:
Your Name
2026-07-22 12:38:34 +08:00
parent df651541db
commit 85853402ac
5 changed files with 695 additions and 45 deletions

View File

@@ -155,9 +155,9 @@ def test_loader_returns_fixed_architecture_provider_order_and_agent99_bridge() -
"active_or_completed_commitments": 68,
"by_status": {
"analysis_or_governance_complete": 6,
"source_implemented_runtime_pending": 26,
"source_implemented_runtime_pending": 27,
"in_progress": 32,
"not_started_or_no_current_evidence": 4,
"not_started_or_no_current_evidence": 3,
"superseded": 2,
},
"product_runtime_closed_commitments": 0,
@@ -167,6 +167,9 @@ def test_loader_returns_fixed_architecture_provider_order_and_agent99_bridge() -
}
assert commitments["AIA-CONV-012"]["title"].startswith("Claude API")
assert commitments["AIA-CONV-013"]["title"].startswith("Gemini API")
assert commitments["AIA-CONV-029"]["status"] == (
"source_implemented_runtime_pending"
)
assert "Host112" in commitments["AIA-CONV-049"]["title"]
assert commitments["AIA-CONV-049"]["status"] == (
"source_implemented_runtime_pending"

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
@@ -69,6 +70,91 @@ def test_explicit_dialogue_about_machine_payload_still_requests_ai(
)
@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,
@@ -160,6 +246,29 @@ async def test_nemotron_group_mention_uses_structured_provider_status_router(
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 現在是硬鎖定狀態嗎?",
@@ -183,6 +292,11 @@ async def test_nemotron_group_mention_uses_structured_provider_status_router(
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,
@@ -190,6 +304,117 @@ async def test_nemotron_group_mention_uses_structured_provider_status_router(
}
@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,