merge: T8 pgvector 混合检索与缺口埋点(T-P2-03/04/06,TD-08)

This commit is contained in:
xsl
2026-08-14 20:13:00 +08:00
10 changed files with 1409 additions and 11 deletions
+297
View File
@@ -0,0 +1,297 @@
"""混合检索集成:真实 PostgreSQL + pgvector,嵌入用 DeterministicEmbedder(不联网).
证明三件单测证明不了的事(任务文档 §4):
1. 硬过滤零漏 —— 禁忌/draft 实体即使向量最近也绝不出现(T-P2-04 红线);
2. 关键词路径在 tsv 'simple' 分词下真实命中;
3. 无结果 query 落 kb_gap 且 hit_count 递增(T-P2-06)。
外加:向量化重跑整组替换且逐行带版本(T-P2-03)、kb_asset partial 索引可用。
Run: MEIDA_RUN_INTEGRATION=1 .venv/bin/pytest -m integration -q --no-cov
"""
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncIterator
from typing import Any
import pytest
from sqlalchemy import delete, select, text
from packages.domain.ids import next_id
from packages.retrieval.embedding import DeterministicEmbedder
from packages.retrieval.repo import ChunkInput, hybrid_search, upsert_chunks
from packages.retrieval.service import RetrievalService, UserContext
from packages.shared.db import dispose_engine, session_scope
from packages.shared.models import KbAsset, KbChunk, KbEntity, KbGap
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
def _unique(name: str) -> str:
return f"it-t8-{name}-{uuid.uuid4().hex[:10]}"
@pytest.fixture(autouse=True)
async def _hermetic_engine() -> AsyncIterator[None]:
"""每个用例前后重建全局 engine:asyncpg 连接绑定创建它的事件循环,
pytest-asyncio 给每个用例一个新 loop,复用池里旧连接会报
"attached to a different loop"。app 系用例由 lifespan 的 dispose_engine
兜底;直接用 session_scope 的本文件必须自己管。"""
await dispose_engine()
yield
await dispose_engine()
@pytest.fixture
async def clean_kb() -> AsyncIterator[dict[str, list[Any]]]:
"""登记本用例造的脏数据并在结束时清掉:集成库长活,不能留假实体."""
registry: dict[str, list[Any]] = {"entities": [], "gaps": []}
yield registry
async with session_scope() as session:
if registry["entities"]:
ids = registry["entities"]
await session.execute(delete(KbChunk).where(KbChunk.entity_id.in_(ids)))
await session.execute(delete(KbAsset).where(KbAsset.entity_id.in_(ids)))
await session.execute(delete(KbEntity).where(KbEntity.entity_id.in_(ids)))
for query_text in registry["gaps"]:
await session.execute(delete(KbGap).where(KbGap.query_text == query_text))
async def _add_entity(
registry: dict[str, list[Any]],
*,
entity_type: str = "item",
status: str = "published",
attrs: dict[str, Any] | None = None,
) -> int:
entity_id = next_id()
registry["entities"].append(entity_id)
async with session_scope() as session:
session.add(
KbEntity(
entity_id=entity_id,
entity_type=entity_type,
name=_unique(entity_type),
attrs=attrs or {},
status=status,
)
)
return entity_id
class RenamedEmbedder:
"""换名复用确定性向量:验证 model/version 逐行落库与整组替换(T-P2-03)."""
def __init__(self, model_name: str, version: str) -> None:
self._inner = DeterministicEmbedder(dimensions=1024)
self.model_name = model_name
self.version = version
self.dimensions = 1024
async def embed(self, texts: list[str]) -> list[list[float]]:
return await self._inner.embed(texts)
# --- 1. 硬过滤零漏(T-P2-04 红线)--------------------------------------------
async def test_hard_filter_never_leaks_taboo_or_draft(clean_kb: dict[str, list[Any]]) -> None:
embedder = DeterministicEmbedder(dimensions=1024)
query_text = "pear shape body dress advice"
# 防御性登记:若回归导致空结果被记进 kb_gap,用例结束也能清干净。
clean_kb["gaps"].append(query_text)
taboo_id = await _add_entity(
clean_kb, attrs={"occasions": ["wedding"], "avoid_body_shapes": ["apple"]}
)
draft_id = await _add_entity(clean_kb, status="draft")
good_id = await _add_entity(clean_kb, attrs={"occasions": ["wedding"]})
async with session_scope() as session:
# 禁忌与 draft 的 chunk 和 query 完全同文:不过滤的话它们必然霸占
# 向量第一名(距离 0)—— 这样断言"零出现"才有说服力。
await upsert_chunks(session, taboo_id, [ChunkInput(text=query_text)], embedder)
await upsert_chunks(session, draft_id, [ChunkInput(text=query_text)], embedder)
await upsert_chunks(
session,
good_id,
[ChunkInput(text="soft knit sweater paired with a high waist skirt")],
embedder,
)
async with session_scope() as session:
svc = RetrievalService(session, embedding=embedder)
outcome = await svc.search(
query_text,
user_ctx=UserContext(body_shape="apple", occasion="wedding", entity_types=("item",)),
)
assert outcome.degraded is False
assert outcome.results, "正常 published 实体应被召回"
assert {r.entity_id for r in outcome.results} == {good_id}
# 对照组:去掉禁忌/场景过滤后禁忌实体确实在库里且可被召回 ——
# 证明上面拦截它的是硬过滤,而不是数据没写进去。draft 永远不出现。
query_vec = (await embedder.embed([query_text]))[0]
async with session_scope() as session:
control = await hybrid_search(session, query_vec, query_text)
control_entities = {r.entity_id for r in control}
assert taboo_id in control_entities
assert draft_id not in control_entities
# --- 2. 关键词路径(tsv 'simple' 分词)----------------------------------------
async def test_keyword_recall_ranks_matching_chunk_first(
clean_kb: dict[str, list[Any]],
) -> None:
embedder = DeterministicEmbedder(dimensions=1024)
entity_id = await _add_entity(clean_kb)
matching = "breathable linen shirt keeps you cool in summer"
unrelated = "heavy wool coat for freezing winter nights"
async with session_scope() as session:
await upsert_chunks(
session,
entity_id,
[ChunkInput(text=matching), ChunkInput(text=unrelated)],
embedder,
)
# 查询文本与两个 chunk 都不同文 → 向量路分不出高下(近似正交),
# 排序由关键词路(0.2 权重、归一后差距 1.0)决定。
async with session_scope() as session:
results = await hybrid_search(
session, (await embedder.embed(["linen shirt summer"]))[0], "linen shirt summer"
)
assert [r.text for r in results] == [matching, unrelated]
assert results[0].kw_score == pytest.approx(1.0)
assert results[1].kw_score == 0.0
# --- 3. 向量化可重跑(T-P2-03)------------------------------------------------
async def test_upsert_chunks_is_versioned_and_rerunnable(
clean_kb: dict[str, list[Any]],
) -> None:
entity_id = await _add_entity(clean_kb)
async with session_scope() as session:
first = await upsert_chunks(
session,
entity_id,
[ChunkInput(text="rule one"), ChunkInput(text="rule two")],
RenamedEmbedder("bge-m3", "v1"),
)
assert {c.embedding_model for c in first} == {"bge-m3"}
assert {c.embedding_version for c in first} == {"v1"}
# 换模型名(= 换版本)重跑:整组替换,不残留旧版本向量
async with session_scope() as session:
await upsert_chunks(
session,
entity_id,
[ChunkInput(text="rule one"), ChunkInput(text="rule two"), ChunkInput(text="rule new")],
RenamedEmbedder("bge-m4", "v2"),
)
async with session_scope() as session:
rows = (
(await session.execute(select(KbChunk).where(KbChunk.entity_id == entity_id)))
.scalars()
.all()
)
assert len(rows) == 3
assert {r.embedding_model for r in rows} == {"bge-m4"}
assert {r.embedding_version for r in rows} == {"v2"}
# --- 4. 无结果 query 落 kb_gap(T-P2-06)--------------------------------------
async def test_no_result_query_lands_in_kb_gap_with_counter(
clean_kb: dict[str, list[Any]],
) -> None:
query_text = _unique("missing")
clean_kb["gaps"].append(query_text)
svc_embedder = DeterministicEmbedder(dimensions=1024)
async with session_scope() as session:
svc = RetrievalService(session, embedding=svc_embedder)
first = await svc.search(query_text, user_ctx=UserContext(entity_types=("nonexistent",)))
second = await svc.search(query_text, user_ctx=UserContext(entity_types=("nonexistent",)))
assert first.degraded and first.results == []
assert second.degraded and second.results == []
async with session_scope() as session:
gaps = (
(await session.execute(select(KbGap).where(KbGap.query_text == query_text)))
.scalars()
.all()
)
assert len(gaps) == 1 # 同 query + 同 filter_ctx 只有一行
assert gaps[0].hit_count == 2
assert gaps[0].filter_ctx["entity_types"] == ["nonexistent"]
# --- 5. kb_asset 按 entity 查询 + partial 索引可用 -----------------------------
async def test_tryon_ready_assets_query_uses_partial_index(
clean_kb: dict[str, list[Any]],
) -> None:
entity_id = await _add_entity(clean_kb)
async with session_scope() as session:
session.add_all(
[
KbAsset(
asset_id=next_id(),
entity_id=entity_id,
asset_type="front",
cos_key=_unique("front"),
tryon_ready=True,
review_status="approved",
),
KbAsset(
asset_id=next_id(),
entity_id=entity_id,
asset_type="side",
cos_key=_unique("side"),
tryon_ready=False,
review_status="pending",
),
]
)
async with session_scope() as session:
ready = (
(
await session.execute(
select(KbAsset).where(
KbAsset.entity_id == entity_id, KbAsset.tryon_ready.is_(True)
)
)
)
.scalars()
.all()
)
assert len(ready) == 1
assert ready[0].tryon_ready is True
# 关闭顺序扫描逼 planner 走索引,证明 partial 索引真的可用
# (SET LOCAL 只影响当前事务,随 session_scope 提交即失效)。
await session.execute(text("SET LOCAL enable_seqscan = off"))
plan_lines = (
(await session.execute(text("EXPLAIN SELECT asset_id FROM kb_asset WHERE tryon_ready")))
.scalars()
.all()
)
plan_text = "\n".join(str(line) for line in plan_lines)
assert "ix_kb_asset_tryon_ready" in plan_text
+99
View File
@@ -0,0 +1,99 @@
"""DeterministicEmbedder 与工厂:纯数学,不连库不联网(T-P2-03 接口位)."""
from __future__ import annotations
import math
import pytest
from packages.retrieval.embedding import (
DETERMINISTIC_MODEL_NAME,
DeterministicEmbedder,
build_embedding_provider,
)
from packages.shared.config import get_settings
from packages.shared.errors import AppError, ErrorCode
def _dot(a: list[float], b: list[float]) -> float:
return sum(x * y for x, y in zip(a, b, strict=True))
async def test_same_text_embeds_to_same_vector() -> None:
embedder = DeterministicEmbedder(dimensions=1024)
first, second = await embedder.embed(["白衬衫怎么搭配", "白衬衫怎么搭配"])
assert first == second
async def test_dimensions_match_declaration() -> None:
vec = (await DeterministicEmbedder(dimensions=1024).embed(["hello"]))[0]
assert len(vec) == 1024
small = (await DeterministicEmbedder(dimensions=8).embed(["hello"]))[0]
assert len(small) == 8
async def test_vectors_are_l2_normalized() -> None:
for text in ("hello", "中文知识片段", ""):
vec = (await DeterministicEmbedder(dimensions=1024).embed([text]))[0]
assert math.isclose(_dot(vec, vec), 1.0, rel_tol=1e-6, abs_tol=1e-9)
async def test_different_texts_are_near_orthogonal() -> None:
"""不同文本近似正交(|dot| ≪ 1):向量召回管线在无真模型时仍可测."""
left, right = await DeterministicEmbedder(dimensions=1024).embed(
["interview outfit advice", "winter skincare routine"]
)
assert left != right
assert abs(_dot(left, right)) < 0.1
async def test_batch_preserves_order_and_length() -> None:
texts = ["alpha", "beta", "gamma"]
vectors = await DeterministicEmbedder(dimensions=16).embed(texts)
assert len(vectors) == len(texts)
singles = [(await DeterministicEmbedder(dimensions=16).embed([t]))[0] for t in texts]
assert vectors == singles
def test_metadata_is_stable() -> None:
embedder = DeterministicEmbedder(dimensions=8)
assert embedder.model_name == DETERMINISTIC_MODEL_NAME
assert embedder.version
assert embedder.dimensions == 8
def test_invalid_dimensions_rejected() -> None:
with pytest.raises(ValueError, match="dimensions"):
DeterministicEmbedder(dimensions=0)
def test_factory_builds_deterministic_embedder(monkeypatch: pytest.MonkeyPatch) -> None:
# 带 alias 的 Settings 字段按字段名构造会被静默忽略 —— 统一走
# setenv + cache_clear(上批经验,见任务文档 §2)。
monkeypatch.setenv("KB_EMBEDDING_MODEL", "deterministic-v1")
monkeypatch.setenv("KB_VECTOR_DIMENSIONS", "1024")
get_settings.cache_clear()
try:
settings = get_settings()
provider = build_embedding_provider(settings)
assert isinstance(provider, DeterministicEmbedder)
assert provider.model_name == "deterministic-v1"
assert provider.dimensions == 1024
finally:
monkeypatch.undo()
get_settings.cache_clear()
def test_factory_rejects_unknown_model(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("KB_EMBEDDING_MODEL", "bge-m3-onnx")
get_settings.cache_clear()
try:
settings = get_settings()
assert settings.kb_embedding_model == "bge-m3-onnx"
with pytest.raises(AppError) as excinfo:
build_embedding_provider(settings)
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
finally:
monkeypatch.undo()
get_settings.cache_clear()
+158
View File
@@ -0,0 +1,158 @@
"""RRF 融合 + 权重 + 新鲜度的数学正确性(T-P2-04 验收 3):构造已知名次断言最终序."""
from __future__ import annotations
import itertools
from datetime import UTC, datetime, timedelta
import pytest
from packages.retrieval.rerank import (
DEFAULT_RERANK_WEIGHTS,
FRESHNESS_HALF_LIFE_DAYS,
RRF_K,
RecallRow,
freshness_score,
rerank,
rrf_component,
rrf_score,
)
NOW = datetime(2026, 8, 14, 12, 0, 0, tzinfo=UTC)
def _row(
chunk_id: int,
*,
vec_rank: int | None = None,
kw_rank: int | None = None,
updated_at: datetime = NOW,
entity_id: int = 1,
) -> RecallRow:
return RecallRow(
chunk_id=chunk_id,
entity_id=entity_id,
chunk_type="text",
text=f"chunk-{chunk_id}",
updated_at=updated_at,
vec_rank=vec_rank,
kw_rank=kw_rank,
)
# --- 单路 RRF 分量 ------------------------------------------------------------
def test_rrf_component_is_max_normalized() -> None:
assert rrf_component(1) == pytest.approx(1.0) # (60+1)/(60+1)
assert rrf_component(2) == pytest.approx(61 / 62)
assert rrf_component(RRF_K + 2) == pytest.approx(0.5) # 61/122:名次再排后一半
assert rrf_component(None) == 0.0
assert rrf_component(0) == 0.0
assert rrf_component(-3) == 0.0
def test_rrf_component_monotonic_non_increasing() -> None:
values = [rrf_component(r) for r in range(1, 20)]
assert all(a >= b for a, b in itertools.pairwise(values))
def test_rrf_score_is_sum_of_raw_reciprocals() -> None:
assert rrf_score(1, 2) == pytest.approx(1 / 61 + 1 / 62)
assert rrf_score(None, 1) == pytest.approx(1 / 61)
assert rrf_score(1, None) == pytest.approx(1 / 61)
assert rrf_score(None, None) == 0.0
# --- 新鲜度 ------------------------------------------------------------------
def test_freshness_half_life_decay() -> None:
assert freshness_score(NOW, now=NOW) == pytest.approx(1.0)
old = NOW - timedelta(days=FRESHNESS_HALF_LIFE_DAYS)
assert freshness_score(old, now=NOW) == pytest.approx(0.5)
older = NOW - timedelta(days=2 * FRESHNESS_HALF_LIFE_DAYS)
assert freshness_score(older, now=NOW) == pytest.approx(0.25)
# 时钟漂移产生未来时间戳 → 按 0 天处理,不放大分数
future = NOW + timedelta(hours=1)
assert freshness_score(future, now=NOW) == pytest.approx(1.0)
# --- 加权公式 ----------------------------------------------------------------
def test_final_score_follows_architecture_formula() -> None:
"""final = 0.45×vec + 0.2×kw + 0.25×rule + 0.1×fresh,逐项核对."""
row = _row(1, vec_rank=1, kw_rank=3)
boost = {1: 0.5}
scored = rerank([row], rule_boost=boost, now=NOW)
expected = 0.45 * 1.0 + 0.2 * (61 / 63) + 0.25 * 0.5 + 0.1 * 1.0
assert scored[0].final_score == pytest.approx(expected)
assert scored[0].vec_score == pytest.approx(1.0)
assert scored[0].kw_score == pytest.approx(61 / 63)
assert scored[0].rule_score == pytest.approx(0.5)
assert scored[0].freshness_score == pytest.approx(1.0)
def test_default_weights_match_architecture_section_8_3() -> None:
assert DEFAULT_RERANK_WEIGHTS == {
"vector": 0.45,
"keyword": 0.2,
"rule": 0.25,
"freshness": 0.1,
}
def test_dual_recall_beats_single_recall() -> None:
"""两路都命中的 chunk 应压过只有向量路命中、名次更高的 chunk."""
both = _row(1, vec_rank=5, kw_rank=2)
vec_only = _row(2, vec_rank=1)
scored = rerank([both, vec_only], now=NOW)
by_id = {c.chunk_id: c for c in scored}
assert by_id[1].final_score > by_id[2].final_score
def test_rule_boost_lifts_lower_recall_chunk() -> None:
"""规则权重 0.25×boost 可以把召回名次落后的 chunk 抬到最前(自建知识库的意义)."""
rank5 = _row(1, vec_rank=5)
rank1 = _row(2, vec_rank=1)
scored = rerank([rank5, rank1], rule_boost={1: 1.0}, now=NOW)
assert scored[0].chunk_id == 1
assert scored[0].rule_score == pytest.approx(1.0)
def test_keyword_only_chunk_gets_zero_vector_score() -> None:
scored = rerank([_row(7, kw_rank=1)], now=NOW)
assert scored[0].vec_score == 0.0
assert scored[0].kw_score == pytest.approx(1.0)
def test_freshness_breaks_ties_toward_newer_chunk() -> None:
"""名次相同(现实中少见,但公式上可能)时,更新鲜者胜."""
fresh = _row(1, vec_rank=2, updated_at=NOW)
stale = _row(2, vec_rank=2, updated_at=NOW - timedelta(days=90))
scored = rerank([fresh, stale], now=NOW)
assert scored[0].chunk_id == 1
delta = scored[0].final_score - scored[1].final_score
assert delta == pytest.approx(0.1 * (1.0 - 0.5**3))
def test_zero_weight_dimension_ignores_that_signal() -> None:
old = _row(1, vec_rank=1, updated_at=NOW - timedelta(days=365))
new = _row(2, vec_rank=2, updated_at=NOW)
scored = rerank([old, new], weights={"vector": 1.0}, now=NOW)
assert scored[0].chunk_id == 1 # 新鲜度权重为 0,旧的反而名次靠前
def test_stable_ordering_and_top_k_truncation() -> None:
rows = [_row(i, vec_rank=i) for i in (30, 10, 20, 40)]
scored = rerank(rows, now=NOW, top_k=2)
assert [c.chunk_id for c in scored] == [10, 20]
def test_equal_final_scores_break_tie_by_chunk_id() -> None:
rows = [_row(90, kw_rank=1), _row(10, kw_rank=1)]
scored = rerank(rows, now=NOW)
assert [c.chunk_id for c in scored] == [10, 90]
def test_empty_input_returns_empty() -> None:
assert rerank([], now=NOW) == []
+179
View File
@@ -0,0 +1,179 @@
"""RetrievalService 编排逻辑:repo 层打桩,不连库(编排正确性与 DB 无关)."""
from __future__ import annotations
from typing import Any
import pytest
from packages.retrieval import repo
from packages.retrieval import service as service_mod
from packages.retrieval.rerank import ScoredChunk
from packages.retrieval.service import RetrievalService, UserContext
class FakeEmbedder:
"""满足 EmbeddingProvider Protocol 的最小桩:记录调用,返回固定向量."""
model_name = "fake"
version = "v1"
dimensions = 4
def __init__(self) -> None:
self.calls: list[list[str]] = []
async def embed(self, texts: list[str]) -> list[list[float]]:
self.calls.append(list(texts))
return [[0.25, 0.25, 0.25, 0.25] for _ in texts]
class FakeSession:
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
def _scored(chunk_id: int = 1) -> ScoredChunk:
return ScoredChunk(
chunk_id=chunk_id,
entity_id=100,
chunk_type="text",
text="chunk",
vec_score=1.0,
kw_score=0.0,
rule_score=0.0,
freshness_score=1.0,
final_score=0.55,
)
@pytest.fixture
def stubbed_repo(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
"""把 repo 的两个 IO 函数换成记录桩,service 逻辑照常执行."""
calls: dict[str, Any] = {"hybrid_search": None, "gaps": [], "results": []}
async def fake_hybrid_search(
session: Any, query_vec: Any, query_text: str, **kwargs: Any
) -> list[ScoredChunk]:
calls["hybrid_search"] = {"query_vec": query_vec, "query_text": query_text, **kwargs}
return list(calls["results"])
async def fake_record_gap(session: Any, query_text: str, filter_ctx: Any) -> None:
calls["gaps"].append((query_text, filter_ctx))
monkeypatch.setattr(repo, "hybrid_search", fake_hybrid_search)
monkeypatch.setattr(repo, "record_gap", fake_record_gap)
return calls
async def test_search_with_hits_returns_results_without_gap(stubbed_repo: dict[str, Any]) -> None:
stubbed_repo["results"] = [_scored(1), _scored(2)]
embedder = FakeEmbedder()
session = FakeSession()
svc = RetrievalService(session, embedding=embedder)
outcome = await svc.search("面试穿什么", user_ctx=UserContext(body_shape="apple"))
assert outcome.degraded is False
assert [c.chunk_id for c in outcome.results] == [1, 2]
assert stubbed_repo["gaps"] == []
assert session.commits == 0 # 有结果的路径不写库,事务留给调用方
assert embedder.calls == [["面试穿什么"]] # embed 的输入就是 query 原文
async def test_search_forwards_user_context_to_hybrid(stubbed_repo: dict[str, Any]) -> None:
stubbed_repo["results"] = [_scored()]
svc = RetrievalService(FakeSession(), embedding=FakeEmbedder())
await svc.search(
"婚礼穿什么",
user_ctx=UserContext(
body_shape="pear",
occasion="wedding",
entity_types=("item", "style"),
rule_boost={42: 0.8},
),
)
seen = stubbed_repo["hybrid_search"]
assert seen is not None
assert seen["query_text"] == "婚礼穿什么"
assert seen["body_shape"] == "pear"
assert seen["occasion"] == "wedding"
assert seen["entity_types"] == ("item", "style")
assert seen["rule_boost"] == {42: 0.8}
async def test_empty_entity_types_is_forwarded_as_none(stubbed_repo: dict[str, Any]) -> None:
stubbed_repo["results"] = []
svc = RetrievalService(FakeSession(), embedding=FakeEmbedder())
await svc.search("随便看看", user_ctx=UserContext())
seen = stubbed_repo["hybrid_search"]
assert seen is not None
assert seen["entity_types"] is None # 空元组 = 不限类型,不是匹配空集合
async def test_no_results_records_gap_and_degrades(stubbed_repo: dict[str, Any]) -> None:
stubbed_repo["results"] = []
session = FakeSession()
svc = RetrievalService(session, embedding=FakeEmbedder())
outcome = await svc.search(
"不存在的知识点", user_ctx=UserContext(body_shape="apple", entity_types=("item",))
)
assert outcome.degraded is True
assert outcome.results == []
assert session.commits == 1 # gap 埋点立即提交:检索本身只读,别拖住调用方事务
assert stubbed_repo["gaps"] == [
(
"不存在的知识点",
{"body_shape": "apple", "occasion": None, "entity_types": ["item"]},
)
]
async def test_gap_failure_degrades_but_does_not_raise(
stubbed_repo: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
"""埋点失败不该把"检索空结果"变成异常 —— 空结果本身是合法结局."""
async def broken_gap(session: Any, query_text: str, filter_ctx: Any) -> None:
raise RuntimeError("db down")
monkeypatch.setattr(repo, "record_gap", broken_gap)
stubbed_repo["results"] = []
session = FakeSession()
svc = RetrievalService(session, embedding=FakeEmbedder())
outcome = await svc.search("anything")
assert outcome.degraded is True
assert session.rollbacks == 1
async def test_default_context_when_user_ctx_omitted(stubbed_repo: dict[str, Any]) -> None:
stubbed_repo["results"] = []
svc = RetrievalService(FakeSession(), embedding=FakeEmbedder())
outcome = await svc.search("no ctx")
assert outcome.degraded is True
assert stubbed_repo["gaps"] == [
("no ctx", {"body_shape": None, "occasion": None, "entity_types": []})
]
def test_service_module_reexports_for_dependency_injection() -> None:
"""service 与 repo 共享同一模块对象:monkeypatch 打桩路径就是生产调用路径."""
assert service_mod.repo is repo