style: fix ruff lint violations across service and tests
CI / quality (push) Canceled after 0s

Wraps over-long lines, sorts imports, drops an unused local, and bumps the
agent submodule for its own import-sort fix so make check passes.
This commit is contained in:
vasily committed 2026-10-16 20:13:59 +08:00
1 parent 78033bb830
commit c492124fe1
12 files changed
+272 -68

No files matched your search

+1 -1
View File
@@ -13,6 +13,7 @@ from gewu.domain.contracts import (
IngestReviewItem, IngestReviewItem,
KnowledgeNode, KnowledgeNode,
NeighborRequest, NeighborRequest,
OnlineEvidence,
ProjectRecord, ProjectRecord,
ProposalReceipt, ProposalReceipt,
ProposalRevision, ProposalRevision,
@@ -26,7 +27,6 @@ from gewu.domain.contracts import (
ServiceDiagnostics, ServiceDiagnostics,
SourceRevisionRecord, SourceRevisionRecord,
TypedEdge, TypedEdge,
OnlineEvidence,
) )
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
+59 -20
View File
@@ -19,12 +19,12 @@ from gewu.application.search import (
BrowseIndexResult, BrowseIndexResult,
GraphIndexResult, GraphIndexResult,
IndexedNode, IndexedNode,
SourceRelatedNodeHit,
SourceNodesIndexResult,
SearchEdgeHit, SearchEdgeHit,
SearchEdgeResult, SearchEdgeResult,
SearchIndexResult, SearchIndexResult,
SourceIndexResult, SourceIndexResult,
SourceNodesIndexResult,
SourceRelatedNodeHit,
) )
from gewu.application.serialization import safe_yaml_record from gewu.application.serialization import safe_yaml_record
from gewu.domain.contracts import Confidence, KnowledgeNode, SourceRevisionRecord, TypedEdge from gewu.domain.contracts import Confidence, KnowledgeNode, SourceRevisionRecord, TypedEdge
@@ -113,7 +113,8 @@ class SQLiteSearchIndex:
) )
connection.execute( connection.execute(
"CREATE TABLE IF NOT EXISTS indexed_citations (" "CREATE TABLE IF NOT EXISTS indexed_citations ("
"node_id TEXT NOT NULL, project_id TEXT NOT NULL, source_revision_id TEXT NOT NULL, " "node_id TEXT NOT NULL, project_id TEXT NOT NULL, "
"source_revision_id TEXT NOT NULL, "
"PRIMARY KEY(node_id, source_revision_id))" "PRIMARY KEY(node_id, source_revision_id))"
) )
connection.execute( connection.execute(
@@ -700,7 +701,13 @@ class SQLiteSearchIndex:
project_scope = tuple(sorted(set(project_ids) | ({"shared"} if include_shared else set()))) project_scope = tuple(sorted(set(project_ids) | ({"shared"} if include_shared else set())))
if not project_scope: if not project_scope:
return BrowseIndexResult( return BrowseIndexResult(
revision, (), 0, 0, 0, {k: {} for k in ("tags", "audience", "use_for", "domain")}, False revision,
(),
0,
0,
0,
{k: {} for k in ("tags", "audience", "use_for", "domain")},
False,
) )
removal_filter = ( removal_filter = (
@@ -766,17 +773,37 @@ class SQLiteSearchIndex:
match = self._match_expression(query) match = self._match_expression(query)
if order == "title_asc": if order == "title_asc":
order_by = "LOWER(json_extract(indexed_nodes.node_json, '$.title')) ASC, json_extract(indexed_nodes.node_json, '$.title') ASC, indexed_nodes.node_id ASC" order_by = (
"LOWER(json_extract(indexed_nodes.node_json, '$.title')) ASC, "
"json_extract(indexed_nodes.node_json, '$.title') ASC, "
"indexed_nodes.node_id ASC"
)
elif order == "title_desc": elif order == "title_desc":
order_by = "LOWER(json_extract(indexed_nodes.node_json, '$.title')) DESC, json_extract(indexed_nodes.node_json, '$.title') DESC, indexed_nodes.node_id DESC" order_by = (
"LOWER(json_extract(indexed_nodes.node_json, '$.title')) DESC, "
"json_extract(indexed_nodes.node_json, '$.title') DESC, "
"indexed_nodes.node_id DESC"
)
elif order == "date_newest": elif order == "date_newest":
order_by = "(json_extract(indexed_nodes.node_json, '$.as_of') IS NULL) ASC, json_extract(indexed_nodes.node_json, '$.as_of') DESC, indexed_nodes.node_id ASC" order_by = (
"(json_extract(indexed_nodes.node_json, '$.as_of') IS NULL) ASC, "
"json_extract(indexed_nodes.node_json, '$.as_of') DESC, "
"indexed_nodes.node_id ASC"
)
elif order == "date_oldest": elif order == "date_oldest":
order_by = "(json_extract(indexed_nodes.node_json, '$.as_of') IS NULL) ASC, json_extract(indexed_nodes.node_json, '$.as_of') ASC, indexed_nodes.node_id ASC" order_by = (
"(json_extract(indexed_nodes.node_json, '$.as_of') IS NULL) ASC, "
"json_extract(indexed_nodes.node_json, '$.as_of') ASC, "
"indexed_nodes.node_id ASC"
)
elif order == "relevance" and match: elif order == "relevance" and match:
order_by = "score ASC, indexed_nodes.node_id ASC" order_by = "score ASC, indexed_nodes.node_id ASC"
else: else:
order_by = "LOWER(json_extract(indexed_nodes.node_json, '$.title')) ASC, json_extract(indexed_nodes.node_json, '$.title') ASC, indexed_nodes.node_id ASC" order_by = (
"LOWER(json_extract(indexed_nodes.node_json, '$.title')) ASC, "
"json_extract(indexed_nodes.node_json, '$.title') ASC, "
"indexed_nodes.node_id ASC"
)
try: try:
with closing(self._connect()) as connection: with closing(self._connect()) as connection:
@@ -794,7 +821,8 @@ class SQLiteSearchIndex:
if project_ids: if project_ids:
p_holders = ", ".join("?" for _ in project_ids) p_holders = ", ".join("?" for _ in project_ids)
p_row = connection.execute( p_row = connection.execute(
f"SELECT COUNT(*) FROM indexed_nodes WHERE project_id IN ({p_holders}){removal_filter}", f"SELECT COUNT(*) FROM indexed_nodes "
f"WHERE project_id IN ({p_holders}){removal_filter}",
project_ids, project_ids,
).fetchone() ).fetchone()
project_node_count = int(p_row[0]) if p_row else 0 project_node_count = int(p_row[0]) if p_row else 0
@@ -803,7 +831,8 @@ class SQLiteSearchIndex:
shared_node_count = 0 shared_node_count = 0
if include_shared: if include_shared:
s_row = connection.execute( s_row = connection.execute(
f"SELECT COUNT(*) FROM indexed_nodes WHERE project_id='shared'{removal_filter}" f"SELECT COUNT(*) FROM indexed_nodes "
f"WHERE project_id='shared'{removal_filter}"
).fetchone() ).fetchone()
shared_node_count = int(s_row[0]) if s_row else 0 shared_node_count = int(s_row[0]) if s_row else 0
@@ -825,7 +854,8 @@ class SQLiteSearchIndex:
if match: if match:
query_stmt = ( query_stmt = (
"SELECT indexed_nodes.node_json, bm25(node_fts) AS score " "SELECT indexed_nodes.node_json, bm25(node_fts) AS score "
f"FROM node_fts JOIN indexed_nodes USING(node_id) {where_clause} AND node_fts MATCH ? " f"FROM node_fts JOIN indexed_nodes USING(node_id) "
f"{where_clause} AND node_fts MATCH ? "
f"ORDER BY {order_by} LIMIT ? OFFSET ?" f"ORDER BY {order_by} LIMIT ? OFFSET ?"
) )
node_rows = connection.execute( node_rows = connection.execute(
@@ -852,7 +882,8 @@ class SQLiteSearchIndex:
for field in ("tags", "audience", "use_for", "domain"): for field in ("tags", "audience", "use_for", "domain"):
facet_stmt = ( facet_stmt = (
"SELECT facet.value, COUNT(DISTINCT indexed_nodes.node_id) " "SELECT facet.value, COUNT(DISTINCT indexed_nodes.node_id) "
f"FROM indexed_nodes, json_each(indexed_nodes.node_json, '$.' || ?) AS facet " f"FROM indexed_nodes, "
f"json_each(indexed_nodes.node_json, '$.' || ?) AS facet "
f"WHERE indexed_nodes.project_id IN ({scope_holders}){removal_filter} " f"WHERE indexed_nodes.project_id IN ({scope_holders}){removal_filter} "
"GROUP BY facet.value" "GROUP BY facet.value"
) )
@@ -884,7 +915,7 @@ class SQLiteSearchIndex:
limit: int = 20, limit: int = 20,
offset: int = 0, offset: int = 0,
) -> SourceNodesIndexResult | None: ) -> SourceNodesIndexResult | None:
revision = self._rebuild_if_needed() self._rebuild_if_needed()
project_scope = tuple(sorted(set(project_ids) | ({"shared"} if include_shared else set()))) project_scope = tuple(sorted(set(project_ids) | ({"shared"} if include_shared else set())))
if not project_scope: if not project_scope:
return None return None
@@ -904,7 +935,8 @@ class SQLiteSearchIndex:
# Check source revision grant # Check source revision grant
grant_row = connection.execute( grant_row = connection.execute(
f"SELECT 1 FROM indexed_source_grants WHERE source_revision_id=? AND project_id IN ({scope_holders}) LIMIT 1", f"SELECT 1 FROM indexed_source_grants WHERE source_revision_id=? "
f"AND project_id IN ({scope_holders}) LIMIT 1",
(source_revision_id, *project_scope), (source_revision_id, *project_scope),
).fetchone() ).fetchone()
if grant_row is None: if grant_row is None:
@@ -969,16 +1001,21 @@ class SQLiteSearchIndex:
count_row = connection.execute(count_stmt, (*filter_params, match)).fetchone() count_row = connection.execute(count_stmt, (*filter_params, match)).fetchone()
else: else:
count_stmt = ( count_stmt = (
f"{cte} SELECT COUNT(*) FROM combined JOIN indexed_nodes USING(node_id) {where_clause}" f"{cte} SELECT COUNT(*) FROM combined "
f"JOIN indexed_nodes USING(node_id) {where_clause}"
) )
count_row = connection.execute(count_stmt, filter_params).fetchone() count_row = connection.execute(count_stmt, filter_params).fetchone()
total = int(count_row[0]) if count_row else 0 total = int(count_row[0]) if count_row else 0
# Rows query # Rows query
order_by = "LOWER(json_extract(indexed_nodes.node_json, '$.title')) ASC, indexed_nodes.node_id ASC" order_by = (
"LOWER(json_extract(indexed_nodes.node_json, '$.title')) ASC, "
"indexed_nodes.node_id ASC"
)
if match: if match:
row_stmt = ( row_stmt = (
f"{cte} SELECT indexed_nodes.node_json, combined.relation_basis FROM combined " f"{cte} SELECT indexed_nodes.node_json, "
f"combined.relation_basis FROM combined "
f"JOIN indexed_nodes USING(node_id) JOIN node_fts USING(node_id) " f"JOIN indexed_nodes USING(node_id) JOIN node_fts USING(node_id) "
f"{where_clause} AND node_fts MATCH ? ORDER BY {order_by} LIMIT ? OFFSET ?" f"{where_clause} AND node_fts MATCH ? ORDER BY {order_by} LIMIT ? OFFSET ?"
) )
@@ -987,8 +1024,10 @@ class SQLiteSearchIndex:
).fetchall() ).fetchall()
else: else:
row_stmt = ( row_stmt = (
f"{cte} SELECT indexed_nodes.node_json, combined.relation_basis FROM combined " f"{cte} SELECT indexed_nodes.node_json, "
f"JOIN indexed_nodes USING(node_id) {where_clause} ORDER BY {order_by} LIMIT ? OFFSET ?" f"combined.relation_basis FROM combined "
f"JOIN indexed_nodes USING(node_id) "
f"{where_clause} ORDER BY {order_by} LIMIT ? OFFSET ?"
) )
rows = connection.execute( rows = connection.execute(
row_stmt, (*filter_params, limit + 1, offset) row_stmt, (*filter_params, limit + 1, offset)
+49 -15
View File
@@ -21,18 +21,18 @@ from gewu.domain.contracts import (
EdgeEndpoint, EdgeEndpoint,
EvidenceReference, EvidenceReference,
Finding, Finding,
KnowledgeNode,
KnowledgeBrowseRequest,
KnowledgeBrowseEnvelope, KnowledgeBrowseEnvelope,
KnowledgeBrowseRequest,
KnowledgeGraphEnvelope, KnowledgeGraphEnvelope,
KnowledgeGraphRequest, KnowledgeGraphRequest,
SourceRelatedNode, KnowledgeNode,
SourceRelatedNodesEnvelope,
NeighborRequest, NeighborRequest,
ReadEnvelope, ReadEnvelope,
Scope, Scope,
SearchRequest, SearchRequest,
SourceListRequest, SourceListRequest,
SourceRelatedNode,
SourceRelatedNodesEnvelope,
SourceRevisionRecord, SourceRevisionRecord,
TypedEdge, TypedEdge,
) )
@@ -165,7 +165,9 @@ class SearchIndex(Protocol):
def count_sources(self, *, project_ids: tuple[str, ...], include_shared: bool) -> int: ... def count_sources(self, *, project_ids: tuple[str, ...], include_shared: bool) -> int: ...
def graph(self, *, project_ids: tuple[str, ...], include_shared: bool, max_nodes: int, max_edges: int) -> GraphIndexResult: ... def graph(
self, *, project_ids: tuple[str, ...], include_shared: bool, max_nodes: int, max_edges: int
) -> GraphIndexResult: ...
def browse( def browse(
self, self,
@@ -616,8 +618,15 @@ class HybridSearchIndex:
def count_sources(self, *, project_ids: tuple[str, ...], include_shared: bool) -> int: def count_sources(self, *, project_ids: tuple[str, ...], include_shared: bool) -> int:
return self.lexical.count_sources(project_ids=project_ids, include_shared=include_shared) return self.lexical.count_sources(project_ids=project_ids, include_shared=include_shared)
def graph(self, *, project_ids: tuple[str, ...], include_shared: bool, max_nodes: int, max_edges: int) -> GraphIndexResult: def graph(
return self.lexical.graph(project_ids=project_ids, include_shared=include_shared, max_nodes=max_nodes, max_edges=max_edges) self, *, project_ids: tuple[str, ...], include_shared: bool, max_nodes: int, max_edges: int
) -> GraphIndexResult:
return self.lexical.graph(
project_ids=project_ids,
include_shared=include_shared,
max_nodes=max_nodes,
max_edges=max_edges,
)
def browse(self, **kwargs: Any) -> BrowseIndexResult: def browse(self, **kwargs: Any) -> BrowseIndexResult:
return self.lexical.browse(**kwargs) return self.lexical.browse(**kwargs)
@@ -709,7 +718,9 @@ class CanonicalSearchService:
state = "current" if index_revision == canonical_revision else "lagging" state = "current" if index_revision == canonical_revision else "lagging"
return canonical_revision, index_revision, state, schema_version return canonical_revision, index_revision, state, schema_version
def browse(self, request: KnowledgeBrowseRequest, *, principal: Principal) -> KnowledgeBrowseEnvelope: def browse(
self, request: KnowledgeBrowseRequest, *, principal: Principal
) -> KnowledgeBrowseEnvelope:
project_ids = self._resolve_scope(request.scope, principal) project_ids = self._resolve_scope(request.scope, principal)
fingerprint = hashlib.sha256(canonical_json({ fingerprint = hashlib.sha256(canonical_json({
"projects": project_ids, "projects": project_ids,
@@ -723,7 +734,9 @@ class CanonicalSearchService:
"to": request.as_of_to.isoformat() if request.as_of_to else None, "to": request.as_of_to.isoformat() if request.as_of_to else None,
"stale": request.stale_only, "order": request.order, "limit": request.limit, "stale": request.stale_only, "order": request.order, "limit": request.limit,
})).hexdigest() })).hexdigest()
offset, expected_revision = self._cursor_offset(request.cursor, fingerprint, max_offset=100_000) offset, expected_revision = self._cursor_offset(
request.cursor, fingerprint, max_offset=100_000
)
lexical = self.index.lexical if isinstance(self.index, HybridSearchIndex) else self.index lexical = self.index.lexical if isinstance(self.index, HybridSearchIndex) else self.index
result = lexical.browse( result = lexical.browse(
query=request.query, query=request.query,
@@ -761,7 +774,12 @@ class CanonicalSearchService:
canonical_revision = self.canonical_head() canonical_revision = self.canonical_head()
warnings: tuple[Finding, ...] = () warnings: tuple[Finding, ...] = ()
if result.index_revision != canonical_revision: if result.index_revision != canonical_revision:
warnings = (Finding(code="INDEX_LAG", message="Browse data reflects an older accepted revision."),) warnings = (
Finding(
code="INDEX_LAG",
message="Browse data reflects an older accepted revision.",
),
)
return KnowledgeBrowseEnvelope( return KnowledgeBrowseEnvelope(
request_id="browse-" + secrets.token_hex(12), request_id="browse-" + secrets.token_hex(12),
resolved_scope=Scope(projects=project_ids, include_shared=request.scope.include_shared), resolved_scope=Scope(projects=project_ids, include_shared=request.scope.include_shared),
@@ -824,7 +842,12 @@ class CanonicalSearchService:
canonical_revision = self.canonical_head() canonical_revision = self.canonical_head()
warnings: tuple[Finding, ...] = () warnings: tuple[Finding, ...] = ()
if result.index_revision != canonical_revision: if result.index_revision != canonical_revision:
warnings = (Finding(code="INDEX_LAG", message="Source nodes reflect an older accepted revision."),) warnings = (
Finding(
code="INDEX_LAG",
message="Source nodes reflect an older accepted revision.",
),
)
return SourceRelatedNodesEnvelope( return SourceRelatedNodesEnvelope(
request_id="srcnodes-" + secrets.token_hex(12), request_id="srcnodes-" + secrets.token_hex(12),
resolved_scope=Scope(projects=(project_id,), include_shared=include_shared), resolved_scope=Scope(projects=(project_id,), include_shared=include_shared),
@@ -972,7 +995,9 @@ class CanonicalSearchService:
), ),
) )
def graph(self, request: KnowledgeGraphRequest, *, principal: Principal) -> KnowledgeGraphEnvelope: def graph(
self, request: KnowledgeGraphRequest, *, principal: Principal
) -> KnowledgeGraphEnvelope:
project_ids = self._resolve_scope(request.scope, principal) project_ids = self._resolve_scope(request.scope, principal)
if request.center_node_id is None: if request.center_node_id is None:
result = self.index.graph( result = self.index.graph(
@@ -995,7 +1020,8 @@ class CanonicalSearchService:
if request.max_nodes > 1: if request.max_nodes > 1:
neighbors = self.index.neighbors( neighbors = self.index.neighbors(
node_id=origin.node_id, edge_types=(), project_ids=project_ids, node_id=origin.node_id, edge_types=(), project_ids=project_ids,
include_shared=request.scope.include_shared, limit=min(50, request.max_nodes - 1), include_shared=request.scope.include_shared,
limit=min(50, request.max_nodes - 1),
) )
for hit in neighbors.edges: for hit in neighbors.edges:
endpoint = hit.neighbor_endpoint endpoint = hit.neighbor_endpoint
@@ -1007,12 +1033,20 @@ class CanonicalSearchService:
index_revision=neighbors.index_revision if neighbors else scoped.index_revision, index_revision=neighbors.index_revision if neighbors else scoped.index_revision,
nodes=tuple(nodes_by_id.values()), nodes=tuple(nodes_by_id.values()),
edges=tuple(edges[:request.max_edges]), edges=tuple(edges[:request.max_edges]),
truncated=(neighbors.has_more if neighbors else False) or len(edges) > request.max_edges, truncated=(
(neighbors.has_more if neighbors else False)
or len(edges) > request.max_edges
),
) )
canonical_revision = self.canonical_head() canonical_revision = self.canonical_head()
warnings: tuple[Finding, ...] = () warnings: tuple[Finding, ...] = ()
if result.index_revision != canonical_revision: if result.index_revision != canonical_revision:
warnings = (Finding(code="INDEX_LAG", message="Graph data reflects an older accepted revision."),) warnings = (
Finding(
code="INDEX_LAG",
message="Graph data reflects an older accepted revision.",
),
)
return KnowledgeGraphEnvelope( return KnowledgeGraphEnvelope(
request_id="graph-" + secrets.token_hex(12), request_id="graph-" + secrets.token_hex(12),
resolved_scope=Scope(projects=project_ids, include_shared=request.scope.include_shared), resolved_scope=Scope(projects=project_ids, include_shared=request.scope.include_shared),
+1 -1
View File
@@ -50,11 +50,11 @@ from gewu.domain.contracts import (
GitCommitId, GitCommitId,
KnowledgeBrowseEnvelope, KnowledgeBrowseEnvelope,
KnowledgeGraphEnvelope, KnowledgeGraphEnvelope,
SourceRelatedNodesEnvelope,
ProjectRecord, ProjectRecord,
ReadEnvelope, ReadEnvelope,
RecordId, RecordId,
ServiceDiagnostics, ServiceDiagnostics,
SourceRelatedNodesEnvelope,
) )
from gewu.transports.accounts_http import register_account_routes from gewu.transports.accounts_http import register_account_routes
from gewu.transports.admin_http import register_admin_routes from gewu.transports.admin_http import register_admin_routes
+14 -3
View File
@@ -490,7 +490,12 @@ class OnlineEvidence(StrictRecord):
def public_http_url(cls, value: str) -> str: def public_http_url(cls, value: str) -> str:
parsed = urlsplit(value) parsed = urlsplit(value)
hostname = parsed.hostname hostname = parsed.hostname
if parsed.scheme not in {"http", "https"} or not hostname or parsed.username or parsed.password: if (
parsed.scheme not in {"http", "https"}
or not hostname
or parsed.username
or parsed.password
):
raise ValueError("online evidence requires an HTTP(S) URL without embedded credentials") raise ValueError("online evidence requires an HTTP(S) URL without embedded credentials")
if hostname.lower() == "localhost" or hostname.lower().endswith((".localhost", ".local")): if hostname.lower() == "localhost" or hostname.lower().endswith((".localhost", ".local")):
raise ValueError("web references must use a public hostname") raise ValueError("web references must use a public hostname")
@@ -830,14 +835,20 @@ class KnowledgeBrowseRequest(StrictRecord):
as_of_from: date | None = None as_of_from: date | None = None
as_of_to: date | None = None as_of_to: date | None = None
stale_only: bool = False stale_only: bool = False
order: Literal["title_asc", "title_desc", "date_newest", "date_oldest", "relevance"] = "title_asc" order: Literal[
"title_asc", "title_desc", "date_newest", "date_oldest", "relevance"
] = "title_asc"
limit: int = Field(default=20, ge=1, le=50) limit: int = Field(default=20, ge=1, le=50)
cursor: str | None = Field(default=None, max_length=512) cursor: str | None = Field(default=None, max_length=512)
budget_tokens: int = Field(ge=256, le=50_000) budget_tokens: int = Field(ge=256, le=50_000)
@model_validator(mode="after") @model_validator(mode="after")
def validate_browse(self) -> KnowledgeBrowseRequest: def validate_browse(self) -> KnowledgeBrowseRequest:
if self.as_of_from is not None and self.as_of_to is not None and self.as_of_from > self.as_of_to: if (
self.as_of_from is not None
and self.as_of_to is not None
and self.as_of_from > self.as_of_to
):
raise ValueError("as_of_from must not be later than as_of_to") raise ValueError("as_of_from must not be later than as_of_to")
if self.order == "relevance" and not self.query: if self.order == "relevance" and not self.query:
raise ValueError("relevance ordering requires a search query") raise ValueError("relevance ordering requires a search query")
+60 -13
View File
@@ -211,7 +211,11 @@ def _validate_node_enrichment(
for field in ("node_id", "project_id", "node_type", "created", "lifecycle"): for field in ("node_id", "project_id", "node_type", "created", "lifecycle"):
if getattr(previous, field) != getattr(updated, field): if getattr(previous, field) != getattr(updated, field):
findings.append( findings.append(
_finding("NODE_ENRICHMENT_IDENTITY_CHANGED", f"Enrichment cannot change {field}", path) _finding(
"NODE_ENRICHMENT_IDENTITY_CHANGED",
f"Enrichment cannot change {field}",
path,
)
) )
if updated.updated <= previous.updated: if updated.updated <= previous.updated:
findings.append( findings.append(
@@ -230,7 +234,11 @@ def _validate_node_enrichment(
new_items = {claim.claim_id: claim for claim in getattr(updated, field)} new_items = {claim.claim_id: claim for claim in getattr(updated, field)}
if not old_items.keys() <= new_items.keys(): if not old_items.keys() <= new_items.keys():
findings.append( findings.append(
_finding("NODE_ENRICHMENT_REMOVED_CLAIM", "Enrichment cannot remove accepted claims", f"{path}.{field}") _finding(
"NODE_ENRICHMENT_REMOVED_CLAIM",
"Enrichment cannot remove accepted claims",
f"{path}.{field}",
)
) )
continue continue
for claim_id, old_claim in old_items.items(): for claim_id, old_claim in old_items.items():
@@ -241,27 +249,58 @@ def _validate_node_enrichment(
or old_claim.evidence_type != new_claim.evidence_type or old_claim.evidence_type != new_claim.evidence_type
): ):
findings.append( findings.append(
_finding("NODE_ENRICHMENT_CHANGED_CLAIM", "Enrichment cannot alter an accepted assertion", claim_path) _finding(
"NODE_ENRICHMENT_CHANGED_CLAIM",
"Enrichment cannot alter an accepted assertion",
claim_path,
)
) )
continue continue
if not set(old_claim.online_evidence) <= set(new_claim.online_evidence): if not set(old_claim.online_evidence) <= set(new_claim.online_evidence):
findings.append( findings.append(
_finding("NODE_ENRICHMENT_REMOVED_ONLINE_EVIDENCE", "Enrichment cannot remove prior online evidence", claim_path) _finding(
"NODE_ENRICHMENT_REMOVED_ONLINE_EVIDENCE",
"Enrichment cannot remove prior online evidence",
claim_path,
)
) )
old_evidence = set(old_claim.evidence) old_evidence = set(old_claim.evidence)
if not old_evidence <= set(new_claim.evidence): if not old_evidence <= set(new_claim.evidence):
findings.append( findings.append(
_finding("NODE_ENRICHMENT_REMOVED_EVIDENCE", "Enrichment cannot remove accepted evidence", claim_path) _finding(
"NODE_ENRICHMENT_REMOVED_EVIDENCE",
"Enrichment cannot remove accepted evidence",
claim_path,
)
) )
if new_claim.evidence != old_claim.evidence and new_claim.assessment.assessor_id != proposer_id: if (
new_claim.evidence != old_claim.evidence
and new_claim.assessment.assessor_id != proposer_id
):
findings.append( findings.append(
_finding("ASSESSOR_ATTRIBUTION_MISMATCH", "New evidence requires an assessment by its proposer", claim_path) _finding(
"ASSESSOR_ATTRIBUTION_MISMATCH",
"New evidence requires an assessment by its proposer",
claim_path,
)
) )
if not set(previous.tags) <= set(updated.tags): if not set(previous.tags) <= set(updated.tags):
findings.append(_finding("NODE_ENRICHMENT_REMOVED_FACET", "Enrichment cannot remove existing tags", f"{path}.tags")) findings.append(
_finding(
"NODE_ENRICHMENT_REMOVED_FACET",
"Enrichment cannot remove existing tags",
f"{path}.tags",
)
)
for field in ("audience", "domain", "use_for"): for field in ("audience", "domain", "use_for"):
if not set(getattr(previous, field)) <= set(getattr(updated, field)): if not set(getattr(previous, field)) <= set(getattr(updated, field)):
findings.append(_finding("NODE_ENRICHMENT_REMOVED_FACET", "Enrichment cannot remove existing facets", f"{path}.{field}")) findings.append(
_finding(
"NODE_ENRICHMENT_REMOVED_FACET",
"Enrichment cannot remove existing facets",
f"{path}.{field}",
)
)
return tuple(findings) return tuple(findings)
@@ -378,11 +417,17 @@ def validate_proposal(
elif operation.operation == "update_node": elif operation.operation == "update_node":
if previous_node is None: if previous_node is None:
findings.append( findings.append(
_finding("NODE_NOT_FOUND", "Update target is not an accepted node", f"{path}.target_id") _finding(
"NODE_NOT_FOUND",
"Update target is not an accepted node",
f"{path}.target_id",
)
) )
else: else:
findings.extend( findings.extend(
_validate_node_enrichment(previous_node, node, proposal.proposer_id, f"{path}.node") _validate_node_enrichment(
previous_node, node, proposal.proposer_id, f"{path}.node"
)
) )
if node.node_type.value == "entity": if node.node_type.value == "entity":
possible_duplicates = [ possible_duplicates = [
@@ -444,7 +489,10 @@ def validate_proposal(
and old_claim.evidence == claim.evidence and old_claim.evidence == claim.evidence
and old_claim.assessment == claim.assessment and old_claim.assessment == claim.assessment
) )
if claim.assessment.assessor_id != proposal.proposer_id and not unchanged_prior_assessment: if (
claim.assessment.assessor_id != proposal.proposer_id
and not unchanged_prior_assessment
):
findings.append( findings.append(
_finding( _finding(
"ASSESSOR_ATTRIBUTION_MISMATCH", "ASSESSOR_ATTRIBUTION_MISMATCH",
@@ -873,7 +921,6 @@ def validate_proposal(
"EDGE_SOURCE_MISSING", "EDGE_SOURCE_MISSING",
"EDGE_PROJECT_MISSING", "EDGE_PROJECT_MISSING",
"EDGE_TYPE_MISMATCH", "EDGE_TYPE_MISMATCH",
"NODE_NOT_FOUND",
"POLICY_WEAKENING", "POLICY_WEAKENING",
"OUT_OF_SCOPE", "OUT_OF_SCOPE",
} }
+1 -1
View File
@@ -10,9 +10,9 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
from gewu.domain.contracts import ( from gewu.domain.contracts import (
AutoAcceptPolicy, AutoAcceptPolicy,
Confidence, Confidence,
NeighborRequest,
KnowledgeBrowseRequest, KnowledgeBrowseRequest,
KnowledgeGraphRequest, KnowledgeGraphRequest,
NeighborRequest,
NodeType, NodeType,
ProjectRecord, ProjectRecord,
RecordId, RecordId,
+4 -1
View File
@@ -528,7 +528,10 @@ def create_mcp_server(
stale_only: bool = False, stale_only: bool = False,
include_evidence: bool = True, include_evidence: bool = True,
) -> str: ) -> str:
"""Search accepted knowledge in scope. Set include_evidence=false for compact claim summaries; the default includes full citations.""" (
"Search accepted knowledge in scope. Set include_evidence=false "
"for compact claim summaries; the default includes full citations."
)
principal = _principal(ctx, identity_provider) principal = _principal(ctx, identity_provider)
try: try:
+4 -1
View File
@@ -31,7 +31,10 @@ def test_review_store_round_trips_jev_facet_arrays_and_idempotent_create(tmp_pat
{ {
"facet": "tags", "facet": "tags",
"value": "spatial sound", "value": "spatial sound",
"definition": "Audio techniques that place sound in immersive spatial configurations.", "definition": (
"Audio techniques that place sound in immersive "
"spatial configurations."
),
}, },
), ),
) )
+74 -10
View File
@@ -165,10 +165,20 @@ def test_authenticated_search_scope_through_http_and_mcp(tmp_path: Path) -> None
full_row = full_result.json()["data"][0] full_row = full_result.json()["data"][0]
assert full_row["evidence_included"] is True assert full_row["evidence_included"] is True
assert full_row["node"]["claims"][0]["evidence"] assert full_row["node"]["claims"][0]["evidence"]
assert full_result.json()["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote assert (
full_result.json()["citations"][0]["quote"]
== NODE.claims[0].evidence[0].quote
)
browse = await http.post( browse = await http.post(
"/v1/knowledge/browse", "/v1/knowledge/browse",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "limit": 2}, json={
"scope": {
"projects": ["dome-performance"],
"include_projects": [],
"include_shared": False,
},
"limit": 2,
},
) )
assert browse.status_code == 200, browse.text assert browse.status_code == 200, browse.text
browse_body = browse.json() browse_body = browse.json()
@@ -182,14 +192,29 @@ def test_authenticated_search_scope_through_http_and_mcp(tmp_path: Path) -> None
assert browse_body["facets"]["tags"] assert browse_body["facets"]["tags"]
browse_page_two = await http.post( browse_page_two = await http.post(
"/v1/knowledge/browse", "/v1/knowledge/browse",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "limit": 2, "cursor": browse_body["next_cursor"]}, 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 browse_page_two.status_code == 200
assert len(browse_page_two.json()["nodes"]) == 2 assert len(browse_page_two.json()["nodes"]) == 2
assert browse_page_two.json()["next_cursor"] is None assert browse_page_two.json()["next_cursor"] is None
filtered_browse = await http.post( filtered_browse = await http.post(
"/v1/knowledge/browse", "/v1/knowledge/browse",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "node_types": ["problem"]}, json={
"scope": {
"projects": ["dome-performance"],
"include_projects": [],
"include_shared": False,
},
"node_types": ["problem"],
},
) )
assert filtered_browse.status_code == 200 assert filtered_browse.status_code == 200
assert filtered_browse.json()["filtered_node_count"] == 1 assert filtered_browse.json()["filtered_node_count"] == 1
@@ -197,7 +222,13 @@ def test_authenticated_search_scope_through_http_and_mcp(tmp_path: Path) -> None
assert filtered_browse.json()["project_node_count"] == 4 assert filtered_browse.json()["project_node_count"] == 4
graph = await http.post( graph = await http.post(
"/v1/knowledge/graph", "/v1/knowledge/graph",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}}, json={
"scope": {
"projects": ["dome-performance"],
"include_projects": [],
"include_shared": False,
},
},
) )
assert graph.status_code == 200, graph.text assert graph.status_code == 200, graph.text
assert len(graph.json()["nodes"]) == 4 assert len(graph.json()["nodes"]) == 4
@@ -205,7 +236,14 @@ def test_authenticated_search_scope_through_http_and_mcp(tmp_path: Path) -> None
assert graph.json()["truncated"] is False assert graph.json()["truncated"] is False
neighborhood = await http.post( neighborhood = await http.post(
"/v1/knowledge/graph", "/v1/knowledge/graph",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "center_node_id": NODE.node_id}, 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.status_code == 200, neighborhood.text
assert neighborhood.json()["center_node_id"] == NODE.node_id assert neighborhood.json()["center_node_id"] == NODE.node_id
@@ -213,19 +251,41 @@ def test_authenticated_search_scope_through_http_and_mcp(tmp_path: Path) -> None
assert len(neighborhood.json()["nodes"]) == 3 assert len(neighborhood.json()["nodes"]) == 3
center_only = await http.post( center_only = await http.post(
"/v1/knowledge/graph", "/v1/knowledge/graph",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "center_node_id": NODE.node_id, "max_nodes": 1}, 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 center_only.status_code == 200
assert [item["node_id"] for item in center_only.json()["nodes"]] == [NODE.node_id] assert [item["node_id"] for item in center_only.json()["nodes"]] == [NODE.node_id]
assert center_only.json()["edges"] == [] assert center_only.json()["edges"] == []
denied_neighborhood = await http.post( denied_neighborhood = await http.post(
"/v1/knowledge/graph", "/v1/knowledge/graph",
json={"scope": {"projects": ["other-project"], "include_projects": [], "include_shared": False}, "center_node_id": NODE.node_id}, json={
"scope": {
"projects": ["other-project"],
"include_projects": [],
"include_shared": False,
},
"center_node_id": NODE.node_id,
},
) )
assert denied_neighborhood.status_code == 404 assert denied_neighborhood.status_code == 404
capped_graph = await http.post( capped_graph = await http.post(
"/v1/knowledge/graph", "/v1/knowledge/graph",
json={"scope": {"projects": ["dome-performance"], "include_projects": [], "include_shared": False}, "max_nodes": 1}, json={
"scope": {
"projects": ["dome-performance"],
"include_projects": [],
"include_shared": False,
},
"max_nodes": 1,
},
) )
assert capped_graph.status_code == 200 assert capped_graph.status_code == 200
assert len(capped_graph.json()["nodes"]) == 1 assert len(capped_graph.json()["nodes"]) == 1
@@ -245,7 +305,11 @@ def test_authenticated_search_scope_through_http_and_mcp(tmp_path: Path) -> None
assert source_nodes_resp.status_code == 200, source_nodes_resp.text assert source_nodes_resp.status_code == 200, source_nodes_resp.text
source_nodes_data = source_nodes_resp.json() source_nodes_data = source_nodes_resp.json()
assert source_nodes_data["total"] >= 1 assert source_nodes_data["total"] >= 1
assert source_nodes_data["nodes"][0]["relation_basis"] in ("citation", "edge", "both") assert source_nodes_data["nodes"][0]["relation_basis"] in (
"citation",
"edge",
"both",
)
assert listed_sources.json()["filtered_count"] == 2 assert listed_sources.json()["filtered_count"] == 2
assert listed_sources.json()["data"][0]["source"]["revision_id"] == ( assert listed_sources.json()["data"][0]["source"]["revision_id"] == (
+4 -1
View File
@@ -283,7 +283,10 @@ def test_existing_node_can_gain_facets_without_replacing_its_assertion() -> None
existing = NODE existing = NODE
enriched = NODE.model_copy( enriched = NODE.model_copy(
update={"updated": NOW + timedelta(minutes=1), "tags": (*NODE.tags, "interactive-storytelling")} update={
"updated": NOW + timedelta(minutes=1),
"tags": (*NODE.tags, "interactive-storytelling"),
},
) )
draft = ProposalRevision( draft = ProposalRevision(
proposal_id="proposal-enrich-facets", proposal_id="proposal-enrich-facets",