codex mechanicus search (spec-014): ground warhammer lore in binaric.tech via codex_search tool
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""Unit coverage for SPEC-014 Codex Mechanicus search (CDX-01..06)."""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fjerkroa_bot.codex import CODEX_SEARCH_TOOL, CodexSearch
|
||||
from fjerkroa_bot.openai_responder import OpenAIResponder
|
||||
|
||||
CONFIG = {"openai-token": "t", "model": "m", "system": "s", "history-limit": 5}
|
||||
|
||||
INDEX = {
|
||||
"items": [
|
||||
{
|
||||
"id": "doctrine-heretek",
|
||||
"collection": "doctrines",
|
||||
"url": "/en/codex/doctrines/doctrine-heretek/",
|
||||
"title": "Heretek — Doctrine of the Tech-Heretic",
|
||||
"summary": "The label the Cult Mechanicus stamps on Tech-Priests who pursue forbidden sciences.",
|
||||
"body": "xenotech, sentient machines, Warp-touched archeotech",
|
||||
},
|
||||
{
|
||||
"id": "doctrine-heretek",
|
||||
"collection": "doctrines",
|
||||
"url": "/de/codex/doctrines/doctrine-heretek/",
|
||||
"title": "Heretek — Doktrin des Techketzers",
|
||||
"summary": "Das Etikett des Kultes Mechanicus fuer Techpriester verbotener Wissenschaften.",
|
||||
"body": "Xenotech, empfindungsfaehige Maschinen",
|
||||
},
|
||||
{
|
||||
"id": "forge-stygies",
|
||||
"collection": "forges",
|
||||
"url": "/en/codex/forges/forge-stygies/",
|
||||
"title": "Stygies VIII",
|
||||
"summary": "A forge world of shrouded reputation.",
|
||||
"body": "The forge fields many Skitarii legions.",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _reader(cfg):
|
||||
reader = CodexSearch(lambda: cfg)
|
||||
return reader
|
||||
|
||||
|
||||
class TestToolOffered(unittest.TestCase):
|
||||
def test_tool_present_only_when_enabled(self):
|
||||
"""CDX-01: codex_search appears only with enable-codex."""
|
||||
off = OpenAIResponder(CONFIG, "chat")
|
||||
self.assertNotIn("codex_search", [f["name"] for f in off._available_tools()])
|
||||
on = OpenAIResponder(dict(CONFIG, **{"enable-codex": True}), "chat")
|
||||
self.assertIn("codex_search", [f["name"] for f in on._available_tools()])
|
||||
self.assertEqual(CODEX_SEARCH_TOOL["name"], "codex_search")
|
||||
|
||||
|
||||
class TestIndexGuardAndCache(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_internal_index_url_refused(self):
|
||||
"""CDX-02: an index URL on a private address is refused before any fetch."""
|
||||
reader = _reader({"enable-codex": True, "codex-index-url": "http://127.0.0.1/search-index.json"})
|
||||
result = await reader.search("heretek")
|
||||
self.assertIn("error", result)
|
||||
|
||||
async def test_index_cached_within_ttl(self):
|
||||
"""CDX-02: a second search inside the TTL does not re-fetch the index."""
|
||||
reader = _reader({"enable-codex": True, "codex-cache-ttl": 9999})
|
||||
raw = json.dumps(INDEX).encode()
|
||||
calls = [0]
|
||||
|
||||
class FakeResp:
|
||||
status = 200
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
class FakeSession:
|
||||
def get(self, url):
|
||||
calls[0] += 1
|
||||
return FakeResp()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
with patch("fjerkroa_bot.codex.read_capped", new=AsyncMock(return_value=raw)):
|
||||
with patch("fjerkroa_bot.codex.guard_url", return_value=None):
|
||||
with patch("fjerkroa_bot.codex.aiohttp.ClientSession", return_value=FakeSession()):
|
||||
first = await reader.search("heretek")
|
||||
second = await reader.search("stygies")
|
||||
self.assertEqual(calls[0], 1) # fetched once, served from cache the second time
|
||||
self.assertTrue(first["results"] and second["results"])
|
||||
|
||||
|
||||
class TestRankingAndLang(unittest.IsolatedAsyncioTestCase):
|
||||
async def _search(self, cfg, query, lang="en"):
|
||||
reader = _reader(dict({"enable-codex": True}, **cfg))
|
||||
reader._cache = INDEX["items"]
|
||||
reader._fetched_at = 1e18 # far future: never expires in test
|
||||
with patch("fjerkroa_bot.codex.time.monotonic", return_value=1e18):
|
||||
return await reader.search(query, lang)
|
||||
|
||||
async def test_title_hit_outranks_body_hit(self):
|
||||
"""CDX-03: a title match ranks above a body-only match."""
|
||||
result = await self._search({}, "heretek")
|
||||
self.assertEqual(result["results"][0]["title"].split(" ")[0], "Heretek")
|
||||
self.assertTrue(result["results"][0]["url"].startswith("https://binaric.tech/en/"))
|
||||
|
||||
async def test_lang_filter_selects_language(self):
|
||||
"""CDX-04: lang=de returns the German inscription."""
|
||||
result = await self._search({}, "heretek", lang="de")
|
||||
self.assertTrue(all("/de/" in r["url"] for r in result["results"]))
|
||||
self.assertIn("Techketzer", result["results"][0]["title"])
|
||||
|
||||
async def test_lang_fallback_when_absent(self):
|
||||
"""CDX-04: a tongue with no match falls back to all tongues, not empty."""
|
||||
result = await self._search({}, "stygies", lang="uk") # only en/de exist
|
||||
self.assertTrue(result["results"])
|
||||
self.assertEqual(result["results"][0]["title"], "Stygies VIII")
|
||||
|
||||
|
||||
class TestSanitizeAndFailure(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_result_sanitized_and_capped(self):
|
||||
"""CDX-05: title/summary are @-neutralized and length-capped."""
|
||||
reader = _reader({"enable-codex": True, "codex-summary-chars": 40})
|
||||
reader._cache = [{"collection": "x", "url": "/en/x/", "title": "@everyone hi", "summary": "@here " + "y" * 500, "body": "hit"}]
|
||||
reader._fetched_at = 1e18
|
||||
with patch("fjerkroa_bot.codex.time.monotonic", return_value=1e18):
|
||||
result = await reader.search("hit")
|
||||
top = result["results"][0]
|
||||
self.assertNotIn("@everyone", top["title"])
|
||||
self.assertNotIn("@here", top["summary"])
|
||||
self.assertLessEqual(len(top["summary"]), 40)
|
||||
|
||||
async def test_index_failure_returns_error(self):
|
||||
"""CDX-05: a broken index returns an error dict, never raises."""
|
||||
reader = _reader({"enable-codex": True})
|
||||
with patch.object(reader, "_load_index", new=AsyncMock(side_effect=ValueError("boom"))):
|
||||
result = await reader.search("heretek")
|
||||
self.assertIn("error", result)
|
||||
|
||||
|
||||
class TestPerUserCap(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_dispatch_caps_searches(self):
|
||||
"""CDX-06: over codex-daily-per-user, codex_search refuses without searching."""
|
||||
responder = OpenAIResponder(dict(CONFIG, **{"enable-codex": True, "codex-daily-per-user": 2}), "chat")
|
||||
responder.codex.search = AsyncMock(return_value={"query": "x", "results": []})
|
||||
for _ in range(2):
|
||||
await responder._dispatch_tool("codex_search", {"query": "heretek"}, "magos")
|
||||
blocked = await responder._dispatch_tool("codex_search", {"query": "heretek"}, "magos")
|
||||
self.assertIn("error", blocked)
|
||||
self.assertEqual(responder.codex.search.await_count, 2)
|
||||
Reference in New Issue
Block a user