Files
GEWU/tests/api/test_search_api.py
T
vasily 78033bb830
CI / quality (push) Canceled after 0s
feat: online verification, node removals, and knowledge browser
Adds claim-bound online evidence records and citation verification scripts,
node removal transport/adapter, retrieval benchmark and owner-review tooling,
GraphCanvas/SourceDetail knowledge browser UI, and the ingest handoff helper.
Bumps the agents/gewu pack to the online-verification branch.
2026-10-16 20:02:29 +08:00

710 lines
34 KiB
Python

import asyncio
import hashlib
import json
from datetime import UTC, datetime
from pathlib import Path
import httpx2
import yaml
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.types import TextContent
from gewu.adapters.git_repository import GitCanonicalRepository
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
from gewu.adapters.sqlite_search import SQLiteSearchIndex
from gewu.application.reading import CanonicalNodeReader
from gewu.application.search import CanonicalSearchService
from gewu.bootstrap.app import create_app
from gewu.domain.contracts import EdgeEndpoint, NodeType, TypedEdge
from tests.domain.proposal_fixtures import NODE, PROJECTS, SOURCE_REVISION
def test_authenticated_search_scope_through_http_and_mcp(tmp_path: Path) -> None:
async def exercise() -> None:
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
project_key = hashlib.sha256(NODE.project_id.encode()).hexdigest()
node_key = hashlib.sha256(NODE.node_id.encode()).hexdigest()
source_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest()
stale_node = NODE.model_copy(
update={
"as_of": datetime(2024, 1, 1, tzinfo=UTC),
"stale_after": datetime(2025, 1, 1, tzinfo=UTC),
}
)
problem_node = NODE.model_copy(
update={
"node_id": "dome:problem-browse-fixture",
"node_type": NodeType.PROBLEM,
"title": "Problem browse fixture",
"value": None,
"unit": None,
}
)
problem_key = hashlib.sha256(problem_node.node_id.encode()).hexdigest()
older_source = SOURCE_REVISION.model_copy(
update={
"source_id": "dome:older-pilot-report",
"revision_id": "dome:older-pilot-report:r1",
"title": "Older pilot installation report",
"captured_at": datetime(2025, 1, 1, tzinfo=UTC),
}
)
related = NODE.model_copy(update={"node_id": "z:dome-related"})
related_key = hashlib.sha256(related.node_id.encode()).hexdigest()
related_two = NODE.model_copy(update={"node_id": "zz:dome-related-two"})
related_two_key = hashlib.sha256(related_two.node_id.encode()).hexdigest()
edge = TypedEdge(
edge_id="dome:related-edge",
edge_type="compared_with",
source=EdgeEndpoint(kind="node", id=NODE.node_id),
target=EdgeEndpoint(kind="node", id=related.node_id),
)
edge_two = TypedEdge(
edge_id="dome:related-edge-two",
edge_type="compared_with",
source=EdgeEndpoint(kind="node", id=NODE.node_id),
target=EdgeEndpoint(kind="node", id=related_two.node_id),
)
repository.publish_files(
expected_head=repository.head(),
files={
f"projects/{project_key}/nodes/{node_key}.yaml": yaml.safe_dump(
stale_node.model_dump(mode="json")
).encode(),
f"projects/{project_key}/nodes/{problem_key}.yaml": yaml.safe_dump(
problem_node.model_dump(mode="json")
).encode(),
f"projects/{project_key}/nodes/{related_key}.yaml": yaml.safe_dump(
related.model_dump(mode="json")
).encode(),
f"projects/{project_key}/nodes/{related_two_key}.yaml": yaml.safe_dump(
related_two.model_dump(mode="json")
).encode(),
(
f"sources/{hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()}/"
f"{source_key}.yaml"
): yaml.safe_dump(SOURCE_REVISION.model_dump(mode="json")).encode(),
(
f"sources/{hashlib.sha256(older_source.source_id.encode()).hexdigest()}/"
f"{hashlib.sha256(older_source.revision_id.encode()).hexdigest()}.yaml"
): yaml.safe_dump(older_source.model_dump(mode="json")).encode(),
f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
edge.model_dump(mode="json")
).encode(),
(
f"edges/{hashlib.sha256(edge_two.edge_id.encode()).hexdigest()}.yaml"
): yaml.safe_dump(edge_two.model_dump(mode="json")).encode(),
},
message="Add searchable node",
index_directory=tmp_path / "publishing",
)
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
token = identities.provision(
credential_id="search-key",
principal_id="search-reader",
roles=("viewer",),
project_ids=("dome-performance",),
)
search = CanonicalSearchService(
index=SQLiteSearchIndex(tmp_path / "search.sqlite3", repository),
project_ids_provider=lambda: PROJECTS.keys(),
canonical_head=repository.head,
)
app = create_app(
identity_provider=identities,
node_reader=CanonicalNodeReader(repository),
search_service=search,
)
async with app.router.lifespan_context(app):
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=app),
base_url="http://localhost",
headers={"Authorization": f"Bearer {token}"},
) as http:
diagnostic_unbuilt = await http.get("/v1/diagnostics?projects=dome-performance")
assert diagnostic_unbuilt.status_code == 200
unbuilt_state = diagnostic_unbuilt.json()
assert unbuilt_state["index_state"] == "not_built"
assert unbuilt_state["index_revision"] is None
assert unbuilt_state["index_schema_version"] == 5
result = await http.post(
"/v1/search",
json={
"query": "400 visitors",
"scope": {
"projects": ["dome-performance"],
"include_shared": False,
},
"limit": 10,
"budget_tokens": 5000,
"include_evidence": False,
},
)
assert result.status_code == 200, result.text
envelope = result.json()
assert envelope["retrieval_mode"] == "keyword"
assert envelope["index_revision"] == repository.head()
assert envelope["data"][0]["node"]["node_id"] == NODE.node_id
assert envelope["data"][0]["evidence_included"] is False
compact_claim = envelope["data"][0]["node"]["claims"][0]
assert "evidence" not in compact_claim
assert compact_claim["evidence_count"] == len(NODE.claims[0].evidence)
assert envelope["citations"] == []
full_result = await http.post(
"/v1/search",
json={
"query": "400 visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 10,
"budget_tokens": 5000,
"include_evidence": True,
},
)
assert full_result.status_code == 200, full_result.text
full_row = full_result.json()["data"][0]
assert full_row["evidence_included"] is True
assert full_row["node"]["claims"][0]["evidence"]
assert full_result.json()["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote
browse = await http.post(
"/v1/knowledge/browse",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "limit": 2},
)
assert browse.status_code == 200, browse.text
browse_body = browse.json()
assert browse_body["project_node_count"] == 4
assert browse_body["filtered_node_count"] == 4
assert len(browse_body["nodes"]) == 2
assert browse_body["next_cursor"]
assert [item["title"] for item in browse_body["nodes"]] == sorted(
item["title"] for item in browse_body["nodes"]
)
assert browse_body["facets"]["tags"]
browse_page_two = await http.post(
"/v1/knowledge/browse",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "limit": 2, "cursor": browse_body["next_cursor"]},
)
assert browse_page_two.status_code == 200
assert len(browse_page_two.json()["nodes"]) == 2
assert browse_page_two.json()["next_cursor"] is None
filtered_browse = await http.post(
"/v1/knowledge/browse",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "node_types": ["problem"]},
)
assert filtered_browse.status_code == 200
assert filtered_browse.json()["filtered_node_count"] == 1
assert filtered_browse.json()["nodes"][0]["node_type"] == "problem"
assert filtered_browse.json()["project_node_count"] == 4
graph = await http.post(
"/v1/knowledge/graph",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}},
)
assert graph.status_code == 200, graph.text
assert len(graph.json()["nodes"]) == 4
assert len(graph.json()["edges"]) == 2
assert graph.json()["truncated"] is False
neighborhood = await http.post(
"/v1/knowledge/graph",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "center_node_id": NODE.node_id},
)
assert neighborhood.status_code == 200, neighborhood.text
assert neighborhood.json()["center_node_id"] == NODE.node_id
assert NODE.node_id in {item["node_id"] for item in neighborhood.json()["nodes"]}
assert len(neighborhood.json()["nodes"]) == 3
center_only = await http.post(
"/v1/knowledge/graph",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "center_node_id": NODE.node_id, "max_nodes": 1},
)
assert center_only.status_code == 200
assert [item["node_id"] for item in center_only.json()["nodes"]] == [NODE.node_id]
assert center_only.json()["edges"] == []
denied_neighborhood = await http.post(
"/v1/knowledge/graph",
json={"scope": {"projects": ["other-project"], "include_projects": [], "include_shared": False}, "center_node_id": NODE.node_id},
)
assert denied_neighborhood.status_code == 404
capped_graph = await http.post(
"/v1/knowledge/graph",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "max_nodes": 1},
)
assert capped_graph.status_code == 200
assert len(capped_graph.json()["nodes"]) == 1
assert capped_graph.json()["truncated"] is True
listed_sources = await http.post(
"/v1/sources",
json={
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 10,
"budget_tokens": 5000,
},
)
assert listed_sources.status_code == 200, listed_sources.text
source_nodes_resp = await http.get(
f"/v1/projects/dome-performance/sources/{SOURCE_REVISION.revision_id}/nodes"
)
assert source_nodes_resp.status_code == 200, source_nodes_resp.text
source_nodes_data = source_nodes_resp.json()
assert source_nodes_data["total"] >= 1
assert source_nodes_data["nodes"][0]["relation_basis"] in ("citation", "edge", "both")
assert listed_sources.json()["filtered_count"] == 2
assert listed_sources.json()["data"][0]["source"]["revision_id"] == (
SOURCE_REVISION.revision_id
)
source_page_one = await http.post(
"/v1/sources",
json={
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"budget_tokens": 5000,
},
)
source_cursor = source_page_one.json()["next_cursor"]
assert source_cursor
source_page_two = await http.post(
"/v1/sources",
json={
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"cursor": source_cursor,
"budget_tokens": 5000,
},
)
assert source_page_two.status_code == 200
assert source_page_two.json()["data"][0]["source"]["revision_id"] == (
older_source.revision_id
)
denied_sources = await http.post(
"/v1/sources",
json={
"scope": {"projects": ["interactive-video"], "include_shared": False},
"limit": 10,
"budget_tokens": 5000,
},
)
assert denied_sources.status_code == 403
problem_filter = await http.post(
"/v1/search",
json={
"query": "unmatched phrase",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"node_types": ["problem"],
"budget_tokens": 5000,
},
)
assert problem_filter.status_code == 200, problem_filter.text
assert problem_filter.json()["data"] == []
problem_browse = await http.post(
"/v1/search",
json={
"query": "",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"node_types": ["problem"],
"budget_tokens": 5000,
},
)
assert problem_browse.status_code == 200, problem_browse.text
assert [row["node"]["node_id"] for row in problem_browse.json()["data"]] == [
problem_node.node_id
]
unbounded_browse = await http.post(
"/v1/search",
json={
"query": "",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"budget_tokens": 5000,
},
)
assert unbounded_browse.status_code == 422
stale_filter = await http.post(
"/v1/search",
json={
"query": "400 visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"stale_only": True,
"budget_tokens": 5000,
},
)
assert stale_filter.status_code == 200, stale_filter.text
assert stale_filter.json()["data"][0]["node"]["node_id"] == NODE.node_id
facet_filter = await http.post(
"/v1/search",
json={
"query": "400 visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"tags": ["attendance"],
"audience": ["product"],
"use_for": ["comparison"],
"domain": ["production"],
"budget_tokens": 5000,
},
)
assert facet_filter.status_code == 200, facet_filter.text
assert facet_filter.json()["data"][0]["node"]["node_id"] == NODE.node_id
unmatched_facet = await http.post(
"/v1/search",
json={
"query": "400 visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"domain": ["unrelated-domain"],
"budget_tokens": 5000,
},
)
assert unmatched_facet.status_code == 200, unmatched_facet.text
assert unmatched_facet.json()["data"] == []
diagnostic_current = await http.get("/v1/diagnostics?projects=dome-performance")
assert diagnostic_current.status_code == 200
current_state = diagnostic_current.json()
assert current_state["index_state"] == "current"
assert current_state["index_revision"] == repository.head()
assert "proposal_submit" not in current_state["authorized_operations"]
projects = await http.get("/v1/projects")
assert projects.status_code == 200
assert projects.json() == ["dome-performance"]
page_one = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"budget_tokens": 5000,
},
)
assert page_one.status_code == 200
cursor = page_one.json()["next_cursor"]
assert cursor
page_two = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"cursor": cursor,
"budget_tokens": 5000,
},
)
assert page_two.status_code == 200
assert (
page_two.json()["data"][0]["node"]["node_id"]
!= page_one.json()["data"][0]["node"]["node_id"]
)
reused_cursor = await http.post(
"/v1/search",
json={
"query": "400 visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"cursor": cursor,
"budget_tokens": 5000,
},
)
assert reused_cursor.status_code == 422
changed_facet_cursor = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"node_types": ["problem"],
"limit": 1,
"cursor": cursor,
"budget_tokens": 5000,
},
)
assert changed_facet_cursor.status_code == 422
changed_tag_cursor = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"tags": ["attendance"],
"limit": 1,
"cursor": cursor,
"budget_tokens": 5000,
},
)
assert changed_tag_cursor.status_code == 422
adjacent = await http.post(
"/v1/neighbors",
json={
"node_id": NODE.node_id,
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"budget_tokens": 5000,
},
)
assert adjacent.status_code == 200, adjacent.text
assert adjacent.json()["data"][0]["neighbor"]["id"] == related.node_id
neighbor_cursor = adjacent.json()["next_cursor"]
assert neighbor_cursor
adjacent_two = await http.post(
"/v1/neighbors",
json={
"node_id": NODE.node_id,
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"cursor": neighbor_cursor,
"budget_tokens": 5000,
},
)
assert adjacent_two.status_code == 200
assert adjacent_two.json()["data"][0]["neighbor"]["id"] == related_two.node_id
assert adjacent_two.json()["next_cursor"] is None
assert {citation["quote"] for citation in adjacent.json()["citations"]} == {
NODE.claims[0].evidence[0].quote
}
newer = NODE.model_copy(update={"node_id": "new:unrelated-revision-node"})
newer_key = hashlib.sha256(newer.node_id.encode()).hexdigest()
repository.publish_files(
expected_head=repository.head(),
files={
f"projects/{project_key}/nodes/{newer_key}.yaml": yaml.safe_dump(
newer.model_dump(mode="json")
).encode()
},
message="Advance canonical index for cursor-expiry check",
index_directory=tmp_path / "publishing",
)
diagnostic_lag = await http.get("/v1/diagnostics?projects=dome-performance")
assert diagnostic_lag.status_code == 200
lag_state = diagnostic_lag.json()
assert lag_state["index_state"] == "lagging"
assert lag_state["index_revision"] != repository.head()
assert lag_state["canonical_revision"] == repository.head()
expired_search = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"cursor": cursor,
"budget_tokens": 5000,
},
)
assert expired_search.status_code == 409
expired_neighbors = await http.post(
"/v1/neighbors",
json={
"node_id": NODE.node_id,
"scope": {"projects": ["dome-performance"], "include_shared": False},
"limit": 1,
"cursor": neighbor_cursor,
"budget_tokens": 5000,
},
)
assert expired_neighbors.status_code == 409
too_small = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {
"projects": ["dome-performance"],
"include_shared": False,
},
"budget_tokens": 256,
},
)
assert too_small.status_code == 413
dated_confidence_search = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {
"projects": ["dome-performance"],
"include_shared": False,
},
"confidences": ["medium"],
"as_of_from": "2024-01-01",
"as_of_to": "2024-12-31",
"budget_tokens": 5000,
},
)
assert dated_confidence_search.status_code == 200, dated_confidence_search.text
assert [
row["node"]["node_id"] for row in dated_confidence_search.json()["data"]
] == [NODE.node_id]
filtered_page = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {
"projects": ["dome-performance"],
"include_shared": False,
},
"confidences": ["medium"],
"limit": 1,
"budget_tokens": 5000,
},
)
assert filtered_page.status_code == 200
filter_cursor = filtered_page.json()["next_cursor"]
assert filter_cursor
changed_filter_page = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {
"projects": ["dome-performance"],
"include_shared": False,
},
"confidences": ["low"],
"limit": 1,
"cursor": filter_cursor,
"budget_tokens": 5000,
},
)
assert changed_filter_page.status_code == 422
forbidden = await http.post(
"/v1/search",
json={
"query": "visitors",
"scope": {
"projects": ["interactive-video"],
"include_shared": False,
},
"budget_tokens": 5000,
},
)
assert forbidden.status_code == 403
async with Client(
streamable_http_client("http://localhost/mcp", http_client=http)
) as mcp:
tools = await mcp.list_tools()
assert {tool.name for tool in tools.tools} == {
"diagnostics",
"get_node",
"node_history",
"get_source",
"list_projects",
"search",
"neighbors",
"list_sources",
}
listed = await mcp.call_tool("list_projects", {})
assert not listed.is_error
assert isinstance(listed.content[0], TextContent)
assert json.loads(listed.content[0].text) == ["dome-performance"]
searched = await mcp.call_tool(
"search",
{
"query": "400 visitors",
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"limit": 10,
"budget_tokens": 5000,
},
)
assert not searched.is_error
assert isinstance(searched.content[0], TextContent)
assert (
json.loads(searched.content[0].text)["data"][0]["node"]["node_id"]
== NODE.node_id
)
dated_confidence_tool = await mcp.call_tool(
"search",
{
"query": "visitors",
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"limit": 10,
"budget_tokens": 5000,
"confidences": ["medium"],
"as_of_from": "2024-01-01",
"as_of_to": "2024-12-31",
},
)
assert not dated_confidence_tool.is_error
assert [
row["node"]["node_id"]
for row in json.loads(dated_confidence_tool.content[0].text)["data"]
] == [NODE.node_id]
filtered_search = await mcp.call_tool(
"search",
{
"query": "unmatched phrase",
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"limit": 10,
"budget_tokens": 5000,
"node_types": ["problem"],
},
)
assert not filtered_search.is_error
assert json.loads(filtered_search.content[0].text)["data"] == []
browsed_problems = await mcp.call_tool(
"search",
{
"query": "",
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"limit": 10,
"budget_tokens": 5000,
"node_types": ["problem"],
},
)
assert not browsed_problems.is_error
assert [
row["node"]["node_id"]
for row in json.loads(browsed_problems.content[0].text)["data"]
] == [problem_node.node_id]
facet_search = await mcp.call_tool(
"search",
{
"query": "400 visitors",
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"limit": 10,
"budget_tokens": 5000,
"tags": ["attendance"],
"audience": ["product"],
"use_for": ["comparison"],
"domain": ["production"],
},
)
assert not facet_search.is_error
assert (
json.loads(facet_search.content[0].text)["data"][0]["node"]["node_id"]
== NODE.node_id
)
listed_sources_tool = await mcp.call_tool(
"list_sources",
{
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"limit": 10,
"budget_tokens": 5000,
},
)
assert not listed_sources_tool.is_error
assert (
json.loads(listed_sources_tool.content[0].text)["data"][0]["source"][
"revision_id"
]
== SOURCE_REVISION.revision_id
)
adjacent_tool = await mcp.call_tool(
"neighbors",
{
"node_id": NODE.node_id,
"projects": ["dome-performance"],
"include_shared": False,
"all_projects": False,
"edge_types": [],
"limit": 10,
"budget_tokens": 5000,
},
)
assert not adjacent_tool.is_error
assert isinstance(adjacent_tool.content[0], TextContent)
assert (
json.loads(adjacent_tool.content[0].text)["data"][0]["neighbor"]["id"]
== related.node_id
)
asyncio.run(exercise())