Files
microdao-daarion/tests/test_stepan_hardening.py
Apple 129e4ea1fc feat(platform): add new services, tools, tests and crews modules
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
2026-03-03 07:14:14 -08:00

439 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Tests for hardening fixes (v3.4 / v3.5):
- Fix 1: one-shot cache (skip extract if summary already exists)
- Fix 2: deterministic truncation (cut at \n boundary)
- Fix 3: numeric normalization in _parse_number (NBSP, comma separators, units)
- Fix D: active_doc_id set immediately on upload (doc_ctx field + run.py priority)
- Fix E: no "немає даних" when file_id is present but summary is empty
"""
import sys
import os
import io
import asyncio
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "gateway-bot"))
# ── Fix 2: deterministic truncation ─────────────────────────────────────────
def test_truncate_by_line_short_text():
"""Текст коротший за ліміт — повертається без змін."""
from services.doc_service import _truncate_by_line
text = "Рядок 1\nРядок 2\nРядок 3"
assert _truncate_by_line(text, max_chars=500) == text
def test_truncate_by_line_cuts_at_newline():
"""Обрізка відбувається по межі рядка, не посередині."""
from services.doc_service import _truncate_by_line
# Якщо max_chars=20 і текст "AAAAAAAAAAA\nBBBBBBBBBBB"
# Обрізаємо на \n, не посередині BBBBB
text = "AAAAAAAAAAA\nBBBBBBBBBBB\nCCCCCCCCCCC"
result = _truncate_by_line(text, max_chars=20)
# Результат має закінчуватись після першого рядка
assert not result.endswith("B"), f"Should not cut mid-line, got: {result!r}"
assert "AAAAAAAAAAA" in result
assert "\n" not in result.strip() or result.endswith("AAAAAAAAAAA")
def test_truncate_by_line_does_not_end_with_partial_row():
"""Табличний рядок не обривається посередині."""
from services.doc_service import _truncate_by_line
rows = "\n".join([f"Показник_{i}\t{i * 1000}" for i in range(100)])
result = _truncate_by_line(rows, max_chars=200)
# Останній рядок має бути повним (містити \t або бути єдиним полем)
last_line = result.split("\n")[-1]
# Не перевіряємо повноту — просто що немає обривання до першого символу
assert len(last_line) > 0
def test_truncate_by_line_exact_boundary():
"""Якщо ліміт рівно на \n — не додає зайвого."""
from services.doc_service import _truncate_by_line
text = "12345\n67890\nABCDE"
result = _truncate_by_line(text, max_chars=6)
# rfind("\n", 0, 6) знайде \n на позиції 5 → обріже до "12345"
assert result == "12345"
def test_extract_summary_from_bytes_truncates_at_line():
"""extract_summary_from_bytes для великого XLSX не рве рядок посередині."""
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Data"
for i in range(400):
ws.append([f"Показник_{i}", i * 100, "грн"])
buf = io.BytesIO()
wb.save(buf)
xlsx_bytes = buf.getvalue()
from services.doc_service import extract_summary_from_bytes
result = extract_summary_from_bytes("big.xlsx", xlsx_bytes)
# Результат не повинен закінчуватись посередині рядка таблиці
if result:
last_char = result[-1]
# Останній символ має бути частиною завершеного рядка (не обривом)
assert last_char not in ("_",), f"Looks like mid-line cut: ...{result[-30:]!r}"
# ── Fix 3: numeric normalization ─────────────────────────────────────────────
def test_parse_number_plain():
"""Звичайне число."""
from crews.agromatrix_crew.doc_facts import _parse_number
assert _parse_number("5972016") == 5972016.0
def test_parse_number_space_separator():
"""Число з пробілами як роздільниками тисяч."""
from crews.agromatrix_crew.doc_facts import _parse_number
assert _parse_number("5 972 016") == 5972016.0
def test_parse_number_nbsp_separator():
"""Число з NBSP (U+00A0) як роздільником тисяч — openpyxl так форматує."""
from crews.agromatrix_crew.doc_facts import _parse_number
assert _parse_number("5\u00a0972\u00a0016") == 5972016.0
def test_parse_number_thin_space():
"""Число з тонким пробілом (U+202F)."""
from crews.agromatrix_crew.doc_facts import _parse_number
assert _parse_number("5\u202f972\u202f016") == 5972016.0
def test_parse_number_comma_thousands():
"""Кома як роздільник тисяч: 5,972,016."""
from crews.agromatrix_crew.doc_facts import _parse_number
assert _parse_number("5,972,016") == 5972016.0
def test_parse_number_comma_decimal():
"""Кома як десяткова: 12,5 → 12.5."""
from crews.agromatrix_crew.doc_facts import _parse_number
assert _parse_number("12,5") == 12.5
def test_parse_number_with_unit_uah():
"""Число з одиницею грн."""
from crews.agromatrix_crew.doc_facts import _parse_number
assert _parse_number("1521084 грн") == 1521084.0
def test_parse_number_with_unit_ha():
"""Число з одиницею га."""
from crews.agromatrix_crew.doc_facts import _parse_number
assert _parse_number("497 га") == 497.0
def test_parse_number_with_unit_uah_per_ha():
"""Число з одиницею грн/га."""
from crews.agromatrix_crew.doc_facts import _parse_number
assert _parse_number("12015 грн/га") == 12015.0
def test_parse_number_negative_brackets():
"""Від'ємне число в дужках (бухгалтерський стиль): (1 234) → -1234."""
from crews.agromatrix_crew.doc_facts import _parse_number
result = _parse_number("(1 234)")
assert result == -1234.0
def test_parse_number_float_with_dot():
"""Число з крапкою як десятковою."""
from crews.agromatrix_crew.doc_facts import _parse_number
assert _parse_number("12016.13") == 12016.13
def test_parse_number_zero_string():
"""Порожній рядок → 0.0."""
from crews.agromatrix_crew.doc_facts import _parse_number
assert _parse_number("") == 0.0
def test_parse_number_invalid():
"""Нечислові символи → 0.0."""
from crews.agromatrix_crew.doc_facts import _parse_number
assert _parse_number("n/a") == 0.0
def test_extract_doc_facts_from_xlsx_tabular_text():
"""extract_doc_facts витягує факти з XLSX-табличного тексту."""
from crews.agromatrix_crew.doc_facts import extract_doc_facts
# Типовий вивід з extract_summary_from_bytes для XLSX
text = """=== Аркуш: Кукурудза 2024 ===
Показник\tЗначення\tОд.вим
Площа\t497\tга
Прибуток\t5\u00a0972\u00a0016\tгрн
Витрати на добрива\t1\u00a0521\u00a0084\tгрн
Прибуток/га\t12\u00a0015\tгрн/га"""
facts = extract_doc_facts(text)
assert facts.get("area_ha") == 497.0, f"area_ha not found: {facts}"
# profit_uah або інший ключ має містити ~5972016
profit = facts.get("profit_uah") or facts.get("revenue_uah")
assert profit and abs(profit - 5972016) < 1, f"profit not found correctly: {facts}"
def test_extract_doc_facts_nbsp_numbers():
"""extract_doc_facts розпізнає числа з NBSP."""
from crews.agromatrix_crew.doc_facts import extract_doc_facts
text = "Прибуток: 5\u00a0972\u00a0016 грн. Площа: 497 га."
facts = extract_doc_facts(text)
assert facts.get("area_ha") == 497.0
profit = facts.get("profit_uah") or facts.get("revenue_uah")
assert profit and abs(profit - 5972016) < 1, f"facts: {facts}"
# ── Fix 1: one-shot cache ────────────────────────────────────────────────────
async def _scenario_one_shot_cache():
"""
Симулює: upload → extract → повторний upload того самого файлу.
Другий раз: fetch_telegram_file_bytes НЕ має викликатись.
"""
from unittest.mock import AsyncMock, patch, MagicMock
stored = {}
fetch_calls = []
async def mock_upsert(user_id, fact_key, fact_value_json, team_id=None, **kwargs):
stored[fact_key] = fact_value_json
return True
async def mock_get(user_id, fact_key, **kwargs):
val = stored.get(fact_key)
return {"fact_value_json": val} if val else None
mock_mem = AsyncMock()
mock_mem.upsert_fact = AsyncMock(side_effect=mock_upsert)
mock_mem.get_fact = AsyncMock(side_effect=mock_get)
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Test"
ws.append(["Показник", "Значення"])
ws.append(["Прибуток", 5972016])
buf = io.BytesIO()
wb.save(buf)
xlsx_bytes = buf.getvalue()
async def mock_fetch(bot_token, file_id):
fetch_calls.append(file_id)
return xlsx_bytes
with patch("services.doc_service.memory_client", mock_mem):
from services.doc_service import (
save_chat_doc_context,
upsert_chat_doc_context_with_summary,
extract_summary_from_bytes,
get_chat_doc_context,
)
doc_ctx = {"doc_id": "fuid_abc", "file_unique_id": "fuid_abc",
"file_id": "file123", "file_name": "test.xlsx", "source": "telegram"}
# Перший upload: save базовий + extract + upsert summary
await save_chat_doc_context("chat_oneshot", "agromatrix", doc_ctx)
summary = extract_summary_from_bytes("test.xlsx", xlsx_bytes)
await upsert_chat_doc_context_with_summary("chat_oneshot", "agromatrix", doc_ctx, summary)
# Симулюємо логіку Fix 1: перевіряємо чи є вже summary
existing = await get_chat_doc_context("chat_oneshot", "agromatrix")
already_have = (
existing
and existing.get("extracted_summary")
and (existing.get("file_unique_id") or existing.get("doc_id")) == "fuid_abc"
)
return already_have, fetch_calls
def test_one_shot_cache_detects_existing_summary():
"""Fix 1: після першого extract — логіка визначає 'already_have=True' для того самого файлу."""
already_have, fetch_calls = asyncio.run(_scenario_one_shot_cache())
assert already_have is True, "Should detect existing summary for same file_unique_id"
def test_one_shot_cache_different_fuid_not_skipped():
"""Fix 1: різний file_unique_id — не вважається закешованим."""
async def _run():
from unittest.mock import AsyncMock, patch
stored = {}
async def mock_upsert(user_id, fact_key, fact_value_json, team_id=None, **kwargs):
stored[fact_key] = fact_value_json
return True
async def mock_get(user_id, fact_key, **kwargs):
val = stored.get(fact_key)
return {"fact_value_json": val} if val else None
mock_mem = AsyncMock()
mock_mem.upsert_fact = AsyncMock(side_effect=mock_upsert)
mock_mem.get_fact = AsyncMock(side_effect=mock_get)
with patch("services.doc_service.memory_client", mock_mem):
from services.doc_service import (
save_chat_doc_context, upsert_chat_doc_context_with_summary,
extract_summary_from_bytes, get_chat_doc_context,
)
ctx_a = {"doc_id": "fuid_A", "file_unique_id": "fuid_A",
"file_name": "a.xlsx", "source": "telegram"}
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.append(["Data", 123])
buf = io.BytesIO()
wb.save(buf)
xlsx_bytes = buf.getvalue()
await save_chat_doc_context("chat_diff", "agromatrix", ctx_a)
summary = extract_summary_from_bytes("a.xlsx", xlsx_bytes)
await upsert_chat_doc_context_with_summary("chat_diff", "agromatrix", ctx_a, summary)
# Перевіряємо для ІНШОГО fuid
existing = await get_chat_doc_context("chat_diff", "agromatrix")
already_have = (
existing
and existing.get("extracted_summary")
and (existing.get("file_unique_id") or existing.get("doc_id")) == "fuid_B" # інший!
)
return already_have
result = asyncio.run(_run())
assert result is False, "Different file_unique_id should NOT be considered cached"
# ── Fix D: active_doc_id set immediately on upload ───────────────────────────
def test_fix_d_active_doc_id_in_doc_ctx():
"""Fix D: doc_ctx_to_save містить active_doc_id = file_unique_id одразу."""
# Симулюємо те, що робить handle_stepan_message при upload
_fu_id = "test_fuid_xyz"
_file_id_tg = "file_id_abc"
_fname = "Звіт.xlsx"
doc_ctx_to_save = {
"doc_id": _fu_id,
"file_unique_id": _fu_id,
"file_id": _file_id_tg,
"file_name": _fname,
"source": "telegram",
"active_doc_id": _fu_id, # Fix D: явно фіксується
}
assert doc_ctx_to_save["active_doc_id"] == _fu_id, "active_doc_id must equal file_unique_id"
assert doc_ctx_to_save["active_doc_id"] == doc_ctx_to_save["file_unique_id"]
def test_fix_d_run_py_prioritizes_active_doc_id():
"""Fix D: run.py читає active_doc_id як пріоритет (перед doc_id і file_unique_id)."""
# Симулюємо логіку з run.py
def compute_current_doc_id(doc_context: dict) -> str | None:
if not doc_context:
return None
return (
doc_context.get("active_doc_id")
or doc_context.get("doc_id")
or doc_context.get("file_unique_id")
or None
)
# Кейс: active_doc_id є — має бути пріоритетом
ctx_with_anchor = {"active_doc_id": "anchor_id", "doc_id": "doc_id_old", "file_unique_id": "fuid_old"}
assert compute_current_doc_id(ctx_with_anchor) == "anchor_id"
# Кейс: active_doc_id відсутній — fallback на doc_id
ctx_no_anchor = {"doc_id": "doc_id_ok", "file_unique_id": "fuid_ok"}
assert compute_current_doc_id(ctx_no_anchor) == "doc_id_ok"
# Кейс: нічого немає
assert compute_current_doc_id({}) is None
assert compute_current_doc_id(None) is None
# ── Fix E: no "немає даних" when file_id present ─────────────────────────────
def _build_doc_snippet_logic(doc_context: dict) -> str:
"""Спрощена копія логіки DOC BRIDGE з run.py для тестів."""
if not doc_context:
return ""
parts = []
doc_title = doc_context.get("title") or doc_context.get("file_name") or ""
doc_summary = doc_context.get("extracted_summary") or doc_context.get("summary", "")
if doc_title:
parts.append(f"=== ДОКУМЕНТ: «{doc_title}» ===")
if doc_summary:
parts.append(f"ЗМІСТ ДОКУМЕНТА:\n{doc_summary[:3000]}")
parts.append("=== КІНЕЦЬ ДОКУМЕНТА ===")
elif doc_title:
# Fix E logic
_doc_file_id = doc_context.get("file_id") or doc_context.get("file_unique_id") or ""
if _doc_file_id:
parts.append(
f"(Вміст «{doc_title}» ще не витягнутий у цій сесії — "
f"але файл є. Перевір ІСТОРІЮ ДІАЛОГУ..."
)
else:
parts.append(
f"(Вміст «{doc_title}» недоступний. Якщо в history нічого — попроси надіслати файл ще раз.)"
)
return "\n".join(parts)
def _build_instruction(doc_summary_snippet: str, chat_history: str, doc_context: dict) -> str:
"""Спрощена копія логіки інструкцій з run.py для тестів (Fix E)."""
if doc_summary_snippet and "ЗМІСТ ДОКУМЕНТА" in doc_summary_snippet:
return "ІНСТРУКЦІЯ: У тебе є вміст документа вище."
elif chat_history:
return "ІНСТРУКЦІЯ: Використовуй ІСТОРІЮ ДІАЛОГУ"
elif doc_context and (doc_context.get("file_id") or doc_context.get("file_unique_id")):
return "ІНСТРУКЦІЯ: Файл отримано, але вміст ще не витягнутий. НЕ кажи 'немає даних'"
else:
return "Дай коротку, конкретну відповідь."
def test_fix_e_no_summary_but_file_id_present():
"""Fix E: якщо summary порожній але file_id є — НЕ просити 'надіслати ще раз'."""
ctx = {"file_name": "Звіт.xlsx", "file_id": "file123", "file_unique_id": "fuid456"}
snippet = _build_doc_snippet_logic(ctx)
# Snippet має містити "файл є", але НЕ "надішли ще раз"
assert "надіслати файл ще раз" not in snippet, f"Should not ask to resend when file_id exists: {snippet!r}"
assert "файл є" in snippet or "Вміст" in snippet
def test_fix_e_no_summary_no_file_id_allows_resend():
"""Fix E: якщо summary порожній і file_id немає — можна попросити надіслати."""
ctx = {"file_name": "Звіт.xlsx"} # без file_id
snippet = _build_doc_snippet_logic(ctx)
assert "надіслати файл ще раз" in snippet, f"Should suggest resend when no file_id: {snippet!r}"
def test_fix_e_instruction_with_file_id_no_summary():
"""Fix E: інструкція забороняє агенту казати 'немає даних' коли file_id присутній."""
ctx = {"file_name": "test.xlsx", "file_id": "fid123"}
instruction = _build_instruction("", "", ctx)
# Інструкція має ЗАБОРОНЯТИ "немає даних" (містити заперечення "НЕ кажи")
assert "не кажи" in instruction.lower(), f"Instruction must forbid 'немає даних': {instruction!r}"
# І не просити надіслати файл ще раз
assert "надіслати" not in instruction.lower()
def test_fix_e_instruction_with_summary_normal():
"""Fix E: якщо summary є — стандартна інструкція без спеціального тексту."""
ctx = {"file_name": "test.xlsx", "file_id": "fid123", "extracted_summary": "Прибуток: 5 млн грн"}
snippet = _build_doc_snippet_logic(ctx)
instruction = _build_instruction(snippet, "", ctx)
assert "ЗМІСТ ДОКУМЕНТА" in snippet
assert "У тебе є вміст документа" in instruction
def test_fix_e_no_doc_context_fallback_generic():
"""Fix E: без doc_context — стандартна відповідь без згадки документа."""
instruction = _build_instruction("", "", {})
assert "Дай коротку" in instruction