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
This commit is contained in:
Apple
2026-03-03 07:14:14 -08:00
parent e9dedffa48
commit 129e4ea1fc
241 changed files with 69349 additions and 0 deletions

View File

@@ -0,0 +1,438 @@
"""
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