"""M3 检索测试:切片、索引、跨租户。 检索最容易做错的地方不是"搜得准不准",而是"什么该被搜到、什么绝不能被搜到": 1. **草稿不能被检索到**。草稿是还没被人认可的话,让它出现等于把猜测当结论; 2. **刚发布就能搜到**。发布后要立刻建索引,否则用户会以为发布失败; 3. **检索结果必须带来源**,能点回原文; 4. **别的租户搜不到我的知识**,即使关键词一模一样。 """ from __future__ import annotations from typing import Any from httpx import AsyncClient from .conftest import create_workspace, login, unwrap V1 = "/api/v1" async def _knowledge( client: AsyncClient, user: dict[str, Any], ws_id: str, title: str, body: str, doc_type: str = "note", ) -> dict[str, Any]: return unwrap( await client.post( f"{V1}/workspaces/{ws_id}/knowledge", json={"title": title, "body_md": body, "doc_type": doc_type}, headers=user["headers"], ) ) async def _publish( client: AsyncClient, user: dict[str, Any], ws_id: str, doc: dict[str, Any] ) -> dict[str, Any]: return unwrap( await client.post( f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}/status", json={"action": "publish", "version": doc["version"]}, headers=user["headers"], ) ) async def _search( client: AsyncClient, user: dict[str, Any], ws_id: str, query: str, mode: str = "hybrid", ) -> list[dict[str, Any]]: return unwrap( await client.get( f"{V1}/workspaces/{ws_id}/knowledge/search", params={"q": query, "mode": mode}, headers=user["headers"], ) )["items"] async def test_published_is_searchable_draft_is_not(client: AsyncClient) -> None: owner = await login(client) ws = await create_workspace(client, owner, "检索测试") published = await _knowledge( client, owner, ws["id"], "报销发票怎么开", "电子发票原件必须一起寄,复印件不算。" ) parked = await _knowledge( client, owner, ws["id"], "供应商账号变更", "变更收款账号需要法人签字并盖章。" ) await _publish(client, owner, ws["id"], published) hits = await _search(client, owner, ws["id"], "发票") ids = {hit["item_id"] for hit in hits} assert published["id"] in ids assert parked["id"] not in ids, "草稿不该出现在检索结果里" assert hits[0]["score"] > 0 assert hits[0]["title"], "检索结果必须带来源标题" # 还没验证过的知识,关键词再怎么对也不该冒出来 draft_hits = await _search(client, owner, ws["id"], "法人签字") assert all(hit["item_id"] != parked["id"] for hit in draft_hits) async def test_publish_builds_chunks(client: AsyncClient) -> None: owner = await login(client) ws = await create_workspace(client, owner, "切片测试") body = "\n\n".join( [ "## 背景", "十月的报销单据被财务退回两次,第一次是发票复印件,第二次是缺少行程单。" * 20, "## 结论", "报销必须同时提供电子发票原件与行程单,缺一不可。" * 20, ] ) doc = await _knowledge(client, owner, ws["id"], "十月报销被退单复盘", body, "postmortem") published = await _publish(client, owner, ws["id"], doc) assert published["knowledge"]["chunk_count"] >= 2, "长正文应该被切成多片" detail = unwrap( await client.get( f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}", headers=owner["headers"] ) ) assert detail["knowledge"]["chunk_count"] >= 2 async def test_reindex_picks_up_edits(client: AsyncClient) -> None: owner = await login(client) ws = await create_workspace(client, owner, "重建索引") doc = await _knowledge(client, owner, ws["id"], "值班安排", "周一由张三负责。") published = await _publish(client, owner, ws["id"], doc) assert published["knowledge"]["chunk_count"] == 1 # 已发布的内容不许直接改(7003):产品上的路径是「归档 → 恢复成草稿 → 改」 archived = unwrap( await client.post( f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status", json={"action": "archive", "version": published["version"]}, headers=owner["headers"], ) ) draft = unwrap( await client.post( f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/status", json={"action": "restore", "version": archived["version"]}, headers=owner["headers"], ) ) patched = unwrap( await client.patch( f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}", json={ "body_md": "周一由张三负责,周三改由李四负责,节假日另行通知。", "version": draft["version"], }, headers=owner["headers"], ) ) # 改完正文但没重建索引,旧切片还在——这就是 reindex 存在的理由 before = await _search(client, owner, ws["id"], "李四") assert all("李四" not in hit["content"] for hit in before) result = unwrap( await client.post( f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}/reindex", headers=owner["headers"], ) ) assert result["chunk_count"] >= 1 # 仍是草稿,重建了也不该被检索到 assert result["searchable"] is False assert await _search(client, owner, ws["id"], "李四") == [] await _publish(client, owner, ws["id"], patched) after = await _search(client, owner, ws["id"], "李四") assert any("李四" in hit["content"] for hit in after) async def test_search_is_tenant_scoped(client: AsyncClient) -> None: alice = await login(client) bob = await login(client) ws_a = await create_workspace(client, alice, "甲队") ws_b = await create_workspace(client, bob, "乙队") doc = await _knowledge(client, alice, ws_a["id"], "发票规则", "电子发票原件必须一起寄。") await _publish(client, alice, ws_a["id"], doc) assert await _search(client, bob, ws_b["id"], "发票") == [] # 直接拿别人的工作区搜也不行 denied = await client.get( f"{V1}/workspaces/{ws_a['id']}/knowledge/search", params={"q": "发票"}, headers=bob["headers"], ) assert denied.status_code in (403, 404) async def test_keyword_mode_reports_lexical_score(client: AsyncClient) -> None: owner = await login(client) ws = await create_workspace(client, owner, "词法检索") doc = await _knowledge( client, owner, ws["id"], "顺丰单号核对", "单号 SF1234567890 必须与发货记录一致。" ) await _publish(client, owner, ws["id"], doc) hits = await _search(client, owner, ws["id"], "SF1234567890", mode="keyword") assert hits, "带专有编号的查询在词法模式下应该命中" assert hits[0]["lexical_score"] > 0 # keyword 模式里向量权重是 0:总分只由词法分决定 assert hits[0]["score"] == hits[0]["lexical_score"]