# -*- coding: utf-8 -*- """t-260929212316: офлайн-тесты RAG-обёртки (hermetic: копия БД, вендор мокнут). Запуск: ./venv/bin/python tests/test_rag_lookup.py Проверяет: парсер вывода rag.py, лимит k, пустые вопросы, флаг RAG_DISABLED, лимит 1 вызова на тик в инструменте воркера (без реального LLM-вызова — strands-агент не создаётся, проверяем замыкание _rag_tool_for через мок strands_tool), форматирование чанков, трейс RagQueried в сторе. """ import json import os import sqlite3 import sys import tempfile # ── изоляция: копия боевой БД через online-backup (WAL-консистентно) ── _SRC = "/root/simbiot-harness/harness/harness.db" _DST = "/tmp/simbiot-test-rag-lookup.db" for p in (_DST, _DST + "-wal", _DST + "-shm"): if os.path.exists(p): os.unlink(p) s, d = sqlite3.connect(_SRC), sqlite3.connect(_DST) s.backup(d) d.close(); s.close() os.environ["SIMBIOT_DB"] = _DST sys.path.insert(0, "/root/simbiot-harness") from harness import rag_lookup from harness import es fails = [] def check(name, cond, extra=""): print(("PASS" if cond else "FAIL"), name, ("| " + extra) if not cond else "") if not cond: fails.append(name) # ── 1. парсер вывода rag.py ── SAMPLE = """ ═══ 0.842 | COOP_CONTEXT.md#1 ═══ ### Миссия «42» Все миссии одинаково важны. ═══ 0.700 | PORTAL_API.md#3 ═══ GET /api/v1/tasks """ chunks = rag_lookup._parse_output(SAMPLE) check("parse: 2 чанка", len(chunks) == 2, str(len(chunks))) check("parse: score float", abs(chunks[0]["score"] - 0.842) < 1e-6, str(chunks[0])) check("parse: source", chunks[0]["source"] == "COOP_CONTEXT.md#1", chunks[0]["source"]) check("parse: text без хвостов", chunks[0]["text"] == "### Миссия «42»\nВсе миссии одинаково важны.", repr(chunks[0]["text"])) check("parse: пустой вывод → []", rag_lookup._parse_output("") == []) check("parse: мусор без заголовков → []", rag_lookup._parse_output("just text\nno markers") == []) # ── 2. форматирование ── fmt = rag_lookup.format_chunks(chunks) check("format: содержит score", "0.842" in fmt, fmt[:100]) check("format: содержит source", "COOP_CONTEXT.md#1" in fmt, fmt[:100]) check("format: кап длины", len(rag_lookup.format_chunks(chunks, max_chars=30)) <= 60, "max_chars не работает") check("format: пустой список → пустая строка", rag_lookup.format_chunks([]) == "") # ── 3. лимиты rag_query без вендора (вендор-скрипт подменён) ── os.environ["RAG_DISABLED"] = "1" check("rag_query: RAG_DISABLED → []", rag_lookup.rag_query("тест") == []) del os.environ["RAG_DISABLED"] check("rag_query: пустой вопрос → []", rag_lookup.rag_query(" ") == []) # подменяем вендор на несуществующий путь — тишина вместо исключения _orig_venv, rag_lookup._RAG_VENV_PY = rag_lookup._RAG_VENV_PY, "/nonexistent/python" try: r = rag_lookup.rag_query("тест") check("rag_query: мёртвый вендор → [] (не raises)", r == [], str(r)) finally: rag_lookup._RAG_VENV_PY = _orig_venv # ── 4. реальный вендор: если /root/rag жив — один живой запрос ── if os.access(rag_lookup._RAG_VENV_PY, os.X_OK) and os.path.exists(rag_lookup._RAG_SCRIPT): r = rag_lookup.rag_query("кооператив 42 миссия", k=3, level="hour", trace_id="rag-test-1") check("rag_query: живой вендор вернул чанки", len(r) > 0, f"{len(r)} chunks") check("rag_query: k<=3 соблюдён", len(r) <= 3, str(len(r))) check("rag_query: score в диапазоне", all(0 <= c["score"] <= 1.5 for c in r), str([c["score"] for c in r])) # трейс RagQueried записан в стор es.init_db() evs = es.get_events(0, es.count_events()) rag_events = [e for e in evs if e["event_type"] == "RagQueried" and "rag-test-1" in e["aggregate_id"]] check("trace: RagQueried в сторе", len(rag_events) >= 1, f"{len(rag_events)} RagQueried") if rag_events: d = json.loads(rag_events[-1]["data"]) check("trace: payload содержит query/k/results", d.get("query") and d.get("k") == 3 and d.get("results") == len(r), str(d)[:200]) check("trace: level записан", d.get("level") == "hour", str(d.get("level"))) else: print("SKIP: живой вендор недоступен (не /root/rag среда)") # ── 5. лимит 1 запрос на тик в инструменте воркера ── # создаём инструмент напрямую (strands.tool декоратор), не зовём LLM from harness import _llm_worker tool = _llm_worker._rag_tool_for("min", "rag-test-2") check("worker tool: объект создан", tool is not None) fn = getattr(tool, "func", tool) or getattr(tool, "_func", None) or tool # strands @tool может оборачивать функцию — пробуем известные атрибуты called = {"n": 0} _rag_orig = rag_lookup.rag_query def _fake_query(q, **kw): called["n"] += 1 return [{"score": 0.9, "source": "X.md#0", "text": "найдено"}] rag_lookup.rag_query = _fake_query try: # пробуем вызвать внутреннюю функцию инструмента дважды # strands @tool → DecoratedFunctionTool._tool_func — голая функция inner = getattr(tool, "_tool_func", tool) if not callable(inner): inner = tool r1 = inner("вопрос") if callable(inner) else None r2 = inner("вопрос") if callable(inner) else None if callable(inner) and isinstance(r1, str): check("worker tool: 1-й вызов вернул чанки", "найдено" in str(r1), str(r1)[:120]) check("worker tool: 2-й вызов — лимит", "лимит" in str(r2), str(r2)[:120]) check("worker tool: вендор звался 1 раз", called["n"] == 1, str(called["n"])) else: print("SKIP: strands tool-обёртка не даёт прямого вызова:", type(tool)) finally: rag_lookup.rag_query = _rag_orig # ── 6. es не падает на RagQueried при пустом trace_id ── rag_lookup._trace(None, None, {"query": "x"}) check("trace: пустой trace_id — молча", True) print() print("FAILS:", len(fails)) sys.exit(1 if fails else 0)