New router intelligence modules (26 files): alert_ingest/store, audit_store, architecture_pressure, backlog_generator/store, cost_analyzer, data_governance, dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment, platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files), signature_state_store, sofiia_auto_router, tool_governance New services: - sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static - memory-service: integration_endpoints, integrations, voice_endpoints, static UI - aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents) - sofiia-supervisor: new supervisor service - aistalk-bridge-lite: Telegram bridge lite - calendar-service: CalDAV calendar service with reminders - mlx-stt-service / mlx-tts-service: Apple Silicon speech services - binance-bot-monitor: market monitor service - node-worker: STT/TTS memory providers New tools (9): agent_email, browser_tool, contract_tool, observability_tool, oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus, farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine, session_context, style_adapter, telemetry) Tests: 85+ test files for all new modules Made-with: Cursor
389 lines
14 KiB
Python
389 lines
14 KiB
Python
"""
|
||
Unit-тести для telemetry.py (AGX_STEPAN_METRIC tag) — v2.7.2.
|
||
|
||
Перевіряє:
|
||
1. tlog() форматує рядок з тегом AGX_STEPAN_METRIC
|
||
2. tlog() коректно серіалізує типи (bool, int, float, list, dict)
|
||
3. depth_classifier — логи depth=light/deep мають тег
|
||
4. memory_manager.push_recent_topic — topics_push=true/false мають тег
|
||
5. Безпека: tlog() не кидає виняток якщо value некоректний
|
||
6. PII-safe: user_id/chat_id анонімізуються у tlog() (формат h:xxxxxxxxxx)
|
||
7. anonymize_id() — коректність, стабільність, edge cases
|
||
"""
|
||
|
||
import logging
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
root = Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(root))
|
||
sys.path.insert(0, str(root / 'packages' / 'agromatrix-tools'))
|
||
|
||
from crews.agromatrix_crew.telemetry import tlog, TELEMETRY_TAG, _fmt_value, anonymize_id
|
||
from crews.agromatrix_crew.depth_classifier import classify_depth
|
||
from crews.agromatrix_crew.memory_manager import (
|
||
_default_user_profile,
|
||
push_recent_topic,
|
||
)
|
||
|
||
|
||
# ─── tlog() unit ─────────────────────────────────────────────────────────────
|
||
|
||
class _CaptureHandler(logging.Handler):
|
||
"""Ловить LogRecord-и для перевірки."""
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.records: list[logging.LogRecord] = []
|
||
|
||
def emit(self, record: logging.LogRecord) -> None:
|
||
self.records.append(record)
|
||
|
||
@property
|
||
def messages(self) -> list[str]:
|
||
return [r.getMessage() for r in self.records]
|
||
|
||
|
||
def _capture_logger(name: str) -> tuple[logging.Logger, _CaptureHandler]:
|
||
lg = logging.getLogger(name)
|
||
lg.setLevel(logging.DEBUG)
|
||
h = _CaptureHandler()
|
||
lg.addHandler(h)
|
||
return lg, h
|
||
|
||
|
||
def test_tlog_contains_tag():
|
||
lg, h = _capture_logger("test_tag")
|
||
tlog(lg, "depth", depth="light", reason="greeting")
|
||
assert any(TELEMETRY_TAG in m for m in h.messages), \
|
||
f"Expected {TELEMETRY_TAG!r} in log, got: {h.messages}"
|
||
|
||
|
||
def test_tlog_message_format():
|
||
lg, h = _capture_logger("test_fmt")
|
||
tlog(lg, "crew_launch", launched=True, depth="deep")
|
||
msg = h.messages[-1]
|
||
assert msg.startswith(TELEMETRY_TAG)
|
||
assert "crew_launch" in msg
|
||
assert "launched=true" in msg
|
||
assert "depth=deep" in msg
|
||
|
||
|
||
def test_tlog_bool_lowercase():
|
||
lg, h = _capture_logger("test_bool")
|
||
tlog(lg, "test_event", flag_a=True, flag_b=False)
|
||
msg = h.messages[-1]
|
||
assert "flag_a=true" in msg
|
||
assert "flag_b=false" in msg
|
||
|
||
|
||
def test_tlog_list_joined():
|
||
lg, h = _capture_logger("test_list")
|
||
tlog(lg, "agents", agents=["ops", "iot", "platform"])
|
||
msg = h.messages[-1]
|
||
assert "ops,iot,platform" in msg
|
||
|
||
|
||
def test_tlog_dict_compact_json():
|
||
lg, h = _capture_logger("test_dict")
|
||
tlog(lg, "event", meta={"key": "val"})
|
||
msg = h.messages[-1]
|
||
assert '"key"' in msg or "key" in msg
|
||
|
||
|
||
def test_tlog_float_formatted():
|
||
lg, h = _capture_logger("test_float")
|
||
tlog(lg, "confidence", score=0.75)
|
||
msg = h.messages[-1]
|
||
assert "score=0.75" in msg
|
||
|
||
|
||
def test_tlog_no_kv():
|
||
lg, h = _capture_logger("test_nokv")
|
||
tlog(lg, "simple_event")
|
||
msg = h.messages[-1]
|
||
assert TELEMETRY_TAG in msg
|
||
assert "simple_event" in msg
|
||
|
||
|
||
def test_tlog_safe_on_bad_value():
|
||
"""tlog() не має кидати виняток навіть при некоректному value."""
|
||
lg, h = _capture_logger("test_safe")
|
||
|
||
class _Bad:
|
||
def __str__(self):
|
||
raise RuntimeError("bad")
|
||
|
||
# Should NOT raise
|
||
tlog(lg, "event", bad=_Bad())
|
||
# At least one message logged (fallback)
|
||
assert len(h.messages) >= 1
|
||
|
||
|
||
# ─── _fmt_value unit ──────────────────────────────────────────────────────────
|
||
|
||
def test_fmt_bool_true():
|
||
assert _fmt_value(True) == "true"
|
||
|
||
|
||
def test_fmt_bool_false():
|
||
assert _fmt_value(False) == "false"
|
||
|
||
|
||
def test_fmt_int():
|
||
assert _fmt_value(42) == "42"
|
||
|
||
|
||
def test_fmt_float():
|
||
assert _fmt_value(3.14) == "3.14"
|
||
|
||
|
||
def test_fmt_list():
|
||
assert _fmt_value(["a", "b", "c"]) == "a,b,c"
|
||
|
||
|
||
def test_fmt_str():
|
||
assert _fmt_value("hello") == "hello"
|
||
|
||
|
||
# ─── depth_classifier logs tagged ────────────────────────────────────────────
|
||
|
||
def test_depth_classifier_greeting_logs_tagged():
|
||
"""classify_depth("привіт") має emitнути рядок з TELEMETRY_TAG."""
|
||
dc_logger = logging.getLogger("crews.agromatrix_crew.depth_classifier")
|
||
dc_logger.setLevel(logging.DEBUG)
|
||
h = _CaptureHandler()
|
||
dc_logger.addHandler(h)
|
||
try:
|
||
classify_depth("привіт")
|
||
assert any(TELEMETRY_TAG in m for m in h.messages), \
|
||
f"No tagged log for greeting. Messages: {h.messages}"
|
||
finally:
|
||
dc_logger.removeHandler(h)
|
||
|
||
|
||
def test_depth_classifier_deep_logs_tagged():
|
||
"""classify_depth("зроби план на тиждень") → тег присутній."""
|
||
dc_logger = logging.getLogger("crews.agromatrix_crew.depth_classifier")
|
||
dc_logger.setLevel(logging.DEBUG)
|
||
h = _CaptureHandler()
|
||
dc_logger.addHandler(h)
|
||
try:
|
||
classify_depth("зроби план на тиждень")
|
||
assert any(TELEMETRY_TAG in m for m in h.messages), \
|
||
f"No tagged log for deep. Messages: {h.messages}"
|
||
finally:
|
||
dc_logger.removeHandler(h)
|
||
|
||
|
||
def test_depth_classifier_followup_logs_tagged():
|
||
"""classify_depth("а на завтра?", last_topic=...) → тег присутній."""
|
||
dc_logger = logging.getLogger("crews.agromatrix_crew.depth_classifier")
|
||
dc_logger.setLevel(logging.DEBUG)
|
||
h = _CaptureHandler()
|
||
dc_logger.addHandler(h)
|
||
try:
|
||
classify_depth("а на завтра?", last_topic="plan_day")
|
||
assert any(TELEMETRY_TAG in m for m in h.messages), \
|
||
f"No tagged log for followup. Messages: {h.messages}"
|
||
finally:
|
||
dc_logger.removeHandler(h)
|
||
|
||
|
||
# ─── memory_manager.push_recent_topic logs tagged ────────────────────────────
|
||
|
||
def test_push_recent_topic_push_tagged():
|
||
"""push_recent_topic → topics_push лог має тег."""
|
||
mm_logger = logging.getLogger("crews.agromatrix_crew.memory_manager")
|
||
mm_logger.setLevel(logging.DEBUG)
|
||
h = _CaptureHandler()
|
||
mm_logger.addHandler(h)
|
||
try:
|
||
profile = _default_user_profile("u_tag_push")
|
||
push_recent_topic(profile, "plan_day", "план на завтра")
|
||
tagged = [m for m in h.messages if TELEMETRY_TAG in m]
|
||
assert tagged, f"No tagged log for topics_push. Messages: {h.messages}"
|
||
assert any("topics_push" in m for m in tagged)
|
||
assert any("pushed=true" in m for m in tagged)
|
||
finally:
|
||
mm_logger.removeHandler(h)
|
||
|
||
|
||
def test_push_recent_topic_dedup_tagged():
|
||
"""push_recent_topic dedup → topics_push=false лог має тег."""
|
||
mm_logger = logging.getLogger("crews.agromatrix_crew.memory_manager")
|
||
mm_logger.setLevel(logging.DEBUG)
|
||
h = _CaptureHandler()
|
||
mm_logger.addHandler(h)
|
||
try:
|
||
profile = _default_user_profile("u_tag_dedup")
|
||
push_recent_topic(profile, "plan_day", "план на завтра")
|
||
h.records.clear()
|
||
push_recent_topic(profile, "plan_day", "план на завтра") # dedup
|
||
tagged = [m for m in h.messages if TELEMETRY_TAG in m]
|
||
assert tagged, f"No tagged log for dedup. Messages: {h.messages}"
|
||
assert any("pushed=false" in m for m in tagged)
|
||
finally:
|
||
mm_logger.removeHandler(h)
|
||
|
||
|
||
# ─── Log level passthrough ────────────────────────────────────────────────────
|
||
|
||
def test_tlog_default_level_is_info():
|
||
"""tlog без level= використовує INFO."""
|
||
lg, h = _capture_logger("test_level")
|
||
tlog(lg, "event")
|
||
assert h.records[-1].levelno == logging.INFO
|
||
|
||
|
||
def test_tlog_warning_level():
|
||
"""tlog з level=logging.WARNING записує WARNING."""
|
||
lg, h = _capture_logger("test_warn_level")
|
||
import logging as _logging
|
||
tlog(lg, "fallback_event", level=_logging.WARNING, reason="timeout")
|
||
assert h.records[-1].levelno == _logging.WARNING
|
||
|
||
|
||
# ─── anonymize_id unit ────────────────────────────────────────────────────────
|
||
|
||
def test_anonymize_none_returns_none():
|
||
assert anonymize_id(None) is None
|
||
|
||
|
||
def test_anonymize_empty_returns_empty():
|
||
assert anonymize_id("") == ""
|
||
|
||
|
||
def test_anonymize_format_h_prefix():
|
||
result = anonymize_id("123456789")
|
||
assert result is not None
|
||
assert result.startswith("h:")
|
||
assert len(result) == 12 # "h:" + 10 hex chars
|
||
|
||
|
||
def test_anonymize_hex_chars_only():
|
||
"""Хеш частина містить тільки hex символи."""
|
||
import re
|
||
result = anonymize_id("some_user_id")
|
||
assert result is not None
|
||
hash_part = result[2:] # skip "h:"
|
||
assert re.fullmatch(r'[0-9a-f]{10}', hash_part), \
|
||
f"Expected 10 lowercase hex chars, got: {hash_part!r}"
|
||
|
||
|
||
def test_anonymize_stable_same_input():
|
||
"""Той самий input → той самий псевдонім (детерміновано)."""
|
||
a = anonymize_id("user_42")
|
||
b = anonymize_id("user_42")
|
||
assert a == b
|
||
|
||
|
||
def test_anonymize_different_inputs_different_hashes():
|
||
"""Різні inputs → різні псевдоніми (з високою ймовірністю)."""
|
||
a = anonymize_id("user_1")
|
||
b = anonymize_id("user_2")
|
||
assert a != b
|
||
|
||
|
||
def test_anonymize_int_via_str():
|
||
"""Типовий user_id як число (stringified)."""
|
||
result = anonymize_id("987654321")
|
||
assert result is not None
|
||
assert result.startswith("h:")
|
||
assert len(result) == 12
|
||
|
||
|
||
def test_anonymize_telegram_negative_chat_id():
|
||
"""Telegram chat_id може бути від'ємним числом."""
|
||
result = anonymize_id("-1001234567890")
|
||
assert result is not None
|
||
assert result.startswith("h:")
|
||
assert len(result) == 12
|
||
|
||
|
||
# ─── tlog PII-safe behavior ───────────────────────────────────────────────────
|
||
|
||
def test_tlog_user_id_is_anonymized():
|
||
"""user_id=<raw> → у лог-рядку з'являється h:... а не сире значення."""
|
||
lg, h = _capture_logger("test_pii_uid")
|
||
raw_user_id = "987654321"
|
||
tlog(lg, "memory_save", user_id=raw_user_id, ok=True)
|
||
msg = h.messages[-1]
|
||
# Сирого id не має бути у рядку
|
||
assert raw_user_id not in msg, \
|
||
f"Raw user_id found in log: {msg!r}"
|
||
# Замість нього має бути анонімізований псевдонім
|
||
assert "user_id=h:" in msg, f"Expected 'user_id=h:' in: {msg!r}"
|
||
|
||
|
||
def test_tlog_chat_id_is_anonymized():
|
||
"""chat_id → анонімізується автоматично."""
|
||
lg, h = _capture_logger("test_pii_cid")
|
||
raw_chat_id = "-1001234567890"
|
||
tlog(lg, "memory_save", entity="FarmProfile", chat_id=raw_chat_id, ok=True)
|
||
msg = h.messages[-1]
|
||
assert raw_chat_id not in msg, \
|
||
f"Raw chat_id found in log: {msg!r}"
|
||
assert "chat_id=h:" in msg, f"Expected 'chat_id=h:' in: {msg!r}"
|
||
|
||
|
||
def test_tlog_non_pii_key_not_anonymized():
|
||
"""Звичайні ключі (не user_id/chat_id) не анонімізуються."""
|
||
lg, h = _capture_logger("test_nonpii")
|
||
tlog(lg, "depth", depth="light", reason="greeting", words=3)
|
||
msg = h.messages[-1]
|
||
assert "depth=light" in msg
|
||
assert "reason=greeting" in msg
|
||
assert "words=3" in msg
|
||
|
||
|
||
def test_tlog_pii_anonymize_format_stable():
|
||
"""Один і той самий user_id → той самий псевдонім у різних tlog викликах."""
|
||
lg, h = _capture_logger("test_pii_stable")
|
||
uid = "user_test_stable"
|
||
tlog(lg, "event_a", user_id=uid)
|
||
tlog(lg, "event_b", user_id=uid)
|
||
# Витягаємо псевдоніми
|
||
anon_a = [p for p in h.messages[0].split() if p.startswith("user_id=")][0]
|
||
anon_b = [p for p in h.messages[1].split() if p.startswith("user_id=")][0]
|
||
assert anon_a == anon_b, \
|
||
f"Unstable anonymization: {anon_a!r} vs {anon_b!r}"
|
||
|
||
|
||
def test_tlog_custom_pii_keys():
|
||
"""Можна передати власний set pii_keys для додаткових полів."""
|
||
lg, h = _capture_logger("test_custom_pii")
|
||
tlog(lg, "custom", pii_keys=frozenset({"session_id"}), session_id="abc123xyz")
|
||
msg = h.messages[-1]
|
||
assert "abc123xyz" not in msg
|
||
assert "session_id=h:" in msg
|
||
|
||
|
||
def test_tlog_none_user_id_is_null():
|
||
"""None user_id → логується як null (не помилка)."""
|
||
lg, h = _capture_logger("test_pii_none")
|
||
tlog(lg, "event", user_id=None)
|
||
msg = h.messages[-1]
|
||
assert "user_id=null" in msg
|
||
|
||
|
||
def test_memory_manager_logs_no_raw_user_id():
|
||
"""
|
||
Перевіряємо що memory_manager tlog логи не містять сирого user_id.
|
||
Симулюємо через push_recent_topic (логи topics_push).
|
||
"""
|
||
mm_logger = logging.getLogger("crews.agromatrix_crew.memory_manager")
|
||
mm_logger.setLevel(logging.DEBUG)
|
||
h = _CaptureHandler()
|
||
mm_logger.addHandler(h)
|
||
try:
|
||
from crews.agromatrix_crew.memory_manager import _default_user_profile, push_recent_topic
|
||
raw_uid = "99887766554"
|
||
profile = _default_user_profile(raw_uid)
|
||
push_recent_topic(profile, "plan_day", "план на завтра")
|
||
for msg in h.messages:
|
||
if TELEMETRY_TAG in msg:
|
||
assert raw_uid not in msg, \
|
||
f"Raw user_id found in telemetry log: {msg!r}"
|
||
finally:
|
||
mm_logger.removeHandler(h)
|