""" 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