"""语义缓存测试(T-P6,M2):精确/n-gram 阈值/TTL/LRU/重建/晋升/singleflight/SSE 回放。""" import asyncio import json import pytest from gateway.proxy.semcache import ( SingleFlight, SemanticCache, grams, synth_sse_chunks, weighted_jaccard, ) from gateway.proxy.ledger import Ledger @pytest.fixture() def cache(tmp_path): led = Ledger.init_db(tmp_path / "p.sqlite3") return SemanticCache(led, max_entries=100, sim_threshold=0.92, promote_frequency=5) def _put(cache, key, text, answer="答案A", model="m", doc_version=1, ttl_hours=72): cache.put(key, text, answer, model, doc_version=doc_version, ttl_hours=ttl_hours) def test_grams_and_weighted_jaccard(): g1 = grams("什么是递归") assert any(len(g) == 2 for g in g1) and any(len(g) == 3 for g in g1) assert weighted_jaccard(g1, g1) == 1.0 assert weighted_jaccard(grams("完全不同话题"), g1) == 0.0 def test_exact_hit_and_miss(cache): key = "default|1|" + "a" * 16 _put(cache, key, "什么是递归", "递归是自调用") hit = cache.lookup(key, "什么是递归") assert hit and hit["level"] == "exact" and hit["answer"] == "递归是自调用" assert cache.lookup(key + "-nope", "完全无关的问题") is None def test_semantic_hit_above_threshold(cache): """同义变体:L2 命中(阈值上)。""" key = "default|1|b1" _put(cache, key, "请解释一下什么叫做递归函数", "递归解释") hit = cache.lookup("default|1|b2", "请解释一下什么叫做递归函数", doc_version=1) assert hit and hit["level"] == "semantic" assert cache.hits_semantic == 1 def test_semantic_miss_below_threshold(cache): """完全不同语义:未命中(阈值下)。""" _put(cache, "default|1|c1", "请解释一下什么叫做递归函数", "递归解释") assert cache.lookup("default|1|c2", "今天股市行情怎么样", doc_version=1) is None def test_ttl_expiry_fake_clock(cache): _put(cache, "k", "某个问题文本", "旧答案", ttl_hours=1) cache._clock = cache._now() + 7200 # 假时钟 +2h assert cache.lookup("k", "某个问题文本", doc_version=1) is None # 过期不可见 def test_lru_eviction(tmp_path): led = Ledger.init_db(tmp_path / "p.sqlite3") cache = SemanticCache(led, max_entries=3) for i in range(5): _put(cache, f"k{i}", f"完全不同的问题编号{i}", f"答{i}") assert len(cache._l1) == 3 # LRU 上限 assert cache.lookup("k0", "完全不同的问题编号0") is None # 最旧被驱逐 assert cache.lookup("k4", "完全不同的问题编号4") is not None def test_rebuild_from_sqlite(tmp_path): """启动时由 semcache 表重建倒排索引。""" led = Ledger.init_db(tmp_path / "p.sqlite3") c1 = SemanticCache(led, max_entries=100) c1.put("k", "解释递归的概念", "持久化答案", "m") c2 = SemanticCache(led, max_entries=100) # 新实例:重建 hit = c2.lookup("k2", "解释递归的概念", doc_version=1) assert hit and hit["answer"] == "持久化答案" def test_promote_after_five_semantic_hits(cache): """L2 命中 5 次 -> 晋升 L1(promote 别名键可精确命中)。""" key = "default|1|p1" _put(cache, key, "请解释一下什么叫做递归函数呢?", "递归解释(变体)") promoted = False for i in range(5): hit = cache.lookup("default|1|p%d" % (i + 2), "请解释一下什么叫做递归函数呢", doc_version=1) assert hit and hit["level"] == "semantic" if any(k.startswith("default|1|promoted:") for k in cache._l1): promoted = True assert promoted and cache.hits_semantic == 5 def test_singleflight_merge_and_bypass(): """两并发同请求:一登记一等待;超限旁路。""" async def scenario(): sf = SingleFlight() fut, slot = sf.try_claim("h1") assert fut is None and slot is not None # 首个登记 fut2, slot2 = sf.try_claim("h1") assert fut2 is not None and slot2 is None # 第二个等待 sf.release(slot, result="共享答案") got = await sf.wait(fut2) assert got == "共享答案" # 超限旁路 sf2 = SingleFlight() sf2.MAX = 2 _f, s1 = sf2.try_claim("a") _f2, s2 = sf2.try_claim("b") f3, s3 = sf2.try_claim("c") assert f3 is None and s3 is None # 第三个旁路 asyncio.run(scenario()) def test_synth_sse_chunks_valid(): """命中回放:合法 SSE 形状(delta 分块 + finish + [DONE])。""" chunks = synth_sse_chunks("你好世界" * 10, chunk_size=20, model="m", request_id="r1") text = b"".join(chunks).decode("utf-8") assert text.count("chat.completion.chunk") >= 2 assert '"finish_reason": "stop"' in text or '"finish_reason":"stop"' in text assert text.rstrip("\n").endswith("data: [DONE]") content = "" done = False for raw in chunks: for line in raw.decode("utf-8").splitlines(): if not line.startswith("data:"): continue payload = line[5:].strip() if payload == "[DONE]": done = True continue obj = json.loads(payload) assert obj["object"] == "chat.completion.chunk" content += obj["choices"][0]["delta"].get("content") or "" assert done and "你好世界" in content