simbiot-harness/tests/test_rag_lookup.py
Simbiot Harness 34df7f3d72 feat(rag)+fix(timeout): RAG-инструмент в тиках + хард-кап шагов воркера
RAG (t-260929212316): каждому LLM-тику доступен rag_lookup — семантический
поиск по базе кооператива (/root/rag, fastembed+sqlite). Лимиты зашиты в
инструмент: 1 запрос/тик, k<=5, таймаут 20с, сабпроцесс rag-venv, мёртвый
вендор не валит тик. Трейс RagQueried в сторе. CLI: rag-query.

Хард-кап шагов (e-260930031800): мин-воркер тонул в 90+ tool-вызовах и
умирал по таймауту 300с. До: 3 CycleError за 2 часа, эскалации min→hour.
Фикс: cancel_tool на вызовы сверх лимита + end_turn (AfterToolsEvent)
останавливает цикл после партии + limits.turns (нативный механизм SDK).
e2e: 20 параллельных вызовов при лимите 3 → ровно 3 исполнения, цикл
остановлен, тик завершён текстом за ~12с (было: таймаут 300с).

Мандат мин-уровня: про rag_lookup и приоритет RAG для вопросов про кооператив.

Тесты: tests/test_rag_lookup.py (29 PASS), tests/test_tool_budget.py (12 PASS).
2026-09-30 04:16:46 +00:00

142 lines
No EOL
6.7 KiB
Python
Raw 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.

# -*- 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)