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
439 lines
20 KiB
Python
439 lines
20 KiB
Python
"""
|
||
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
|