702 lines
25 KiB
Python
702 lines
25 KiB
Python
import hashlib
|
|||
|
|
import sqlite3
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
from collections.abc import Callable
|
||
|
|
from datetime import UTC, date, datetime
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import cast
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import yaml
|
||
|
|
|
||
|
|
from gewu.adapters.git_repository import GitCanonicalRepository
|
||
|
|
from gewu.adapters.sqlite_search import SQLiteSearchIndex
|
||
|
|
from gewu.application.identity import Principal
|
||
|
|
from gewu.application.search import CanonicalSearchService, SearchError
|
||
|
|
from gewu.domain.contracts import (
|
||
|
|
Confidence,
|
||
|
|
EdgeEndpoint,
|
||
|
|
KnowledgeNode,
|
||
|
|
NeighborRequest,
|
||
|
|
NodeType,
|
||
|
|
Scope,
|
||
|
|
SearchRequest,
|
||
|
|
TypedEdge,
|
||
|
|
)
|
||
|
|
from gewu.domain.validation import assertion_digest, evidence_digest
|
||
|
|
from tests.domain.proposal_fixtures import NODE, PROJECTS, SOURCE_REVISION
|
||
|
|
|
||
|
|
|
||
|
|
def _node_path(node: KnowledgeNode) -> str:
|
||
|
|
project_key = hashlib.sha256(node.project_id.encode()).hexdigest()
|
||
|
|
node_key = hashlib.sha256(node.node_id.encode()).hexdigest()
|
||
|
|
return f"projects/{project_key}/nodes/{node_key}.yaml"
|
||
|
|
|
||
|
|
|
||
|
|
def _publish_nodes(
|
||
|
|
repository: GitCanonicalRepository, directory: Path, nodes: tuple[KnowledgeNode, ...]
|
||
|
|
) -> str:
|
||
|
|
return repository.publish_files(
|
||
|
|
expected_head=repository.head(),
|
||
|
|
files={
|
||
|
|
_node_path(node): yaml.safe_dump(
|
||
|
|
node.model_dump(mode="json"), allow_unicode=True
|
||
|
|
).encode()
|
||
|
|
for node in nodes
|
||
|
|
},
|
||
|
|
message="Publish search fixtures",
|
||
|
|
index_directory=directory,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _service(
|
||
|
|
repository: GitCanonicalRepository,
|
||
|
|
index_path: Path,
|
||
|
|
*,
|
||
|
|
head: Callable[[], str] | None = None,
|
||
|
|
) -> CanonicalSearchService:
|
||
|
|
return CanonicalSearchService(
|
||
|
|
index=SQLiteSearchIndex(index_path, repository),
|
||
|
|
project_ids_provider=lambda: PROJECTS.keys(),
|
||
|
|
canonical_head=head or repository.head,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_fts_search_enforces_explicit_project_and_shared_scope(tmp_path: Path) -> None:
|
||
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||
|
|
stale = NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"as_of": datetime(2024, 1, 1, tzinfo=UTC),
|
||
|
|
"stale_after": datetime(2025, 1, 1, tzinfo=UTC),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
video_node = NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"node_id": "video:branching-workflow",
|
||
|
|
"project_id": "interactive-video",
|
||
|
|
"title": "Interactive branching workflow",
|
||
|
|
"summary": "Video authors control branching and state growth.",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
shared_node = NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"node_id": "shared:visitor-benchmark",
|
||
|
|
"project_id": "shared",
|
||
|
|
"title": "Shared visitor benchmark",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
_publish_nodes(repository, tmp_path / "publishing", (stale, video_node, shared_node))
|
||
|
|
service = _service(repository, tmp_path / "search.sqlite3")
|
||
|
|
dome_reader = Principal(
|
||
|
|
"reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
|
||
|
|
)
|
||
|
|
dome_result = service.search(
|
||
|
|
SearchRequest(
|
||
|
|
query="visitors",
|
||
|
|
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||
|
|
budget_tokens=5000,
|
||
|
|
),
|
||
|
|
principal=dome_reader,
|
||
|
|
)
|
||
|
|
assert [cast(dict[str, object], row["node"])["node_id"] for row in dome_result.data] == [
|
||
|
|
NODE.node_id
|
||
|
|
]
|
||
|
|
assert any(warning.code == "STALE_NODE" for warning in dome_result.warnings)
|
||
|
|
assert dome_result.retrieval_mode == "keyword"
|
||
|
|
assert dome_result.canonical_revision == dome_result.index_revision
|
||
|
|
assert dome_result.tokens_used > 0
|
||
|
|
bounded = service.search(
|
||
|
|
SearchRequest(
|
||
|
|
query="visitors",
|
||
|
|
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||
|
|
budget_tokens=1500,
|
||
|
|
),
|
||
|
|
principal=dome_reader,
|
||
|
|
)
|
||
|
|
assert bounded.data == ()
|
||
|
|
assert bounded.citations == ()
|
||
|
|
assert bounded.omissions
|
||
|
|
assert bounded.warnings
|
||
|
|
assert bounded.next_cursor is not None
|
||
|
|
recovered = service.search(
|
||
|
|
SearchRequest(
|
||
|
|
query="visitors",
|
||
|
|
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||
|
|
cursor=bounded.next_cursor,
|
||
|
|
budget_tokens=5000,
|
||
|
|
),
|
||
|
|
principal=dome_reader,
|
||
|
|
)
|
||
|
|
recovered_node = cast(dict[str, object], recovered.data[0]["node"])
|
||
|
|
assert recovered_node["node_id"] == NODE.node_id
|
||
|
|
with pytest.raises(SearchError, match="too small"):
|
||
|
|
service.search(
|
||
|
|
SearchRequest(
|
||
|
|
query="visitors",
|
||
|
|
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||
|
|
budget_tokens=256,
|
||
|
|
),
|
||
|
|
principal=dome_reader,
|
||
|
|
)
|
||
|
|
with pytest.raises(SearchError, match="shared knowledge"):
|
||
|
|
service.search(
|
||
|
|
SearchRequest(
|
||
|
|
query="visitors",
|
||
|
|
scope=Scope(projects=("dome-performance",)),
|
||
|
|
budget_tokens=5000,
|
||
|
|
),
|
||
|
|
principal=dome_reader,
|
||
|
|
)
|
||
|
|
with pytest.raises(SearchError, match="every requested project"):
|
||
|
|
service.search(
|
||
|
|
SearchRequest(
|
||
|
|
query='visitors" OR *',
|
||
|
|
scope=Scope(
|
||
|
|
projects=("dome-performance", "interactive-video"), include_shared=False
|
||
|
|
),
|
||
|
|
budget_tokens=5000,
|
||
|
|
),
|
||
|
|
principal=dome_reader,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_assumption_and_limitation_claims_are_searchable_and_cited(tmp_path: Path) -> None:
|
||
|
|
original_claim = NODE.claims[0]
|
||
|
|
statement = "The assumptionspecific condition affects seasonal visitor counts."
|
||
|
|
statement_digest = assertion_digest(statement)
|
||
|
|
evidence = original_claim.evidence[0].model_copy(
|
||
|
|
update={
|
||
|
|
"assertion_path": "assumptions[0].text",
|
||
|
|
"assertion_digest": statement_digest,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
assessment = original_claim.assessment.model_copy(
|
||
|
|
update={
|
||
|
|
"assertion_digest": statement_digest,
|
||
|
|
"evidence_digests": (evidence_digest(evidence),),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
context_claim = original_claim.model_copy(
|
||
|
|
update={
|
||
|
|
"claim_id": "dome:assumption-search-test",
|
||
|
|
"text": statement,
|
||
|
|
"evidence": (evidence,),
|
||
|
|
"assessment": assessment,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
limitation_evidence = evidence.model_copy(update={"assertion_path": "limitations[0].text"})
|
||
|
|
limitation_claim = context_claim.model_copy(
|
||
|
|
update={
|
||
|
|
"claim_id": "dome:limitation-search-test",
|
||
|
|
"evidence": (limitation_evidence,),
|
||
|
|
"assessment": assessment.model_copy(
|
||
|
|
update={"evidence_digests": (evidence_digest(limitation_evidence),)}
|
||
|
|
),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
contextual_node = NODE.model_copy(
|
||
|
|
update={"assumptions": (context_claim,), "limitations": (limitation_claim,)}
|
||
|
|
)
|
||
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||
|
|
_publish_nodes(repository, tmp_path / "publishing", (contextual_node,))
|
||
|
|
service = _service(repository, tmp_path / "search.sqlite3")
|
||
|
|
reader = Principal(
|
||
|
|
"reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
|
||
|
|
)
|
||
|
|
|
||
|
|
result = service.search(
|
||
|
|
SearchRequest(
|
||
|
|
query="assumptionspecific",
|
||
|
|
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||
|
|
budget_tokens=50_000,
|
||
|
|
),
|
||
|
|
principal=reader,
|
||
|
|
)
|
||
|
|
found = cast(dict[str, object], result.data[0]["node"])
|
||
|
|
assert found["assumptions"][0]["text"] == statement
|
||
|
|
assert found["limitations"][0]["text"] == statement
|
||
|
|
assert any(item.quote == evidence.quote for item in result.citations)
|
||
|
|
|
||
|
|
|
||
|
|
def test_fts_search_indexes_entity_aliases(tmp_path: Path) -> None:
|
||
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||
|
|
entity = NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"node_type": NodeType.ENTITY,
|
||
|
|
"node_id": "dome:planetarium-entity",
|
||
|
|
"title": "Dome installation",
|
||
|
|
"aliases": ("Planetarium",),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
_publish_nodes(repository, tmp_path / "publishing", (entity,))
|
||
|
|
service = _service(repository, tmp_path / "search.sqlite3")
|
||
|
|
reader = Principal(
|
||
|
|
"reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
|
||
|
|
)
|
||
|
|
result = service.search(
|
||
|
|
SearchRequest(
|
||
|
|
query="Planetarium",
|
||
|
|
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||
|
|
budget_tokens=5000,
|
||
|
|
),
|
||
|
|
principal=reader,
|
||
|
|
)
|
||
|
|
assert [cast(dict[str, object], row["node"])["node_id"] for row in result.data] == [
|
||
|
|
entity.node_id
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def test_fts_search_applies_node_type_and_freshness_facets_in_sql(tmp_path: Path) -> None:
|
||
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||
|
|
stale_problem = NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"node_id": "dome:stale-visitor-problem",
|
||
|
|
"node_type": NodeType.PROBLEM,
|
||
|
|
"title": "Visitor count reporting problem",
|
||
|
|
"as_of": datetime(2023, 1, 1, tzinfo=UTC),
|
||
|
|
"stale_after": datetime(2024, 1, 1, tzinfo=UTC),
|
||
|
|
"value": None,
|
||
|
|
"unit": None,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
fresh_finding = NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"node_id": "dome:fresh-visitor-finding",
|
||
|
|
"node_type": NodeType.FINDING,
|
||
|
|
"title": "Visitor count finding",
|
||
|
|
"as_of": None,
|
||
|
|
"stale_after": None,
|
||
|
|
"value": None,
|
||
|
|
"unit": None,
|
||
|
|
"sample_size": None,
|
||
|
|
"reporting_year": None,
|
||
|
|
"tags": ("demo-only",),
|
||
|
|
"audience": ("sales",),
|
||
|
|
"use_for": ("demo",),
|
||
|
|
"domain": ("video-production",),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
_publish_nodes(repository, tmp_path / "publishing", (stale_problem, fresh_finding))
|
||
|
|
index = SQLiteSearchIndex(tmp_path / "search.sqlite3", repository)
|
||
|
|
|
||
|
|
problem_hits = index.search(
|
||
|
|
query="visitor",
|
||
|
|
project_ids=("dome-performance",),
|
||
|
|
include_shared=False,
|
||
|
|
limit=10,
|
||
|
|
node_types=("problem",),
|
||
|
|
)
|
||
|
|
assert [item.node.node_id for item in problem_hits.nodes] == [stale_problem.node_id]
|
||
|
|
|
||
|
|
browse_hits = index.search(
|
||
|
|
query="",
|
||
|
|
project_ids=("dome-performance",),
|
||
|
|
include_shared=False,
|
||
|
|
limit=10,
|
||
|
|
node_types=("problem",),
|
||
|
|
)
|
||
|
|
assert [item.node.node_id for item in browse_hits.nodes] == [stale_problem.node_id]
|
||
|
|
|
||
|
|
stale_hits = index.search(
|
||
|
|
query="visitor",
|
||
|
|
project_ids=("dome-performance",),
|
||
|
|
include_shared=False,
|
||
|
|
limit=10,
|
||
|
|
stale_only=True,
|
||
|
|
)
|
||
|
|
assert [item.node.node_id for item in stale_hits.nodes] == [stale_problem.node_id]
|
||
|
|
|
||
|
|
scoped_facet_hits = index.search(
|
||
|
|
query="visitor",
|
||
|
|
project_ids=("dome-performance",),
|
||
|
|
include_shared=False,
|
||
|
|
limit=10,
|
||
|
|
tags=("attendance",),
|
||
|
|
audience=("product",),
|
||
|
|
use_for=("comparison",),
|
||
|
|
domain=("production",),
|
||
|
|
)
|
||
|
|
assert [item.node.node_id for item in scoped_facet_hits.nodes] == [stale_problem.node_id]
|
||
|
|
|
||
|
|
or_facet_hits = index.search(
|
||
|
|
query="visitor",
|
||
|
|
project_ids=("dome-performance",),
|
||
|
|
include_shared=False,
|
||
|
|
limit=10,
|
||
|
|
tags=("attendance", "demo-only"),
|
||
|
|
)
|
||
|
|
assert {item.node.node_id for item in or_facet_hits.nodes} == {
|
||
|
|
stale_problem.node_id,
|
||
|
|
fresh_finding.node_id,
|
||
|
|
}
|
||
|
|
|
||
|
|
confidence_date_hits = index.search(
|
||
|
|
query="visitor",
|
||
|
|
project_ids=("dome-performance",),
|
||
|
|
include_shared=False,
|
||
|
|
limit=10,
|
||
|
|
confidences=(Confidence.MEDIUM,),
|
||
|
|
as_of_from=date(2023, 1, 1),
|
||
|
|
as_of_to=date(2023, 12, 31),
|
||
|
|
)
|
||
|
|
assert [item.node.node_id for item in confidence_date_hits.nodes] == [stale_problem.node_id]
|
||
|
|
|
||
|
|
mismatched_confidence_hits = index.search(
|
||
|
|
query="visitor",
|
||
|
|
project_ids=("dome-performance",),
|
||
|
|
include_shared=False,
|
||
|
|
limit=10,
|
||
|
|
confidences=(Confidence.LOW,),
|
||
|
|
as_of_from=date(2023, 1, 1),
|
||
|
|
as_of_to=date(2023, 12, 31),
|
||
|
|
)
|
||
|
|
assert not mismatched_confidence_hits.nodes
|
||
|
|
|
||
|
|
|
||
|
|
def test_typed_browse_uses_scoped_non_overlapping_cursor_pages(tmp_path: Path) -> None:
|
||
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||
|
|
first = NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"node_id": "dome:problem-a",
|
||
|
|
"node_type": NodeType.PROBLEM,
|
||
|
|
"title": "Problem A",
|
||
|
|
"value": None,
|
||
|
|
"unit": None,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
second = NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"node_id": "dome:problem-b",
|
||
|
|
"node_type": NodeType.PROBLEM,
|
||
|
|
"title": "Problem B",
|
||
|
|
"value": None,
|
||
|
|
"unit": None,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
other_project = NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"node_id": "video:problem-c",
|
||
|
|
"project_id": "interactive-video",
|
||
|
|
"node_type": NodeType.PROBLEM,
|
||
|
|
"title": "Problem C",
|
||
|
|
"value": None,
|
||
|
|
"unit": None,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
_publish_nodes(repository, tmp_path / "publishing", (first, second, other_project))
|
||
|
|
service = _service(repository, tmp_path / "search.sqlite3")
|
||
|
|
reader = Principal(
|
||
|
|
"reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
|
||
|
|
)
|
||
|
|
request = SearchRequest(
|
||
|
|
query="",
|
||
|
|
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||
|
|
node_types=(NodeType.PROBLEM,),
|
||
|
|
limit=1,
|
||
|
|
budget_tokens=5000,
|
||
|
|
)
|
||
|
|
|
||
|
|
page_one = service.search(request, principal=reader)
|
||
|
|
page_two = service.search(
|
||
|
|
request.model_copy(update={"cursor": page_one.next_cursor}), principal=reader
|
||
|
|
)
|
||
|
|
|
||
|
|
ids = [
|
||
|
|
cast(dict[str, object], row["node"])["node_id"] for row in (*page_one.data, *page_two.data)
|
||
|
|
]
|
||
|
|
assert ids == [first.node_id, second.node_id]
|
||
|
|
assert page_one.next_cursor is not None
|
||
|
|
assert page_two.next_cursor is None
|
||
|
|
|
||
|
|
|
||
|
|
def test_search_index_rebuilds_from_git_after_index_deletion_and_new_revision(
|
||
|
|
tmp_path: Path,
|
||
|
|
) -> None:
|
||
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||
|
|
_publish_nodes(repository, tmp_path / "publishing", (NODE,))
|
||
|
|
index_path = tmp_path / "search.sqlite3"
|
||
|
|
index = SQLiteSearchIndex(index_path, repository)
|
||
|
|
first = index.search(
|
||
|
|
query="visitors",
|
||
|
|
project_ids=("dome-performance",),
|
||
|
|
include_shared=False,
|
||
|
|
limit=10,
|
||
|
|
)
|
||
|
|
assert [item.node.node_id for item in first.nodes] == [NODE.node_id]
|
||
|
|
|
||
|
|
video_node = NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"node_id": "video:branching-workflow",
|
||
|
|
"project_id": "interactive-video",
|
||
|
|
"title": "Interactive video branches",
|
||
|
|
"summary": "Branching is costly for video authors.",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
new_revision = _publish_nodes(repository, tmp_path / "publishing", (video_node,))
|
||
|
|
second = index.search(
|
||
|
|
query="branches",
|
||
|
|
project_ids=("interactive-video",),
|
||
|
|
include_shared=False,
|
||
|
|
limit=10,
|
||
|
|
)
|
||
|
|
assert second.index_revision == new_revision
|
||
|
|
assert [item.node.node_id for item in second.nodes] == [video_node.node_id]
|
||
|
|
|
||
|
|
rebuilt = SQLiteSearchIndex(tmp_path / "rebuilt-search.sqlite3", repository).search(
|
||
|
|
query="visitors",
|
||
|
|
project_ids=("dome-performance",),
|
||
|
|
include_shared=False,
|
||
|
|
limit=10,
|
||
|
|
)
|
||
|
|
assert rebuilt.index_revision == new_revision
|
||
|
|
assert [item.node.node_id for item in rebuilt.nodes] == [NODE.node_id]
|
||
|
|
|
||
|
|
|
||
|
|
def test_os_termination_inside_index_rebuild_rolls_back_before_retrying(
|
||
|
|
tmp_path: Path,
|
||
|
|
) -> None:
|
||
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||
|
|
nodes = tuple(
|
||
|
|
NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"node_id": f"dome:rebuild-{index:02d}",
|
||
|
|
"title": f"Visitor evidence {index:02d}",
|
||
|
|
"summary": f"The installation hosted visitors in fixture record {index:02d}.",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
for index in range(8)
|
||
|
|
)
|
||
|
|
first_revision = _publish_nodes(repository, tmp_path / "publishing", nodes)
|
||
|
|
index_path = tmp_path / "search.sqlite3"
|
||
|
|
index = SQLiteSearchIndex(index_path, repository)
|
||
|
|
initial = index.search(
|
||
|
|
query="visitors",
|
||
|
|
project_ids=("dome-performance",),
|
||
|
|
include_shared=False,
|
||
|
|
limit=20,
|
||
|
|
)
|
||
|
|
assert len(initial.nodes) == len(nodes)
|
||
|
|
|
||
|
|
added_nodes = tuple(
|
||
|
|
NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"node_id": f"dome:rebuild-{index:02d}",
|
||
|
|
"title": f"Visitor evidence {index:02d}",
|
||
|
|
"summary": f"The installation hosted visitors in fixture record {index:02d}.",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
for index in range(8, 12)
|
||
|
|
)
|
||
|
|
all_nodes = (*nodes, *added_nodes)
|
||
|
|
latest_revision = _publish_nodes(repository, tmp_path / "publishing", added_nodes)
|
||
|
|
|
||
|
|
crashed = subprocess.run(
|
||
|
|
[
|
||
|
|
sys.executable,
|
||
|
|
"-m",
|
||
|
|
"tests.adapters.crash_sqlite_rebuild_worker",
|
||
|
|
str(repository.path),
|
||
|
|
str(index_path),
|
||
|
|
],
|
||
|
|
cwd=Path(__file__).parents[2],
|
||
|
|
capture_output=True,
|
||
|
|
check=False,
|
||
|
|
timeout=60,
|
||
|
|
)
|
||
|
|
assert crashed.returncode == 74, crashed.stderr.decode("utf-8", errors="replace")
|
||
|
|
|
||
|
|
with sqlite3.connect(index_path) as connection:
|
||
|
|
assert connection.execute("SELECT count(*) FROM indexed_nodes").fetchone() == (len(nodes),)
|
||
|
|
assert connection.execute("SELECT count(*) FROM node_fts").fetchone() == (len(nodes),)
|
||
|
|
prior_revision = connection.execute("SELECT revision FROM index_state").fetchone()
|
||
|
|
assert prior_revision == (first_revision,)
|
||
|
|
|
||
|
|
recovered = SQLiteSearchIndex(index_path, repository)
|
||
|
|
result = recovered.search(
|
||
|
|
query="visitors",
|
||
|
|
project_ids=("dome-performance",),
|
||
|
|
include_shared=False,
|
||
|
|
limit=20,
|
||
|
|
)
|
||
|
|
assert {hit.node.node_id for hit in result.nodes} == {node.node_id for node in all_nodes}
|
||
|
|
assert result.index_revision == latest_revision
|
||
|
|
assert recovered.diagnostic_status()[0] == latest_revision
|
||
|
|
|
||
|
|
|
||
|
|
def test_search_discloses_canonical_revision_advancing_during_the_query(
|
||
|
|
tmp_path: Path,
|
||
|
|
) -> None:
|
||
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||
|
|
_publish_nodes(repository, tmp_path / "publishing", (NODE,))
|
||
|
|
video_node = NODE.model_copy(
|
||
|
|
update={
|
||
|
|
"node_id": "video:branching-workflow",
|
||
|
|
"project_id": "interactive-video",
|
||
|
|
"title": "Interactive video branching",
|
||
|
|
"summary": "Authors control video branches.",
|
||
|
|
}
|
||
|
|
)
|
||
|
|
advanced = False
|
||
|
|
|
||
|
|
def canonical_head_after_index() -> str:
|
||
|
|
nonlocal advanced
|
||
|
|
if not advanced:
|
||
|
|
advanced = True
|
||
|
|
_publish_nodes(repository, tmp_path / "publishing", (video_node,))
|
||
|
|
return repository.head()
|
||
|
|
|
||
|
|
service = _service(
|
||
|
|
repository,
|
||
|
|
tmp_path / "search.sqlite3",
|
||
|
|
head=canonical_head_after_index,
|
||
|
|
)
|
||
|
|
principal = Principal(
|
||
|
|
"reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
|
||
|
|
)
|
||
|
|
response = service.search(
|
||
|
|
SearchRequest(
|
||
|
|
query="visitors",
|
||
|
|
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||
|
|
budget_tokens=5000,
|
||
|
|
),
|
||
|
|
principal=principal,
|
||
|
|
)
|
||
|
|
assert response.index_revision != response.canonical_revision
|
||
|
|
assert any(item.code == "INDEX_LAG" for item in response.warnings)
|
||
|
|
|
||
|
|
|
||
|
|
def test_search_cursor_expires_when_the_index_revision_changes(tmp_path: Path) -> None:
|
||
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||
|
|
second = NODE.model_copy(update={"node_id": "z:season-visitors"})
|
||
|
|
_publish_nodes(repository, tmp_path / "publishing", (NODE, second))
|
||
|
|
service = _service(repository, tmp_path / "search.sqlite3")
|
||
|
|
reader = Principal("reader", "key", frozenset({"viewer"}), frozenset({NODE.project_id}))
|
||
|
|
request = SearchRequest(
|
||
|
|
query="visitors",
|
||
|
|
scope=Scope(projects=(NODE.project_id,), include_shared=False),
|
||
|
|
limit=1,
|
||
|
|
budget_tokens=5000,
|
||
|
|
)
|
||
|
|
first_page = service.search(request, principal=reader)
|
||
|
|
assert first_page.next_cursor is not None
|
||
|
|
|
||
|
|
third = NODE.model_copy(update={"node_id": "zz:season-visitors"})
|
||
|
|
_publish_nodes(repository, tmp_path / "publishing", (third,))
|
||
|
|
with pytest.raises(SearchError, match="cursor expired") as error:
|
||
|
|
service.search(
|
||
|
|
request.model_copy(update={"cursor": first_page.next_cursor}), principal=reader
|
||
|
|
)
|
||
|
|
assert error.value.status_code == 409
|
||
|
|
|
||
|
|
|
||
|
|
def test_neighbors_are_rebuilt_and_hide_cross_project_nodes(tmp_path: Path) -> None:
|
||
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||
|
|
other = NODE.model_copy(update={"node_id": "video:other", "project_id": "interactive-video"})
|
||
|
|
edge = TypedEdge(
|
||
|
|
edge_id="edge:cross-project",
|
||
|
|
edge_type="compared_with",
|
||
|
|
source=EdgeEndpoint(kind="node", id=NODE.node_id),
|
||
|
|
target=EdgeEndpoint(kind="node", id=other.node_id),
|
||
|
|
)
|
||
|
|
files = {
|
||
|
|
_node_path(node): yaml.safe_dump(node.model_dump(mode="json")).encode()
|
||
|
|
for node in (NODE, other)
|
||
|
|
}
|
||
|
|
files[f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml"] = yaml.safe_dump(
|
||
|
|
edge.model_dump(mode="json")
|
||
|
|
).encode()
|
||
|
|
repository.publish_files(
|
||
|
|
expected_head=repository.head(),
|
||
|
|
files=files,
|
||
|
|
message="Publish typed relationship",
|
||
|
|
index_directory=tmp_path / "publishing",
|
||
|
|
)
|
||
|
|
index = SQLiteSearchIndex(tmp_path / "search.sqlite3", repository)
|
||
|
|
hits = index.neighbors(
|
||
|
|
node_id=NODE.node_id,
|
||
|
|
edge_types=(),
|
||
|
|
project_ids=(NODE.project_id, other.project_id),
|
||
|
|
include_shared=False,
|
||
|
|
limit=10,
|
||
|
|
)
|
||
|
|
assert hits.origin is not None and hits.origin.node_id == NODE.node_id
|
||
|
|
assert len(hits.edges) == 1
|
||
|
|
assert hits.edges[0].neighbor_node is not None
|
||
|
|
|
||
|
|
service = _service(repository, tmp_path / "search2.sqlite3")
|
||
|
|
reader = Principal("reader", "key", frozenset({"viewer"}), frozenset({NODE.project_id}))
|
||
|
|
response = service.neighbors(
|
||
|
|
NeighborRequest(
|
||
|
|
node_id=NODE.node_id,
|
||
|
|
scope=Scope(projects=(NODE.project_id,), include_shared=False),
|
||
|
|
budget_tokens=5000,
|
||
|
|
),
|
||
|
|
principal=reader,
|
||
|
|
)
|
||
|
|
assert response.data == ()
|
||
|
|
assert response.citations == ()
|
||
|
|
|
||
|
|
|
||
|
|
def test_neighbor_source_metadata_requires_an_ingested_for_scope(tmp_path: Path) -> None:
|
||
|
|
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||
|
|
edge = TypedEdge(
|
||
|
|
edge_id="edge:zzz-visible-source",
|
||
|
|
edge_type="derived_from",
|
||
|
|
source=EdgeEndpoint(kind="node", id=NODE.node_id),
|
||
|
|
target=EdgeEndpoint(kind="source", id=SOURCE_REVISION.revision_id),
|
||
|
|
)
|
||
|
|
private_source = SOURCE_REVISION.model_copy(
|
||
|
|
update={
|
||
|
|
"source_id": "video:private",
|
||
|
|
"revision_id": "video:private:r1",
|
||
|
|
"ingested_for": ("interactive-video",),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
private_edge = TypedEdge(
|
||
|
|
edge_id="edge:aaa-private-source",
|
||
|
|
edge_type="derived_from",
|
||
|
|
source=EdgeEndpoint(kind="node", id=NODE.node_id),
|
||
|
|
target=EdgeEndpoint(kind="source", id=private_source.revision_id),
|
||
|
|
)
|
||
|
|
source_key = hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()
|
||
|
|
revision_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest()
|
||
|
|
private_source_key = hashlib.sha256(private_source.source_id.encode()).hexdigest()
|
||
|
|
private_revision_key = hashlib.sha256(private_source.revision_id.encode()).hexdigest()
|
||
|
|
files = {
|
||
|
|
_node_path(NODE): yaml.safe_dump(NODE.model_dump(mode="json")).encode(),
|
||
|
|
f"sources/{source_key}/{revision_key}.yaml": yaml.safe_dump(
|
||
|
|
SOURCE_REVISION.model_dump(mode="json")
|
||
|
|
).encode(),
|
||
|
|
f"sources/{private_source_key}/{private_revision_key}.yaml": yaml.safe_dump(
|
||
|
|
private_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(private_edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
|
||
|
|
private_edge.model_dump(mode="json")
|
||
|
|
).encode(),
|
||
|
|
}
|
||
|
|
repository.publish_files(
|
||
|
|
expected_head=repository.head(),
|
||
|
|
files=files,
|
||
|
|
message="Publish source relationship",
|
||
|
|
index_directory=tmp_path / "publishing",
|
||
|
|
)
|
||
|
|
service = _service(repository, tmp_path / "search.sqlite3")
|
||
|
|
reader = Principal("reader", "key", frozenset({"viewer"}), frozenset({NODE.project_id}))
|
||
|
|
response = service.neighbors(
|
||
|
|
NeighborRequest(
|
||
|
|
node_id=NODE.node_id,
|
||
|
|
scope=Scope(projects=(NODE.project_id,), include_shared=False),
|
||
|
|
limit=1,
|
||
|
|
budget_tokens=5000,
|
||
|
|
),
|
||
|
|
principal=reader,
|
||
|
|
)
|
||
|
|
assert len(response.data) == 1
|
||
|
|
assert response.next_cursor is None
|
||
|
|
neighbor = cast(dict[str, object], response.data[0]["neighbor"])
|
||
|
|
assert neighbor["kind"] == "source"
|
||
|
|
assert cast(dict[str, object], neighbor["record"])["revision_id"] == SOURCE_REVISION.revision_id
|