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