diff --git a/.env.example b/.env.example index 11d0a674b..1ec19b7bb 100644 --- a/.env.example +++ b/.env.example @@ -46,4 +46,10 @@ LLM_GATEWAY_EMBEDDING_MODEL= LLM_API_GATEWAY= LLM_API_KEY= CALDAV_BASE_URL= +RANKWEAVE_DISABLED= + +# Optional process HMAC for GET /api/ontology/neighborhood source continuation. +# Empty keeps truncated-without-cursor. Do not reuse OIDC or orchestrator secrets. +# Must be at least 32 bytes when source paging is enabled. +ONTOLOGY_SOURCE_CURSOR_SECRET= CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS= diff --git a/AGENTS.md b/AGENTS.md index b4c64b52b..5151a7ea5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,9 @@ scattered short records. See [ARCHITECTURE.md](ARCHITECTURE.md) for the design, [ADR 0084](docs/adr/0084-lineage-research-grounding.md) for the normative research-grounding policy, and [`docs/lineage-bi-research-notes.md`](docs/lineage-bi-research-notes.md) for -supporting literature and aggregate evidence. +supporting literature and aggregate evidence. Event Lineage (reconstructed +post-to-post parents) is distinct from the typed ontology neighborhood +(ADR 0168); source-window continuation is ADR 0124. Do not mix those graphs. ## Hard rule: no real data in repository artifacts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index acd97d574..e7ac85c9c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -79,6 +79,8 @@ flowchart LR | `post_chat.py` | Pluggable in-popup chat's reason-and-cite step (retrieve step lives in `backend/app/post_chat_ingestion.py`) | | `commitment_extraction.py` | Pluggable LLM derivation of a customer commitment (promise + deadline) from a post; `Null` default, `ContextualOrchestrator` real impl | | `ontology.py` | Loads `docs/ontology/lineageweave-kg.ttl`, the formal OWL 2/RDFS/SKOS vocabulary for the Knowledge Graph's node/edge types (ADR 0004) | +| `ontology_neighborhood.py` | Bounded typed ontology/provenance neighborhood (ADR 0168); PostgreSQL stays authoritative, OWL subclass is not an instance edge | +| `ontology_source_cursor.py` | Opaque HMAC source-window continuation (ADR 0124); keyset pagination, never OFFSET | | `period_report.py` | Fit GRM/GPCM on persisted IRT rows, FIPC-select, EAP-score a period (ADR 0003 slice 3; Bock & Mislevy, 1982) | | `fixtures.py` | Synthetic demo dataset -- no real data ships in this repo | | `server.py` | Legacy stdlib HTTP server for the library-level synthetic fixture demo; production uses FastAPI/PostgreSQL | diff --git a/CHANGELOG.d/2.13.0-ontology-explorer.md b/CHANGELOG.d/2.13.0-ontology-explorer.md new file mode 100644 index 000000000..7893733a1 --- /dev/null +++ b/CHANGELOG.d/2.13.0-ontology-explorer.md @@ -0,0 +1,17 @@ +# 2.13.0 — Ontology provenance explorer + +- Added `GET /api/ontology/neighborhood` as a bounded typed instance graph + (ADR 0168 / issue #341), distinct from Event Lineage. +- PostgreSQL stays authoritative; OWL/RDF/JSON-LD is a projection. +- SKOS broader comes from `corporate_entity.parent_entity_id`; OWL subclass + is rejected as an instance edge. +- Hidden endpoints remove the edge with no omitted-count side channel. +- Catalog metadata is the only source for node truth and timestamps; missing + labels and unauthorized endpoints are omitted without leaking counts. +- Neighborhood SQL now bounds traversal from the focus node, and CSV/JSON-LD + exports remain aligned with the active Buyer search filter. +- Workspace surface: Keyman **Inspect ontology neighborhood**, exact-value table, + CSV/JSON-LD export, and Storybook states. No second GNB destination. +- Session-token removal clears a previously loaded neighborhood. Static catalog + snapshots hide live refocus. Corporate parents require their own visible-post + evidence. OWL-Time is cited as a W3C Candidate Recommendation Draft. diff --git a/CHANGELOG.d/2.13.0-provider-error-boundary.md b/CHANGELOG.d/2.13.0-provider-error-boundary.md new file mode 100644 index 000000000..99a2fbd4d --- /dev/null +++ b/CHANGELOG.d/2.13.0-provider-error-boundary.md @@ -0,0 +1,4 @@ +## Fixed + +- Keep contextual-orchestrator, OIDC, RankWeave, TEPP, and durable-ingestion diagnostics behind stable product error boundaries while retaining the original exception for server-side chaining. +- Keep browser 5xx and transport failures behind the same stable client error boundary. diff --git a/CHANGELOG.d/2.14.0-ontology-source-cursor.md b/CHANGELOG.d/2.14.0-ontology-source-cursor.md new file mode 100644 index 000000000..714a5bbf0 --- /dev/null +++ b/CHANGELOG.d/2.14.0-ontology-source-cursor.md @@ -0,0 +1,10 @@ +# 2.14.0 — Ontology source-window continuation + +- Added a versioned opaque `src.v2.` AES-GCM cursor so buyers can continue an + authorized ontology neighborhood beyond the bounded SQL source window + (ADR 0124/0125 / issue #363); the custom v1 keystream format is rejected. +- Continuation uses keyset pagination of the recursive candidate window, not + `OFFSET`. Tamper, scope, version, expiry, and snapshot drift fail closed. +- `ONTOLOGY_SOURCE_CURSOR_SECRET` is a process secret, never an OIDC or + orchestrator credential. Missing or short secrets withhold the cursor. +- The explorer accumulates subsequent pages without losing selected evidence. diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b6a008e..1e7630dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ All notable changes to this project are documented here. Format follows ### Added +- Ontology neighborhoods can continue beyond the bounded SQL source window + with a versioned opaque HMAC cursor and keyset pagination (ADR 0124 / #363). + A missing process secret keeps the truncated-without-cursor contract. +- Bounded ontology/provenance neighborhood (`GET /api/ontology/neighborhood`) + with typed Post/Person/CorporateEntity/Team nodes, SKOS broader distinct + from OWL subclass, truth-status vocabulary, knowledge-cutoff binding, and + a Keyman-panel explorer that is not Event Lineage (ADR 0168 / #341). - Corroborated SKOS `altLabel` / `prefLabel` pairs now expand corporate catalog candidates so a synthetic short form (`AGP`) and full form (`Aurora Grid Power`) bind one `corporate_entity` row instead of @@ -48,6 +55,17 @@ All notable changes to this project are documented here. Format follows - `make smoke` and `make seed` now run through the locked project `uv` environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +- Ontology neighborhoods now enforce request bounds before database access, + apply node-level ABAC, omit unlabeled endpoints, preserve catalog-owned node + metadata, and keep typed endpoint IDs unambiguous. Workspace CSV and JSON-LD + exports now represent the same filtered graph. +- All OpenAI-compatible chat-completion consumers now validate the shared + response envelope before parsing it, preventing malformed provider bodies + from escaping as raw `KeyError` or response-shape details. +- Ontology Explorer now clears a previously loaded neighborhood when the + session token is removed, hides live refocus on static catalog snapshots, + and looks up corporate-parent visibility independently of the child. OWL-Time + is cited as a W3C Candidate Recommendation Draft. ## [2.12.26] - 2026-08-24 diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index f7da2969b..5d074d88f 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -148,6 +148,15 @@ order by run.requested_at desc """ +_COUNTS_BY_RUN_SQL = """ + select run.analysis_run_id, counts.count_type_code, counts.count_value + from analysis_run run + join analysis_source_count counts + on counts.analysis_source_snapshot_id = run.analysis_source_snapshot_id + where run.analysis_run_id = any($1::uuid[]) + order by counts.count_type_code +""" + def scope_grouping_key(row: Any) -> str | None: """Persist the reconstruct grouping key for the run's authorized scope. @@ -195,16 +204,9 @@ async def _counts_by_run( """Load aggregate snapshot counts for the given runs.""" if not run_ids: return {} - # Safe SQL: this immutable aggregate query has closed schema text; run ids are bound below. + # Safe SQL: this immutable aggregate query has closed schema text; run ids remain bound below. rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - f""" - select run.analysis_run_id, counts.count_type_code, counts.count_value - from analysis_run run - join analysis_source_count counts - on counts.analysis_source_snapshot_id = run.analysis_source_snapshot_id - where run.analysis_run_id = any($1::uuid[]) - order by counts.count_type_code - """, + _COUNTS_BY_RUN_SQL, run_ids, ) grouped: dict[str, list[asyncpg.Record]] = {} diff --git a/backend/app/config.py b/backend/app/config.py index ce155e5d8..d229096ec 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -62,6 +62,7 @@ class Settings: tepp_api_key: str caldav_base_url: str rankweave_disabled: bool + ontology_source_cursor_secret: str @property def keycloak_jwks_uri(self) -> str: @@ -175,4 +176,5 @@ def load_settings() -> Settings: .strip() .lower() in {"1", "true", "yes", "on"}, + ontology_source_cursor_secret=os.environ.get("ONTOLOGY_SOURCE_CURSOR_SECRET", "").strip(), ) diff --git a/backend/app/global_ask_queue.py b/backend/app/global_ask_queue.py index a4d311d63..c93e4852d 100644 --- a/backend/app/global_ask_queue.py +++ b/backend/app/global_ask_queue.py @@ -280,7 +280,21 @@ async def process_global_ask_job( # the task silently and strand the row `running` until orphan # recovery (observed live) — every failure settles the job. _logger.exception("global ask job failed for job_id=%s", job_id) - detail = str(exc) or f"job exceeded the {JOB_DEADLINE_SECONDS}s deadline" + if isinstance(exc, (PermissionError, ConnectionError)): + # Raised locally with a pre-authored, safe message (permission + # state / missing config) — never a provider-boundary leak. + detail = str(exc) + elif isinstance(exc, asyncio.TimeoutError): + detail = f"job exceeded the {JOB_DEADLINE_SECONDS}s deadline" + else: + # Provider responses/exceptions can carry credentials, gateway + # diagnostics, or model output (ADR 0123): never persist the + # raw exception text as a durable `failure_detail`. The + # traceback just logged keeps it for operator debugging only. + detail = ( + "Ask Agent is unavailable: contextual-orchestrator returned " + "no complete evidence object" + ) async with pool.acquire() as conn: await conn.execute( """ diff --git a/backend/app/main.py b/backend/app/main.py index 24e94f15a..8aec031d1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,6 +21,7 @@ import asyncio import json +import logging from contextlib import asynccontextmanager from dataclasses import asdict from datetime import datetime @@ -172,6 +173,22 @@ rebuild_lineage, visible_lineage_graph, ) +from backend.app.ontology_neighborhood_ingestion import ( + neighborhood_error_detail, + neighborhood_error_http_status, + neighborhood_to_payload, + parse_allowed_property_query, + visible_ontology_neighborhood, +) +from lineageweave.ontology_neighborhood import ( + DEFAULT_MAXIMUM_DEPTH, + DEFAULT_MAXIMUM_EDGES, + DEFAULT_MAXIMUM_NODES, + HARD_MAXIMUM_DEPTH, + HARD_MAXIMUM_EDGES, + HARD_MAXIMUM_NODES, + OntologyNeighborhoodError, +) from backend.app.post_chat_ingestion import ( fetch_persisted_chat, fetch_persisted_chats, @@ -188,7 +205,6 @@ from backend.app.demo_scope import ( fetch_demo_corporate_entity_ids, has_real_source_context, - is_demo_scope, ) from lineageweave.http_client import HttpClientError @@ -252,6 +268,8 @@ async def lifespan(app: FastAPI): await app.state.valkey.aclose() +logger = logging.getLogger(__name__) + app = FastAPI(title="LineageWeave API", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -1108,6 +1126,11 @@ async def resolve_customer_master_hint( status.HTTP_503_SERVICE_UNAVAILABLE, "Hint resolution is unavailable: the orchestrator or search provider did not respond", ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Hint resolution is unavailable: the orchestrator or search provider did not respond", + ) from exc if resolution is None: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -1961,6 +1984,49 @@ async def read_related_team( } +@app.get("/api/ontology/neighborhood") +async def read_ontology_neighborhood( + focus_node_type: str = Query(..., min_length=1), + focus_node_id: str = Query(..., min_length=1), + maximum_depth: int = Query(DEFAULT_MAXIMUM_DEPTH, ge=1, le=HARD_MAXIMUM_DEPTH), + maximum_nodes: int = Query(DEFAULT_MAXIMUM_NODES, ge=1, le=HARD_MAXIMUM_NODES), + maximum_edges: int = Query(DEFAULT_MAXIMUM_EDGES, ge=1, le=HARD_MAXIMUM_EDGES), + allowed_property_codes: list[str] | None = Query(None), + knowledge_cutoff: str | None = Query(None), + cursor: str | None = Query(None), + account: CurrentAccount = Depends(get_current_account), + pool: asyncpg.Pool = Depends(get_pool), +) -> dict[str, Any]: + """Typed ontology/KG neighborhood, distinct from Event Lineage.""" + _require_post_read(account) + cutoff_clock = None + if knowledge_cutoff: + try: + cutoff_clock = parse_as_of_clock(knowledge_cutoff) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc + try: + async with pool.acquire() as conn: + neighborhood = await visible_ontology_neighborhood( + conn, + focus_node_type_code=focus_node_type, + focus_node_id=focus_node_id, + can_see_post=lambda row: _can_see_post(account, row), + maximum_depth=maximum_depth, + maximum_nodes=maximum_nodes, + maximum_edges=maximum_edges, + allowed_property_codes=parse_allowed_property_query(allowed_property_codes), + knowledge_cutoff=cutoff_clock, + cursor=cursor, + source_cursor_secret=load_settings().ontology_source_cursor_secret, + source_cursor_scope=account.user_account_id, + ) + payload = neighborhood_to_payload(neighborhood) + except OntologyNeighborhoodError as exc: + raise HTTPException(neighborhood_error_http_status(exc), neighborhood_error_detail(exc)) from None + return payload + + @app.get("/api/posts/{post_id}/counterparties") async def read_post_counterparties( post_id: str, @@ -2052,6 +2118,11 @@ async def verify_post_entity_relationships( status.HTTP_503_SERVICE_UNAVAILABLE, "Relation verification is unavailable: the search provider did not respond", ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Relation verification is unavailable: the search provider did not respond", + ) from exc await publish_activity_event( valkey, post_id, @@ -2105,22 +2176,33 @@ async def extract_post_keymen( # tags dilute the model's attention and a base64 payload sent as # literal text either blows the token budget or is silently # ignored (see lineageweave/post_content_normalization.py). - post_body = ( - await asyncio.to_thread(normalize_post_body, raw_body, _vision_client()) - ).text context_hints = await _load_post_semantic_hints(conn, post_id) - mentions = await ingest_post_keymen( - conn, - keyman_client, - post_id, - post["post_title"], - post_body, - resolution_client=_organization_name_resolution_client(), - verification_client=_relation_verification_client(), - hierarchy_inference_client=_corporate_hierarchy_inference_client(), - context_hints=context_hints, - persist_graph=False, - ) + try: + post_body = ( + await asyncio.to_thread(normalize_post_body, raw_body, _vision_client()) + ).text + mentions = await ingest_post_keymen( + conn, + keyman_client, + post_id, + post["post_title"], + post_body, + resolution_client=_organization_name_resolution_client(), + verification_client=_relation_verification_client(), + hierarchy_inference_client=_corporate_hierarchy_inference_client(), + context_hints=context_hints, + persist_graph=False, + ) + except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc # Live bug (2026-08-19): an organization affiliated ONLY with an # our_side person (our own factory, our own affiliate) got fed # into the counterparty-relationship classifier the same as any @@ -2136,9 +2218,20 @@ async def extract_post_keymen( for name in mention.affiliated_organization_names } ) - relationships = await ingest_post_entity_relationships( - conn, relationship_client, post_id, post["post_title"], post_body, organization_names - ) + try: + relationships = await ingest_post_entity_relationships( + conn, relationship_client, post_id, post["post_title"], post_body, organization_names + ) + except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Keymen extraction is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc async with conn.transaction(): await persist_edges_for_post(conn, post_id) await publish_activity_event( @@ -2266,17 +2359,28 @@ async def evaluate_post( ) async with pool.acquire() as conn: body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) - normalized_body = ( - await asyncio.to_thread( - normalize_post_body, - "" if body_row is None else body_row["post_body"], - _vision_client(), - ) - ).text - async with pool.acquire() as conn: - rows = await ingest_post_evaluation( - conn, client, post_id, post["post_title"], normalized_body - ) + try: + normalized_body = ( + await asyncio.to_thread( + normalize_post_body, + "" if body_row is None else body_row["post_body"], + _vision_client(), + ) + ).text + async with pool.acquire() as conn: + rows = await ingest_post_evaluation( + conn, client, post_id, post["post_title"], normalized_body + ) + except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post evaluation is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post evaluation is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc await publish_activity_event( valkey, post_id, @@ -2473,20 +2577,35 @@ async def read_post_summary( if stored is not None: return stored stale = await fetch_persisted_summary(conn, post_id, allow_stale=True) + + def stale_fallback( + reason: str, error: BaseException | None = None + ) -> dict[str, Any]: + """Return explicitly stale evidence while preserving operator diagnostics.""" + if stale is None: + raise RuntimeError("stale summary fallback called without a stale row") + logger.warning( + "post_summary_stale_fallback post_id=%s reason=%s error_type=%s", + post_id, + reason, + type(error).__name__ if error is not None else "unavailable", + ) + return stale + with use_llm_metadata(post_metadata): client = _post_summary_client() if not client.available: if stale is not None: - return stale + return stale_fallback("orchestrator_unavailable") raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Post summary is unavailable: set ORCHESTRATOR_BASE_URL / ORCHESTRATOR_API_KEY", ) - normalized = await asyncio.to_thread(normalize_post_body, raw_body) - normalized_body = normalized.text context_hints = await _load_post_semantic_hints(conn, post_id) summarize_with_hints = getattr(client, "summarize_with_hints", None) try: + normalized = await asyncio.to_thread(normalize_post_body, raw_body) + normalized_body = normalized.text if callable(summarize_with_hints): summary = await asyncio.to_thread( summarize_with_hints, post["post_title"], normalized_body, context_hints @@ -2495,19 +2614,34 @@ async def read_post_summary( summary = await asyncio.to_thread(client.summarize, post["post_title"], normalized_body) except (HttpClientError, KeyError, OSError, TypeError, ValueError) as exc: if stale is not None: - return stale + return stale_fallback("orchestrator_failure", exc) raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, "Post summary is unavailable: contextual-orchestrator returned no complete evidence object", ) from exc - payload = await persist_post_summary( - conn, - post_id, - summary, - post_body=normalized_body, - hierarchy_inference_client=_corporate_hierarchy_inference_client(), - verification_client=_relation_verification_client(), - ) + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + if stale is not None: + return stale_fallback("orchestrator_unexpected_failure", exc) + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post summary is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + try: + payload = await persist_post_summary( + conn, + post_id, + summary, + post_body=normalized_body, + hierarchy_inference_client=_corporate_hierarchy_inference_client(), + verification_client=_relation_verification_client(), + ) + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + if stale is not None: + return stale_fallback("summary_persist_failure", exc) + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post summary is unavailable: contextual-orchestrator or corroboration provider returned no complete evidence object", + ) from exc content_complete = await post_content_is_complete( conn, post_id, @@ -2634,6 +2768,11 @@ async def chat_about_post( status.HTTP_503_SERVICE_UNAVAILABLE, "Post chat is unavailable: contextual-orchestrator returned no complete evidence object", ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Post chat is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc cited_ids = list(answer.cited_post_ids) async with pool.acquire() as conn: await persist_post_chat(conn, post_id, question, answer.answer_text, cited_ids) @@ -2943,14 +3082,25 @@ async def derive_post_commitment( ) async with pool.acquire() as conn: body_row = await conn.fetchrow("select post_body from source_post where post_id = $1", post_id) - normalized_body = ( - await asyncio.to_thread(normalize_post_body, body_row["post_body"], _vision_client()) - ).text - # TimeML/TempEval document creation time, not wall-clock now: "by next - # Friday" in a January post must resolve to that January, not to the - # Friday after the operator clicked Derive. - reference_date = post["created_at"].date().isoformat() - commitment = client.extract(post["post_title"], normalized_body, reference_date) + try: + normalized_body = ( + await asyncio.to_thread(normalize_post_body, body_row["post_body"], _vision_client()) + ).text + # TimeML/TempEval document creation time, not wall-clock now: "by next + # Friday" in a January post must resolve to that January, not to the + # Friday after the operator clicked Derive. + reference_date = post["created_at"].date().isoformat() + commitment = client.extract(post["post_title"], normalized_body, reference_date) + except (HttpClientError, KeyError, OSError, TypeError, ValueError, RuntimeError) as exc: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Commitment derivation is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc + except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + "Commitment derivation is unavailable: contextual-orchestrator returned no complete evidence object", + ) from exc if not commitment.has_commitment: return {"post_id": str(post["post_id"]), "has_commitment": False, "ticket": None} async with pool.acquire() as conn: diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py new file mode 100644 index 000000000..921bda972 --- /dev/null +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -0,0 +1,930 @@ +"""Load an ABAC-visible ontology neighborhood from PostgreSQL (ADR 0168).""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime, timezone +from typing import Any, Callable, Mapping, Sequence +from uuid import UUID + +import asyncpg + +from backend.app.knowledge_graph import ( + corporate_entity_exists, + person_exists, + team_exists, + visible_affiliation_post_ids, + visible_mention_post_ids, + visible_team_mention_post_ids, +) +from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from lineageweave.knowledge_graph import ( + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_POST, + NODE_TEAM, +) +from lineageweave.ontology_neighborhood import ( + DEFAULT_MAXIMUM_DEPTH, + DEFAULT_MAXIMUM_EDGES, + DEFAULT_MAXIMUM_NODES, + HARD_MAXIMUM_EDGES, + KNOWN_NODE_TYPES, + NeighborhoodFact, + OntologyNodeMetadata, + OntologyNeighborhood, + OntologyNeighborhoodError, + PROPERTY_SKOS_BROADER, + assemble_ontology_neighborhood, + fact_from_knowledge_graph_edge, + skos_broader_fact, +) +from lineageweave.ontology_source_cursor import ( + OntologySourceKey, + OntologySourceCursor, + SOURCE_CURSOR_PREFIX, + mint_source_cursor, + source_cursor_secret_from_env, + source_key_from_row, + verify_source_cursor, +) + +NOT_FOUND_NEIGHBORHOOD_CODES = frozenset( + {"focus_hidden", "focus_not_visible", "unknown_node_type", "dangling_endpoint"} +) + + +class _LoadedFactWindow(list[NeighborhoodFact]): + """Bounded fact list plus whether the SQL source window was exhausted.""" + + def __init__( + self, + facts: Sequence[NeighborhoodFact] = (), + *, + truncated: bool = False, + last_source_key: OntologySourceKey | None = None, + source_keys_by_edge: Mapping[ + tuple[str, str, str, str, str], OntologySourceKey + ] | None = None, + ) -> None: + super().__init__(facts) + self.truncated = truncated + self.last_source_key = last_source_key + self.source_keys_by_edge = dict(source_keys_by_edge or {}) + + +def neighborhood_error_http_status(error: OntologyNeighborhoodError) -> int: + """Map a fail-closed assembler error onto an HTTP status. + + Next action: return this status from GET /api/ontology/neighborhood + and name the buyer's next visible focus rather than leaking counts. + """ + if error.code in NOT_FOUND_NEIGHBORHOOD_CODES: + return 404 + return 422 + + +def neighborhood_error_detail(error: OntologyNeighborhoodError) -> str: + """Return a stable detail that does not reveal focus-node existence. + + Hidden, missing, and dangling focus nodes are intentionally indistinguishable + at the HTTP boundary. Next action: let the buyer choose another authorized + focus instead of probing catalog membership through response details. + """ + if error.code in NOT_FOUND_NEIGHBORHOOD_CODES: + return "focus node is unavailable" + return str(error) + + +def parse_allowed_property_query(values: Sequence[str] | None) -> list[str] | None: + """Split repeated or comma-separated property filters into codes.""" + if values is None: + return None + codes: list[str] = [] + for value in values: + for part in value.split(","): + stripped = part.strip() + if stripped: + codes.append(stripped) + return codes or None + + +def _is_uuid(value: str) -> bool: + try: + UUID(value) + except ValueError: + return False + return True + + +async def visible_post_ids_for_focus( + conn: asyncpg.Connection, + focus_node_type_code: str, + focus_node_id: str, + can_see_post: Callable[[asyncpg.Record], bool], +) -> list[str]: + """Visible evidence posts that authorize the requested focus node.""" + if focus_node_type_code == NODE_POST: + # Safe SQL: eligibility is an immutable schema fragment; id is bound. + row = await conn.fetchrow( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select post_id, visibility_code, corporate_entity_id + from source_post + where post_id = $1 + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')} + """, + focus_node_id, + ) + if row is None: + return [] + return [str(row["post_id"])] if can_see_post(row) else [] + if focus_node_type_code == NODE_PERSON: + return await visible_mention_post_ids(conn, focus_node_id, can_see_post) + if focus_node_type_code == NODE_CORPORATE_ENTITY: + return await visible_affiliation_post_ids(conn, focus_node_id, can_see_post) + if focus_node_type_code == NODE_TEAM: + return await visible_team_mention_post_ids(conn, focus_node_id, can_see_post) + raise OntologyNeighborhoodError("unknown_node_type", f"unknown node type {focus_node_type_code!r}") + + +async def _visible_post_ids_by_nodes( + conn: asyncpg.Connection, + node_keys: set[tuple[str, str]], + can_see_post: Callable[[asyncpg.Record], bool], +) -> dict[tuple[str, str], list[str]]: + """Load evidence visibility for all endpoint nodes in four bounded queries. + + The neighborhood can contain many endpoints. Grouping ids by node type + preserves the same ABAC predicate as the single-node readers while + preventing one database round trip per endpoint. + """ + visible: dict[tuple[str, str], list[str]] = {key: [] for key in node_keys} + ids_by_type = { + node_type: sorted(node_id for candidate_type, node_id in node_keys if candidate_type == node_type) + for node_type in KNOWN_NODE_TYPES + } + queries = ( + ( + NODE_POST, + """ + select post.post_id, post.visibility_code, + post.corporate_entity_id, post.post_id as node_id + from source_post post + where post.post_id = any($1::uuid[]) + and {eligibility} + """, + ), + ( + NODE_PERSON, + """ + select post.post_id, post.visibility_code, + post.corporate_entity_id, mention.person_id as node_id + from combined_post_person_mention mention + join source_post post on post.post_id = mention.post_id + where mention.person_id = any($1::uuid[]) + and {eligibility} + """, + ), + ( + NODE_CORPORATE_ENTITY, + """ + select distinct post.post_id, post.visibility_code, + post.corporate_entity_id, + affiliation.affiliated_corporate_entity_id as node_id + from person_affiliation affiliation + join combined_post_person_mention mention + on mention.person_id = affiliation.person_id + join source_post post on post.post_id = mention.post_id + where affiliation.affiliated_corporate_entity_id = any($1::uuid[]) + and {eligibility} + union + select distinct post.post_id, post.visibility_code, + post.corporate_entity_id, + org_mention.corporate_entity_id as node_id + from post_organization_mention org_mention + join source_post post on post.post_id = org_mention.post_id + where org_mention.corporate_entity_id = any($1::uuid[]) + and {eligibility} + """, + ), + ( + NODE_TEAM, + """ + select post.post_id, post.visibility_code, + post.corporate_entity_id, mention.team_id as node_id + from post_team_mention mention + join source_post post on post.post_id = mention.post_id + where mention.team_id = any($1::uuid[]) + and {eligibility} + """, + ), + ) + for node_type, template in queries: + ids = ids_by_type[node_type] + if not ids: + continue + query = template.format(eligibility=SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")) + rows = await conn.fetch(query, ids) + for row in rows: + try: + raw_node_id = row["node_id"] + except (KeyError, IndexError, TypeError): + raw_node_id = None + if raw_node_id is None and len(ids) == 1: + raw_node_id = ids[0] + if raw_node_id is None: + continue + node_id = str(raw_node_id) + key = (node_type, node_id) + try: + raw_post_id = row["post_id"] + except (KeyError, IndexError, TypeError): + raw_post_id = None + if key in visible and raw_post_id is not None and can_see_post(row): + visible[key].append(str(raw_post_id)) + return {key: list(dict.fromkeys(post_ids)) for key, post_ids in visible.items()} + + +async def focus_catalog_exists( + conn: asyncpg.Connection, focus_node_type_code: str, focus_node_id: str +) -> bool: + """True when the focus id exists in the governed catalog.""" + if not _is_uuid(focus_node_id): + return False + if focus_node_type_code == NODE_POST: + row = await conn.fetchrow("select 1 from source_post where post_id = $1", focus_node_id) + return row is not None + if focus_node_type_code == NODE_PERSON: + return await person_exists(conn, focus_node_id) + if focus_node_type_code == NODE_CORPORATE_ENTITY: + return await corporate_entity_exists(conn, focus_node_id) + if focus_node_type_code == NODE_TEAM: + return await team_exists(conn, focus_node_id) + raise OntologyNeighborhoodError("unknown_node_type", f"unknown node type {focus_node_type_code!r}") + + +async def _load_facts( + conn: asyncpg.Connection, + visible_post_ids: list[str], + *, + focus_node_type_code: str = NODE_POST, + focus_node_id: str = "", + maximum_depth: int = DEFAULT_MAXIMUM_DEPTH, + maximum_edges: int = DEFAULT_MAXIMUM_EDGES, + knowledge_cutoff: datetime | None = None, + snapshot_at: datetime | None = None, + after_key: OntologySourceKey | None = None, +) -> _LoadedFactWindow: + """Load one keyset page of cutoff-safe recursive facts for the focus node.""" + if not visible_post_ids: + return _LoadedFactWindow() + query_limit = maximum_edges + 1 + query = """ + with recursive candidate_facts as ( + select edge.source_node_type_code, + edge.source_node_id::text as source_node_id, + edge.target_node_type_code, + edge.target_node_id::text as target_node_id, + edge.edge_type_code, + min(post.created_at) as available_at, + array_agg(evidence.evidence_post_id::text order by evidence.evidence_post_id) + as evidence_ids + from knowledge_graph_edge edge + join knowledge_graph_edge_evidence evidence + on evidence.knowledge_graph_edge_id = edge.knowledge_graph_edge_id + join source_post post + on post.post_id = evidence.evidence_post_id + where evidence.evidence_post_id = any($1::uuid[]) + and ($6::timestamptz is null or post.created_at <= $6::timestamptz) + and ($7::timestamptz is null or post.created_at <= $7::timestamptz) + and ($7::timestamptz is null or edge.created_at <= $7::timestamptz) + group by edge.source_node_type_code, edge.source_node_id, + edge.target_node_type_code, edge.target_node_id, + edge.edge_type_code + ), reachable(node_type_code, node_id, depth) as ( + values ($2::text, $3::text, 0) + union + select case when candidate.source_node_type_code = reachable.node_type_code + and candidate.source_node_id = reachable.node_id + then candidate.target_node_type_code + else candidate.source_node_type_code end, + case when candidate.source_node_type_code = reachable.node_type_code + and candidate.source_node_id = reachable.node_id + then candidate.target_node_id + else candidate.source_node_id end, + reachable.depth + 1 + from candidate_facts candidate + join reachable + on (candidate.source_node_type_code = reachable.node_type_code + and candidate.source_node_id = reachable.node_id) + or (candidate.target_node_type_code = reachable.node_type_code + and candidate.target_node_id = reachable.node_id) + where reachable.depth < $4::integer + ), ranked as ( + select candidate.source_node_type_code, + candidate.source_node_id, + candidate.target_node_type_code, + candidate.target_node_id, + candidate.edge_type_code, + candidate.available_at, + candidate.evidence_ids, + min(reachable.depth) as hop_depth + from candidate_facts candidate + join reachable + on ((candidate.source_node_type_code = reachable.node_type_code + and candidate.source_node_id = reachable.node_id) + or (candidate.target_node_type_code = reachable.node_type_code + and candidate.target_node_id = reachable.node_id)) + and reachable.depth < $4::integer + group by candidate.source_node_type_code, + candidate.source_node_id, + candidate.target_node_type_code, + candidate.target_node_id, + candidate.edge_type_code, + candidate.available_at, + candidate.evidence_ids + ) + select source_node_type_code, + source_node_id, + target_node_type_code, + target_node_id, + edge_type_code, + available_at, + evidence_ids, + hop_depth + from ranked + where $8::integer is null + or (hop_depth, edge_type_code, source_node_type_code, source_node_id, + target_node_type_code, target_node_id) + > ($8::integer, $9::text, $10::text, $11::text, $12::text, $13::text) + order by hop_depth, + edge_type_code, + source_node_type_code, + source_node_id, + target_node_type_code, + target_node_id + limit $5::integer + """ + arguments: list[object] = [ + visible_post_ids, + focus_node_type_code, + focus_node_id, + maximum_depth, + query_limit, + knowledge_cutoff, + snapshot_at, + None if after_key is None else after_key.hop_depth, + None if after_key is None else after_key.edge_type_code, + None if after_key is None else after_key.source_node_type_code, + None if after_key is None else after_key.source_node_id, + None if after_key is None else after_key.target_node_type_code, + None if after_key is None else after_key.target_node_id, + ] + rows = await conn.fetch(query, *arguments) + source_truncated = len(rows) >= query_limit + page_rows = list(rows[:maximum_edges]) + facts: list[NeighborhoodFact] = [] + source_keys_by_edge: dict[tuple[str, str, str, str, str], OntologySourceKey] = {} + for row in page_rows: + fact = fact_from_knowledge_graph_edge( + source_node_type_code=row["source_node_type_code"], + source_node_id=str(row["source_node_id"]), + target_node_type_code=row["target_node_type_code"], + target_node_id=str(row["target_node_id"]), + edge_type_code=row["edge_type_code"], + recorded_at=row["available_at"], + evidence_references=tuple(row["evidence_ids"] or ()), + provenance_reference="knowledge_graph_edge", + ) + try: + hop_depth = row["hop_depth"] + except (KeyError, IndexError): + hop_depth = None + source_key = source_key_from_row(row) + source_order_key = ( + source_key.hop_depth, + source_key.edge_type_code, + source_key.source_node_type_code, + source_key.source_node_id, + source_key.target_node_type_code, + source_key.target_node_id, + ) + facts.append( + replace( + fact, + source_hop_depth=None if hop_depth is None else int(hop_depth), + source_order_key=source_order_key, + ) + ) + source_keys_by_edge[ + ( + fact.property_code, + fact.source_node_type_code, + fact.source_node_id, + fact.target_node_type_code, + fact.target_node_id, + ) + ] = source_key + last_key = source_key_from_row(page_rows[-1]) if page_rows else None + return _LoadedFactWindow( + facts, + truncated=source_truncated, + last_source_key=last_key, + source_keys_by_edge=source_keys_by_edge, + ) + + +async def _load_skos_facts( + conn: asyncpg.Connection, corporate_entity_ids: list[str] +) -> list[NeighborhoodFact]: + """Load governed corporate hierarchy facts for visible entities.""" + if not corporate_entity_ids: + return [] + rows = await conn.fetch( + """ + select corporate_entity_id, parent_entity_id, created_at + from corporate_entity + where corporate_entity_id = any($1::uuid[]) + and parent_entity_id is not null + """, + corporate_entity_ids, + ) + return [ + skos_broader_fact( + narrower_entity_id=str(row["corporate_entity_id"]), + broader_entity_id=str(row["parent_entity_id"]), + recorded_at=row["created_at"], + provenance_reference="corporate_entity.parent_entity_id", + ) + for row in rows + ] + + +async def _load_labels( + conn: asyncpg.Connection, facts: list[NeighborhoodFact] +) -> dict[tuple[str, str], str]: + """Load only non-empty buyer-visible labels for fact endpoints.""" + ids_by_type = _node_ids_by_type(facts) + person_ids = ids_by_type[NODE_PERSON] + post_ids = ids_by_type[NODE_POST] + corp_ids = ids_by_type[NODE_CORPORATE_ENTITY] + team_ids = ids_by_type[NODE_TEAM] + labels: dict[tuple[str, str], str] = {} + if person_ids: + for row in await conn.fetch( + "select person_id, person_name from cataloged_person where person_id = any($1::uuid[])", + person_ids, + ): + if row["person_name"]: + labels[(NODE_PERSON, str(row["person_id"]))] = str(row["person_name"]) + if post_ids: + for row in await conn.fetch( + "select post_id, post_title from source_post where post_id = any($1::uuid[])", + post_ids, + ): + if row["post_title"]: + labels[(NODE_POST, str(row["post_id"]))] = str(row["post_title"]) + if corp_ids: + for row in await conn.fetch( + "select corporate_entity_id, entity_name from corporate_entity " + "where corporate_entity_id = any($1::uuid[])", + corp_ids, + ): + if row["entity_name"]: + labels[(NODE_CORPORATE_ENTITY, str(row["corporate_entity_id"]))] = str(row["entity_name"]) + if team_ids: + for row in await conn.fetch( + "select team_id, team_name from cataloged_team where team_id = any($1::uuid[])", + team_ids, + ): + if row["team_name"]: + labels[(NODE_TEAM, str(row["team_id"]))] = str(row["team_name"]) + return labels + + +def _node_ids_by_type( + facts: list[NeighborhoodFact], + focus_node_type_code: str | None = None, + focus_node_id: str | None = None, +) -> dict[str, list[str]]: + """Collect unique catalog ids needed by labels and metadata queries.""" + ids_by_type = {node_type: [] for node_type in KNOWN_NODE_TYPES} + seen: set[tuple[str, str]] = set() + endpoints = [ + (fact.source_node_type_code, fact.source_node_id) + for fact in facts + ] + [ + (fact.target_node_type_code, fact.target_node_id) + for fact in facts + ] + if focus_node_type_code and focus_node_id: + endpoints.append((focus_node_type_code, focus_node_id)) + for node_type, node_id in endpoints: + key = (node_type, node_id) + if node_type in ids_by_type and key not in seen: + ids_by_type[node_type].append(node_id) + seen.add(key) + return ids_by_type + + +async def _load_node_metadata( + conn: asyncpg.Connection, + facts: list[NeighborhoodFact], + *, + focus_node_type_code: str, + focus_node_id: str, +) -> dict[tuple[str, str], OntologyNodeMetadata]: + """Load node timestamps from catalogs without deriving them from edges.""" + ids_by_type = _node_ids_by_type(facts, focus_node_type_code, focus_node_id) + metadata: dict[tuple[str, str], OntologyNodeMetadata] = {} + queries = ( + (NODE_PERSON, "select person_id, created_at from cataloged_person where person_id = any($1::uuid[])", "person_id"), + (NODE_POST, "select post_id, created_at from source_post where post_id = any($1::uuid[])", "post_id"), + (NODE_CORPORATE_ENTITY, "select corporate_entity_id, created_at from corporate_entity where corporate_entity_id = any($1::uuid[])", "corporate_entity_id"), + (NODE_TEAM, "select team_id, created_at from cataloged_team where team_id = any($1::uuid[])", "team_id"), + ) + for node_type, query, id_column in queries: + ids = ids_by_type[node_type] + if not ids: + continue + for row in await conn.fetch(query, ids): + metadata[(node_type, str(row[id_column]))] = OntologyNodeMetadata( + recorded_at=row["created_at"] + ) + return metadata + + +def neighborhood_to_payload(neighborhood: OntologyNeighborhood) -> dict[str, Any]: + """JSON object for GET /api/ontology/neighborhood.""" + return { + "focus_node_id": neighborhood.focus_node_id, + "focus_node_type_code": neighborhood.focus_node_type_code, + "truncated": neighborhood.truncated, + "next_cursor": neighborhood.next_cursor, + "limitation_code": neighborhood.limitation_code, + "nodes": [ + { + "node_id": node.node_id, + "node_type_code": node.node_type_code, + "ontology_class_iri": node.ontology_class_iri, + "display_label": node.display_label, + "truth_status_code": node.truth_status_code, + "valid_from": node.valid_from.isoformat() if node.valid_from else None, + "valid_to": node.valid_to.isoformat() if node.valid_to else None, + "recorded_at": node.recorded_at.isoformat() if node.recorded_at else None, + "evidence_count": node.evidence_count, + "shape_code": node.shape_code, + } + for node in neighborhood.nodes + ], + "edges": [ + { + "edge_id": edge.edge_id, + "source_node_type_code": edge.source_node_type_code, + "source_node_id": edge.source_node_id, + "target_node_type_code": edge.target_node_type_code, + "target_node_id": edge.target_node_id, + "property_code": edge.property_code, + "ontology_property_iri": edge.ontology_property_iri, + "property_label": edge.property_label, + "truth_status_code": edge.truth_status_code, + "valid_from": edge.valid_from.isoformat() if edge.valid_from else None, + "valid_to": edge.valid_to.isoformat() if edge.valid_to else None, + "recorded_at": edge.recorded_at.isoformat(), + "provenance_reference": edge.provenance_reference, + "evidence_references": list(edge.evidence_references), + } + for edge in neighborhood.edges + ], + "exact_value_rows": list(neighborhood.exact_value_rows()), + "jsonld": neighborhood.jsonld_document(), + } + + +async def visible_ontology_neighborhood( + conn: asyncpg.Connection, + *, + focus_node_type_code: str, + focus_node_id: str, + can_see_post: Callable[[asyncpg.Record], bool], + maximum_depth: int = DEFAULT_MAXIMUM_DEPTH, + maximum_nodes: int = DEFAULT_MAXIMUM_NODES, + maximum_edges: int = DEFAULT_MAXIMUM_EDGES, + allowed_property_codes: list[str] | None = None, + knowledge_cutoff: datetime | None = None, + cursor: str | None = None, + source_cursor_secret: str | None = None, + source_cursor_scope: str | None = None, +) -> OntologyNeighborhood: + """Assemble the authorized neighborhood for one focus node.""" + if focus_node_type_code not in KNOWN_NODE_TYPES: + raise OntologyNeighborhoodError( + "unknown_node_type", f"unknown node type {focus_node_type_code!r}" + ) + if not focus_node_id or focus_node_id.strip() != focus_node_id: + raise OntologyNeighborhoodError("invalid_focus_id", "focus node id is empty or malformed") + if not _is_uuid(focus_node_id): + raise OntologyNeighborhoodError("invalid_focus_id", "focus node id is not a UUID") + focus_node_id = str(UUID(focus_node_id)) + if not await focus_catalog_exists(conn, focus_node_type_code, focus_node_id): + raise OntologyNeighborhoodError("unknown_node_type", "focus node not found") + visible_post_ids = await visible_post_ids_for_focus( + conn, focus_node_type_code, focus_node_id, can_see_post + ) + if not visible_post_ids: + raise OntologyNeighborhoodError("focus_not_visible", "focus node is not visible") + secret = source_cursor_secret_from_env(source_cursor_secret) + snapshot_at = datetime.now(timezone.utc) + after_key: OntologySourceKey | None = None + source_cursor_claims: OntologySourceCursor | None = None + assembler_cursor = cursor + if cursor is not None and cursor.startswith(SOURCE_CURSOR_PREFIX): + assembler_cursor = None + if secret is None or not source_cursor_scope: + raise OntologyNeighborhoodError("malformed_cursor", "source cursor is unavailable") + source_cursor_claims = verify_source_cursor( + cursor, + secret=secret, + user_account_id=source_cursor_scope, + focus_node_type_code=focus_node_type_code, + focus_node_id=focus_node_id, + knowledge_cutoff=knowledge_cutoff, + maximum_depth=maximum_depth, + maximum_nodes=maximum_nodes, + maximum_edges=maximum_edges, + allowed_property_codes=allowed_property_codes, + visible_post_ids=(), + validate_eligibility=False, + ) + snapshot_at = source_cursor_claims.snapshot_at + after_key = source_cursor_claims.last_key + elif cursor is not None and not cursor.startswith("after:"): + raise OntologyNeighborhoodError("malformed_cursor", "cursor must be an opaque after: or source token") + fact_window = await _load_facts( + conn, + visible_post_ids, + focus_node_type_code=focus_node_type_code, + focus_node_id=focus_node_id, + maximum_depth=maximum_depth, + maximum_edges=HARD_MAXIMUM_EDGES, + knowledge_cutoff=knowledge_cutoff, + snapshot_at=snapshot_at, + ) + facts = list(fact_window) + expansion_truncated = bool(getattr(fact_window, "truncated", False)) + expanded_source_keys = dict(getattr(fact_window, "source_keys_by_edge", {})) + loaded_post_ids = set(visible_post_ids) + visible_by_node: dict[tuple[str, str], list[str]] = {} + for _ in range(maximum_depth): + endpoint_keys = { + (fact.source_node_type_code, fact.source_node_id) + for fact in facts + } | { + (fact.target_node_type_code, fact.target_node_id) + for fact in facts + } + if not endpoint_keys: + break + visible_by_node = await _visible_post_ids_by_nodes( + conn, endpoint_keys, can_see_post + ) + candidate_post_ids = loaded_post_ids | { + post_id + for post_ids in visible_by_node.values() + for post_id in post_ids + } + if candidate_post_ids == loaded_post_ids: + break + expanded_window = await _load_facts( + conn, + sorted(candidate_post_ids), + focus_node_type_code=focus_node_type_code, + focus_node_id=focus_node_id, + maximum_depth=maximum_depth, + maximum_edges=HARD_MAXIMUM_EDGES, + knowledge_cutoff=knowledge_cutoff, + snapshot_at=snapshot_at, + ) + expansion_truncated = expansion_truncated or bool(getattr(expanded_window, "truncated", False)) + expanded_source_keys.update(getattr(expanded_window, "source_keys_by_edge", {})) + for fact in expanded_window: + if fact not in facts: + facts.append(fact) + loaded_post_ids = candidate_post_ids + else: + # The last expansion can add endpoints after the final visibility pass. + # Recheck them before the authorization cache turns an unseen endpoint + # into a silently dropped edge. + endpoint_keys = { + (fact.source_node_type_code, fact.source_node_id) + for fact in facts + } | { + (fact.target_node_type_code, fact.target_node_id) + for fact in facts + } + if endpoint_keys: + visible_by_node = await _visible_post_ids_by_nodes( + conn, endpoint_keys, can_see_post + ) + frozen_posts = sorted(loaded_post_ids) + if source_cursor_claims is not None: + if secret is None or source_cursor_scope is None: + raise OntologyNeighborhoodError("malformed_cursor", "source cursor is unavailable") + source_cursor_claims = verify_source_cursor( + cursor, + secret=secret, + user_account_id=source_cursor_scope, + focus_node_type_code=focus_node_type_code, + focus_node_id=focus_node_id, + knowledge_cutoff=knowledge_cutoff, + maximum_depth=maximum_depth, + maximum_nodes=maximum_nodes, + maximum_edges=maximum_edges, + allowed_property_codes=allowed_property_codes, + visible_post_ids=frozen_posts, + validate_eligibility=True, + ) + snapshot_at = source_cursor_claims.snapshot_at + after_key = source_cursor_claims.last_key + page_window = fact_window + if after_key is not None: + page_window = await _load_facts( + conn, + frozen_posts, + focus_node_type_code=focus_node_type_code, + focus_node_id=focus_node_id, + maximum_depth=maximum_depth, + maximum_edges=maximum_edges, + knowledge_cutoff=knowledge_cutoff, + snapshot_at=snapshot_at, + after_key=after_key, + ) + else: + page_window = _LoadedFactWindow( + facts, + truncated=expansion_truncated, + last_source_key=getattr(fact_window, "last_source_key", None), + source_keys_by_edge=expanded_source_keys, + ) + facts = list(page_window) + source_truncated = bool(getattr(page_window, "truncated", False)) + last_source_key = getattr(page_window, "last_source_key", None) + source_keys_by_edge = getattr(page_window, "source_keys_by_edge", {}) + endpoint_keys = { + (fact.source_node_type_code, fact.source_node_id) + for fact in facts + } | { + (fact.target_node_type_code, fact.target_node_id) + for fact in facts + } + if endpoint_keys: + # Continuation pages can introduce endpoints absent from the first + # window. Rebuild the authorization cache for the actual page before + # discarding unseen relations. + visible_by_node = await _visible_post_ids_by_nodes(conn, endpoint_keys, can_see_post) + corp_ids = [ + fact.source_node_id if fact.source_node_type_code == NODE_CORPORATE_ENTITY else fact.target_node_id + for fact in facts + if NODE_CORPORATE_ENTITY in {fact.source_node_type_code, fact.target_node_type_code} + ] + if focus_node_type_code == NODE_CORPORATE_ENTITY: + corp_ids.append(focus_node_id) + unique_corp_ids = list(dict.fromkeys(corp_ids)) + skos_facts = await _load_skos_facts(conn, unique_corp_ids) + facts.extend(skos_facts) + parent_keys = { + (fact.target_node_type_code, fact.target_node_id) + for fact in skos_facts + if fact.property_code == PROPERTY_SKOS_BROADER + } + missing_parent_keys = {key for key in parent_keys if key not in visible_by_node} + if missing_parent_keys: + parent_visible = await _visible_post_ids_by_nodes(conn, missing_parent_keys, can_see_post) + visible_by_node.update(parent_visible) + hidden_node_keys: set[str] = set() + authorized_facts: list[NeighborhoodFact] = [] + visibility_cache: dict[tuple[str, str], bool] = { + (focus_node_type_code, focus_node_id): True, + } + visibility_cache.update( + { + key: bool(post_ids) + for key, post_ids in visible_by_node.items() + if key != (focus_node_type_code, focus_node_id) + } + ) + for fact in facts: + endpoints = ( + (fact.source_node_type_code, fact.source_node_id), + (fact.target_node_type_code, fact.target_node_id), + ) + authorized = True + for node_type, node_id in endpoints: + node_key = (node_type, node_id) + if node_key not in visibility_cache: + visibility_cache[node_key] = False + if not visibility_cache[node_key]: + hidden_node_keys.add(f"{node_type}:{node_id}") + authorized = False + if authorized: + authorized_facts.append(fact) + facts = authorized_facts + labels = await _load_labels(conn, facts) + if hasattr(conn, "fetchval"): + if focus_node_type_code == NODE_POST: + title = await conn.fetchval("select post_title from source_post where post_id = $1", focus_node_id) + if title: + labels[(NODE_POST, focus_node_id)] = title + elif focus_node_type_code == NODE_PERSON: + name = await conn.fetchval( + "select person_name from cataloged_person where person_id = $1", focus_node_id + ) + if name: + labels[(NODE_PERSON, focus_node_id)] = name + elif focus_node_type_code == NODE_CORPORATE_ENTITY: + name = await conn.fetchval( + "select entity_name from corporate_entity where corporate_entity_id = $1", + focus_node_id, + ) + if name: + labels[(NODE_CORPORATE_ENTITY, focus_node_id)] = name + else: + name = await conn.fetchval( + "select team_name from cataloged_team where team_id = $1", focus_node_id + ) + if name: + labels[(NODE_TEAM, focus_node_id)] = name + labels.setdefault((focus_node_type_code, focus_node_id), focus_node_id) + facts = [ + fact + for fact in facts + if (fact.source_node_type_code, fact.source_node_id) in labels + and (fact.target_node_type_code, fact.target_node_id) in labels + ] + node_metadata = await _load_node_metadata( + conn, + facts, + focus_node_type_code=focus_node_type_code, + focus_node_id=focus_node_id, + ) + page_node_keys = {(focus_node_type_code, focus_node_id)} + for fact in facts: + page_node_keys.add((fact.source_node_type_code, fact.source_node_id)) + page_node_keys.add((fact.target_node_type_code, fact.target_node_id)) + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=focus_node_type_code, + focus_node_id=focus_node_id, + facts=facts, + labels=labels, + hidden_node_keys=frozenset(hidden_node_keys), + node_metadata=node_metadata, + knowledge_cutoff=knowledge_cutoff, + maximum_depth=maximum_depth, + maximum_nodes=maximum_nodes, + maximum_edges=maximum_edges, + allowed_property_codes=allowed_property_codes, + cursor=assembler_cursor, + source_truncated=source_truncated, + ) + last_source_key = None + neighborhood_edges = getattr(neighborhood, "edges", ()) + for edge in reversed(neighborhood_edges): + source_key = source_keys_by_edge.get( + ( + edge.property_code, + edge.source_node_type_code, + edge.source_node_id, + edge.target_node_type_code, + edge.target_node_id, + ) + ) + if source_key is not None: + last_source_key = source_key + break + if ( + secret is not None + and source_cursor_scope + and last_source_key is not None + and neighborhood.truncated + and (source_truncated or neighborhood.next_cursor is not None) + and len(page_node_keys) <= maximum_nodes + and neighborhood_edges + ): + return replace( + neighborhood, + next_cursor=mint_source_cursor( + secret=secret, + user_account_id=source_cursor_scope, + focus_node_type_code=focus_node_type_code, + focus_node_id=focus_node_id, + knowledge_cutoff=knowledge_cutoff, + maximum_depth=maximum_depth, + maximum_nodes=maximum_nodes, + maximum_edges=maximum_edges, + allowed_property_codes=allowed_property_codes, + last_key=last_source_key, + snapshot_at=snapshot_at, + visible_post_ids=frozen_posts, + ), + ) + return neighborhood diff --git a/backend/app/post_content_worker.py b/backend/app/post_content_worker.py index 8b7ef3202..9089298da 100644 --- a/backend/app/post_content_worker.py +++ b/backend/app/post_content_worker.py @@ -37,6 +37,7 @@ _RECOVERY_INTERVAL_SECONDS = 30.0 _INCOMPLETE_FAILURE_CODE = "post_content_ingestion_incomplete" _ATTEMPT_LIMIT_FAILURE_CODE = "post_content_ingestion_attempt_limit" +_UNEXPECTED_FAILURE_DETAIL = "post-content provider operation failed; retry the ingestion job" async def _stream_tail(client: redis.Redis) -> str: @@ -268,13 +269,13 @@ async def process_post_content_job( expected_attempt_count=attempt_count, ) return - except Exception as exc: # noqa: BLE001 - durable failure is recorded for retry. + except Exception: # noqa: BLE001 - durable failure is recorded for retry. _logger.exception("post content ingestion failed for post_id=%s", post_id) await _finish_failed_job( pool, post_id, failure_code="post_content_ingestion_failed", - detail_text=str(exc)[:1000], + detail_text=_UNEXPECTED_FAILURE_DETAIL, expected_attempt_count=attempt_count, ) return diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 648da6a95..39d3f179e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1718,7 +1718,7 @@ def test_persisted_summary_is_returned_without_an_llm(client, demo_analyst_token def test_stale_summary_is_returned_labeled_when_orchestrator_is_unavailable( - client, demo_analyst_token, seeded_db + client, demo_analyst_token, seeded_db, caplog ) -> None: """A legacy saved summary preserves buyer continuity with an explicit label.""" os.environ.pop("ORCHESTRATOR_BASE_URL", None) @@ -1748,6 +1748,8 @@ def test_stale_summary_is_returned_labeled_when_orchestrator_is_unavailable( assert body["summary_status"] == "stale" assert body["summary_contract_version"] == POST_SUMMARY_CONTRACT_VERSION - 1 assert body["korean_summary"] == "보관된 이전 계약 요약입니다." + assert "post_summary_stale_fallback" in caplog.text + assert "reason=orchestrator_unavailable" in caplog.text def test_seed_demo_summary_surfaces_on_get_summary(client, demo_analyst_token, seeded_db) -> None: @@ -3539,6 +3541,170 @@ def answer(self, question: str, sources) -> ChatAnswer: assert "What happened here that no seed already answers?" in events[0]["summary"] +def test_live_chat_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """A provider exception becomes a stable 503 without its raw message.""" + class _FailingChatClient: + available = True + + def answer(self, question: str, sources) -> object: + raise Exception("raw-provider-secret") + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda: _FailingChatClient()) + + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/chat", + json={"question": "What happened in this provider failure case?"}, + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-provider-secret" not in response.text + + +def test_global_ask_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """The cross-post Ask boundary settles a provider failure with a + stable message, not the worker's raw exception text (ADR 0123). + + Submission is fire-and-forget (202): the failure surfaces once the + background worker settles the job, so this polls GET + /api/ask/jobs/{id} the same way the queued-success test does. + """ + import time as _time + + class _FailingAskClient: + available = True + + def answer(self, question: str, sources) -> object: + raise Exception("raw-global-provider-secret") + + monkeypatch.setattr("backend.app.main._post_chat_client", lambda **_kwargs: _FailingAskClient()) + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + + submitted = client.post( + "/api/ask", + json={"question": "What happened in this global failure case?"}, + headers=headers, + ) + assert submitted.status_code == 202 + job_id = submitted.json()["ask_job_id"] + + deadline = _time.monotonic() + 30 + body: dict = {} + while _time.monotonic() < deadline: + polled = client.get(f"/api/ask/jobs/{job_id}", headers=headers) + assert polled.status_code == 200 + body = polled.json() + if body["job_status_code"] in ("succeeded", "failed"): + break + _time.sleep(0.25) + assert body.get("job_status_code") == "failed", body + assert "raw-global-provider-secret" not in str(body) + + +def test_keymen_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Keymen provider failures become a stable 503 at the API boundary.""" + _grant_post_admin(seeded_db["dsn"]) + + class _FailingKeymanClient: + available = True + + def extract(self, post_title: str, post_body: str) -> object: + raise Exception("raw-keyman-provider-secret") + + monkeypatch.setattr("backend.app.main._keyman_extraction_client", lambda: _FailingKeymanClient()) + + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/extract-keymen", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-keyman-provider-secret" not in response.text + + +def test_evaluation_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Evaluation provider failures become a stable 503 at the API boundary.""" + _grant_post_admin(seeded_db["dsn"]) + + class _FailingEvaluationClient: + available = True + + def evaluate(self, post_title: str, post_body: str) -> object: + raise Exception("raw-evaluation-provider-secret") + + monkeypatch.setattr( + "backend.app.main._post_evaluation_client", lambda: _FailingEvaluationClient() + ) + + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/evaluate", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-evaluation-provider-secret" not in response.text + + +def test_commitment_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Commitment provider failures become a stable 503 at the API boundary.""" + _grant_post_admin(seeded_db["dsn"]) + + class _FailingCommitmentClient: + available = True + + def extract(self, post_title: str, post_body: str, reference_date: str) -> object: + raise Exception("raw-commitment-provider-secret") + + monkeypatch.setattr( + "backend.app.main._commitment_extraction_client", lambda: _FailingCommitmentClient() + ) + + response = client.post( + f"/api/posts/{seeded_db['own_private_post_id']}/derive-commitment", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-commitment-provider-secret" not in response.text + + +def test_summary_enrichment_provider_error_does_not_leak_raw_error( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Summary enrichment failures stay a stable 503 at the API boundary.""" + from lineageweave.post_summary import PostSummary + + class _FakeSummaryClient: + available = True + + def summarize(self, post_title: str, post_body: str) -> PostSummary: + return PostSummary(korean_summary="합성 요약") + + async def _fail_persist(*args, **kwargs): + raise Exception("raw-summary-provider-secret") + + monkeypatch.setattr("backend.app.main._post_summary_client", lambda: _FakeSummaryClient()) + monkeypatch.setattr("backend.app.main.persist_post_summary", _fail_persist) + + response = client.get( + f"/api/posts/{seeded_db['own_private_post_id']}/summary", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 503 + assert "raw-summary-provider-secret" not in response.text + + def test_evaluate_is_unavailable_without_orchestrator(client, demo_analyst_token, seeded_db) -> None: os.environ.pop("ORCHESTRATOR_BASE_URL", None) os.environ.pop("ORCHESTRATOR_API_KEY", None) diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index b5f6e9a12..ad7d9b1e1 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -90,3 +90,18 @@ def test_rankweave_disabled_defaults_off(monkeypatch) -> None: def test_rankweave_disabled_flag_is_opt_in(monkeypatch) -> None: monkeypatch.setenv("RANKWEAVE_DISABLED", "1") assert load_settings().rankweave_disabled is True + + +def test_ontology_source_cursor_secret_is_process_env_not_oidc(monkeypatch) -> None: + """Source continuation must not reuse an OIDC or orchestrator credential.""" + monkeypatch.delenv("ONTOLOGY_SOURCE_CURSOR_SECRET", raising=False) + monkeypatch.delenv("KEYVERSE_ISSUER", raising=False) + monkeypatch.delenv("OIDC_ISSUER", raising=False) + monkeypatch.setenv("OIDC_AUDIENCE", "lineageweave-api") + monkeypatch.setenv("ORCHESTRATOR_API_KEY", "orchestrator-secret-must-not-leak") + assert load_settings().ontology_source_cursor_secret == "" + monkeypatch.setenv("ONTOLOGY_SOURCE_CURSOR_SECRET", "ontology-source-cursor-secret-32b") + settings = load_settings() + assert settings.ontology_source_cursor_secret == "ontology-source-cursor-secret-32b" + assert settings.ontology_source_cursor_secret != settings.orchestrator_api_key + assert settings.ontology_source_cursor_secret != settings.oidc_audience diff --git a/docker-compose.yml b/docker-compose.yml index 671301fae..3d0db99b8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -175,6 +175,9 @@ services: TEPP_API_KEY: ${TEPP_API_KEY:-} CALDAV_BASE_URL: ${CALDAV_BASE_URL:-} RANKWEAVE_DISABLED: ${RANKWEAVE_DISABLED:-} + # Process HMAC for ontology source-window continuation. Empty keeps the + # truncated-without-cursor contract. Never reuse an OIDC or orchestrator secret. + ONTOLOGY_SOURCE_CURSOR_SECRET: ${ONTOLOGY_SOURCE_CURSOR_SECRET:-} ports: - "${BACKEND_PORT:-18420}:8000" depends_on: diff --git a/docs/adr/0123-provider-error-boundary.md b/docs/adr/0123-provider-error-boundary.md new file mode 100644 index 000000000..48610ebeb --- /dev/null +++ b/docs/adr/0123-provider-error-boundary.md @@ -0,0 +1,57 @@ +# ADR 0123: Provider failures never become product error payloads + +- Status: Accepted +- Date: 2026-08-21 + +## Context + +Provider responses and exception messages can contain credentials, gateway +diagnostics, prompts, model output, or other internal transport detail. A +provider outage is not buyer evidence and must not be returned as an API error +or persisted as a durable ingestion detail. + +## Decision + +Every contextual-orchestrator, VISION, search, RankWeave, and TEPP boundary +returns a stable product-level unavailable message. Route handlers catch both +known transport/parse failures and unexpected provider exceptions, while +retaining the original exception only as an in-process chained cause for +operator logging. Provider response parsers use generic validation errors and +never interpolate the raw response into an exception message. All +OpenAI-compatible chat-completion consumers use the shared +``chat_completion_content`` validator, so malformed ``choices`` envelopes +cannot escape as raw ``KeyError`` or type-error payloads from a library +boundary. + +The browser API client is a second trust boundary: HTTP 5xx details are +discarded, and transport failures become a stable status-0 client error +before any UI handler can render them. Client-error details remain available +only for actionable validation or authorization responses. + +Missing or malformed evidence remains unavailable; it is never converted into +a fabricated negative result. Existing input-validation errors outside a +provider boundary retain their client-actionable 422 detail. + +## Consequences + +- API clients receive a safe retry/configuration action rather than provider + internals. +- Browser clients cannot turn an upstream 5xx detail or transport exception + into buyer-visible provider diagnostics. +- Server-side debugging keeps exception chaining without exposing it to buyers. +- Malformed provider success envelopes fail closed with a stable validation + error before any channel parser sees them. +- Regression tests exercise unexpected exceptions, not only known transport + subclasses, and assert that provider secrets do not appear in responses. + +## References — APA 7th + +National Institute of Standards and Technology. (2020). *Security and privacy +controls for information systems and organizations* (NIST Special Publication +800-53 Rev. 5). https://doi.org/10.6028/NIST.SP.800-53r5 + +OWASP Foundation. (2025). *Improper error handling*. OWASP Application +Security Verification Standard. https://owasp.org/www-project-application-security-verification-standard/ + +MITRE. (2026). *CWE-209: Generation of error message containing sensitive +information*. https://cwe.mitre.org/data/definitions/209.html diff --git a/docs/adr/0124-ontology-source-window-cursor.md b/docs/adr/0124-ontology-source-window-cursor.md new file mode 100644 index 000000000..a081c4c7a --- /dev/null +++ b/docs/adr/0124-ontology-source-window-cursor.md @@ -0,0 +1,33 @@ +# ADR 0124: Opaque ontology source-window continuation + +**Status:** Accepted +**Date:** 2026-08-21 +**Issue:** [#363](https://github.com/ContextualWisdomLab/LineageWeave/issues/363) +**Predecessor:** [ADR 0168](0168-ontology-provenance-explorer.md) + +**Context:** PR #349 withholds a continuation token when the authorization-safe recursive SQL window is exhausted. The in-memory `after:` cursor can only page facts already loaded, so a buyer cannot inspect every authorized relation in a large neighborhood without fabricating completeness. Issue #363 requires a real database continuation contract that keeps bounds, ABAC, cutoff, and deterministic ordering. + +**Decision:** + +1. `GET /api/ontology/neighborhood` may return a versioned opaque source cursor (`src.v2.`) in addition to the in-memory `after:` token. The source cursor continues the recursive candidate window with keyset pagination. `OFFSET` is forbidden. +2. The process secret is `ONTOLOGY_SOURCE_CURSOR_SECRET`. It is not an OIDC credential, JWKS key, or orchestrator token. A missing or short secret keeps the current truncated-without-cursor behavior. +3. The token uses AES-GCM authenticated encryption with associated data (cursor prefix/version). Plaintext claims never leave the process. The token must not reveal hidden endpoint IDs, omitted counts, SQL ordering keys, timestamps, or tenant identifiers. The v1 custom HMAC keystream format is not accepted by the current implementation. +4. Claims bind tenant/account scope (HMAC digest only), focus type and canonical UUID, `knowledge_cutoff`, depth/node/edge bounds, allowed property codes, last source key, snapshot time, eligibility digest of the frozen visible-post set, contract version, and expiry (15 minutes). +5. Traversal stays focus-connected BFS proximity, then property/tie-break. Source eligibility, `available_time <= knowledge_cutoff`, snapshot time, and endpoint ABAC run before a relation enters a page. A hidden endpoint removes the relation and cannot change visible totals, cursor shape, or timing metadata. +6. A cursor used with a changed focus, cutoff, filter, limit, tenant, eligibility digest, or contract version fails closed. Concurrent graph changes use the sealed snapshot; they never silently splice two graph versions. +7. Page payloads keep the exact-value table, CSV, JSON-LD, provenance, truth status, and mobile/print equivalents. The explorer may accumulate subsequent pages without losing the selected evidence. + +**Consequences:** + +- The in-memory `after:` token remains valid for assembler-level paging of an already-loaded window when no process secret is configured. +- Event Lineage, arbitrary SQL/Cypher/SPARQL, and inference-to-authority promotion stay out of scope. + +**References** + +Krawczyk, H., Bellare, M., & Canetti, R. (1997). *HMAC: Keyed-hashing for message authentication* (RFC 2104). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc2104 + +Bellare, M., & Namprempre, C. (2008). Authenticated encryption: Relations among notions and analysis of the generic composition paradigm. *Journal of Cryptology, 21*(4), 469–491. https://doi.org/10.1007/s00145-008-9026-x + +Dworkin, M. (2007). *Recommendation for block cipher modes of operation: Galois/Counter Mode (GCM) and GMAC* (NIST Special Publication 800-38D). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-38D + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/prov-o/ diff --git a/docs/adr/0125-source-cursor-aead.md b/docs/adr/0125-source-cursor-aead.md new file mode 100644 index 000000000..8ee275501 --- /dev/null +++ b/docs/adr/0125-source-cursor-aead.md @@ -0,0 +1,42 @@ +# ADR 0125: Use a vetted AEAD for ontology source cursors + +**Status:** Accepted +**Date:** 2026-08-21 +**Issue:** PR #349 review finding on the custom cursor keystream +**Predecessor:** [ADR 0124](0124-ontology-source-window-cursor.md) + +## Context + +The ontology continuation cursor is an authenticated, short-lived bearer +token. Its claims include SQL ordering keys and authorization-bound snapshot +digests, so confidentiality and tamper detection are both required. The +previous `src.v1.` implementation combined an HMAC-derived keystream with a +separate HMAC tag. A bespoke encryption construction is harder to audit than a +vetted authenticated-encryption primitive. + +## Decision + +1. Mint `src.v2.` cursors with `cryptography`'s AES-GCM implementation. +2. Use a fresh 96-bit nonce per token and bind the prefix/version as associated + data. Derive the 256-bit AES key from the process secret with a + domain-separated HMAC-SHA256 derivation. +3. Reject v1 and malformed, tampered, expired, scope-mismatched, or stale + tokens with the existing fail-closed `malformed_cursor`/`stale_snapshot` + errors. No migration is needed because the maximum cursor lifetime is 15 + minutes. +4. Keep the minimum process secret length and never place account, tenant, or + hidden endpoint identifiers in plaintext. + +## Consequences + +The implementation removes custom `_keystream`/`_xor` encryption code and +delegates authenticated encryption to a maintained library. Existing v1 +cursors expire naturally and are rejected during the rollout; clients request +the first page again. + +## References + +Dworkin, M. (2007). *Recommendation for block cipher modes of operation: +Galois/Counter Mode (GCM) and GMAC* (NIST Special Publication 800-38D). +National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-38D diff --git a/docs/adr/0168-ontology-provenance-explorer.md b/docs/adr/0168-ontology-provenance-explorer.md new file mode 100644 index 000000000..aed4ef0b7 --- /dev/null +++ b/docs/adr/0168-ontology-provenance-explorer.md @@ -0,0 +1,55 @@ +# ADR 0168: Heterogeneous ontology and provenance explorer + +**Status:** Accepted +**Date:** 2026-08-21 +**Figma:** File ID `1Su3lDRmiZdcUs47t1QwIX` +**Issue:** [#341](https://github.com/ContextualWisdomLab/LineageWeave/issues/341) + +**Context:** The workspace DAG is reconstructed Event Lineage (post/record nodes and inferred parent-to-child links). The formal LineageWeave ontology also defines heterogeneous instance types (`Post`, `Person`, `CorporateEntity`, `Team`) and properties (`mentions`, `affiliatedWith`, `coMentionedWith`, SKOS broader). Calling Event Lineage an ontology graph overstates what that surface renders. PR #330 remains the Event Lineage readability slice and must not become a mixed lineage/ontology graph. + +**Decision:** + +1. PostgreSQL remains authoritative. OWL/RDF/JSON-LD is a governed projection, not a second mutable store. +2. `GET /api/ontology/neighborhood` returns a bounded typed neighborhood with explicit `focus_node_type`, `focus_node_id`, depth/node/edge bounds, property filter, `knowledge_cutoff`, and opaque `after:` cursor. +3. RBAC/ABAC and source eligibility run before any node, edge, label, count, or path enters the response. A hidden endpoint removes the edge. Truncation never reports how many neighbors were omitted. Corporate hierarchy parents use the same visible-post evidence gate as other corporate-entity endpoints; a visible child alone does not reveal a hidden parent label. + A missing and a non-visible focus return the same not-found status and buyer + surface, so the response cannot become a catalog-existence oracle. +4. Truth status is one of `truth_authoritative`, `truth_observed`, `truth_inferred`, `truth_proposed`, `truth_superseded`, `truth_rejected`. Display never promotes inference to authority. + Node truth and `recorded_at` are catalog-owned metadata. A missing catalog + value is omitted from JSON-LD and represented as `null` in the typed API; + edge truth and edge availability never fill a node field. +5. SKOS broader is projected from `corporate_entity.parent_entity_id`. OWL class subsumption is schema, not an instance neighborhood edge, and fails closed. +6. `knowledge_cutoff` binds `available_time` (`min(source_post.created_at)` of supporting evidence). Current-only facts without a time contract stay out of an as-of response. +7. The workspace surface extends the existing Keyman/evidence panel with **Inspect ontology neighborhood**. It is not a second GNB destination. +8. Node type uses shape plus text (never color alone). Every edge carries both endpoint type codes and IDs, so heterogeneous catalogs remain unambiguous even if UUIDs collide. Keyboard users can select every visible node and edge. The graph SVG has no enclosing ARIA `img`. Exact-value table, CSV, JSON-LD, and print expose the same authorized visible graph. +9. Synthetic Storybook frames cover desktop, narrow exact-value-first, node drawer, edge drawer, legend, empty, truncated, denied, stale, and rejected states. No confidential Figma content enters the repository. Storybook inventory records the implementation surface; frame IDs are not copied from the confidential design file (ADR 0002). + +**Consequences:** + +- Event Lineage and ontology neighborhood stay distinct product capabilities. +- Related-node RWR ranking is unchanged; the neighborhood is a typed BFS with fail-closed bounds. +- Coverage, public docstrings, i18n (`en`/`ko`/`zh`/`ja`/`vi`), and Storybook gates apply to this slice. +- Continuing past the SQL source window is [ADR 0124](0124-ontology-source-window-cursor.md) / issue #363. + +**References** + +Cyganiak, R., Wood, D., & Lanthaler, M. (Eds.). (2014). *RDF 1.1 concepts and abstract syntax* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/rdf11-concepts/ + +Brickley, D., & Guha, R. V. (Eds.). (2014). *RDF Schema 1.1* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/rdf-schema/ + +World Wide Web Consortium. (2012). *OWL 2 web ontology language document overview (second edition)* (W3C Recommendation). https://www.w3.org/TR/owl2-overview/ + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge organization system reference* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/skos-reference/ + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/prov-o/ + +Cox, S., & Little, C. (Eds.). (2022). *Time ontology in OWL* (W3C Candidate Recommendation Draft). World Wide Web Consortium. https://www.w3.org/TR/owl-time/ + +Kellogg, G., Champin, P.-A., & Longley, D. (Eds.). (2020). *JSON-LD 1.1* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/json-ld11/ + +Knublauch, H., & Kontokostas, D. (Eds.). (2017). *Shapes constraint language (SHACL)* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/shacl/ + +World Wide Web Consortium. (2024). *Web content accessibility guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +Open Worldwide Application Security Project. (2023). *API1:2023 broken object +level authorization*. https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/ diff --git a/docs/adr/README.md b/docs/adr/README.md index d048dea29..64927cbe0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,6 +15,7 @@ decision from them. | [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`ONTOLOGY_NAMESPACE_INVENTORY.md`](../doctoring/ONTOLOGY_NAMESPACE_INVENTORY.md) | [0157](0157-public-ontology-namespace-identity.md) | | [`image-content-schema.md`](../image-content-schema.md) | [0066](0066-position-preserving-image-content.md) | +| [`storybook-inventory.md`](../storybook-inventory.md) | [0118](0118-uiux-standard-guide-v3-design-overhaul.md), [0168](0168-ontology-provenance-explorer.md) | Files under `docs/doctoring/` remain non-normative supporting evidence even when this map links them to an ADR. Runtime-evidence files record observed diff --git a/docs/doctoring/ONTOLOGY_EXPLORER_REFERENCES.md b/docs/doctoring/ONTOLOGY_EXPLORER_REFERENCES.md new file mode 100644 index 000000000..88e4a4ac8 --- /dev/null +++ b/docs/doctoring/ONTOLOGY_EXPLORER_REFERENCES.md @@ -0,0 +1,42 @@ +# Ontology explorer — doctoring + +These are the standards that ground ADR 0168 / issue #341. Cite them in +APA 7th when you extend the neighborhood, projection, or Buyer graph. + +Cyganiak, R., Wood, D., & Lanthaler, M. (Eds.). (2014). *RDF 1.1 concepts +and abstract syntax* (W3C Recommendation). World Wide Web Consortium. +https://www.w3.org/TR/rdf11-concepts/ + +Brickley, D., & Guha, R. V. (Eds.). (2014). *RDF Schema 1.1* (W3C +Recommendation). World Wide Web Consortium. https://www.w3.org/TR/rdf-schema/ + +World Wide Web Consortium. (2012). *OWL 2 web ontology language document +overview (second edition)* (W3C Recommendation). +https://www.w3.org/TR/owl2-overview/ + +Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge +organization system reference* (W3C Recommendation). World Wide Web +Consortium. https://www.w3.org/TR/skos-reference/ + +Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV +ontology* (W3C Recommendation). World Wide Web Consortium. +https://www.w3.org/TR/prov-o/ + +Cox, S., & Little, C. (Eds.). (2022). *Time ontology in OWL* (W3C +Candidate Recommendation Draft). World Wide Web Consortium. +https://www.w3.org/TR/owl-time/ + +Kellogg, G., Champin, P.-A., & Longley, D. (Eds.). (2020). *JSON-LD 1.1* +(W3C Recommendation). World Wide Web Consortium. +https://www.w3.org/TR/json-ld11/ + +Knublauch, H., & Kontokostas, D. (Eds.). (2017). *Shapes constraint language +(SHACL)* (W3C Recommendation). World Wide Web Consortium. +https://www.w3.org/TR/shacl/ + +World Wide Web Consortium. (2024). *Web content accessibility guidelines +(WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/ + +Open Worldwide Application Security Project. (2023). *API1:2023 broken object +level authorization*. +https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/ diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 0d729eec7..3c56ca63d 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -12,6 +12,7 @@ buyer-facing control you can click before changing product CSS. | `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` | | `Lineage/LineageDag` | Open the current branch node; compare empty, grouped/forked, ungrouped, and long-title states before changing graph CSS. | `--surface`, `--border`, `LineageDag` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | +| `Evidence/OntologyExplorer` | Inspect typed people/orgs/posts, then open authorized evidence. Distinct from Event Lineage. | `--color-primary`, `--color-table-border`, `OntologyExplorer` | | `Reports/LeftoverPairList` | Read residual R, observed Y, expected E, map rank, and distance after IRT main effects, then open the named post. | `--color-chip-border`, `LeftoverPairList` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts index 4907f91df..cec7405d2 100644 --- a/frontend/.storybook/preview.ts +++ b/frontend/.storybook/preview.ts @@ -1,10 +1,14 @@ import type { Preview } from "@storybook/react-vite"; +import { MINIMAL_VIEWPORTS } from "storybook/viewport"; import "../src/index.css"; import "../src/App.css"; const preview: Preview = { parameters: { controls: { matchers: { color: /(background|color)$/i } }, + viewport: { + options: MINIMAL_VIEWPORTS, + }, }, }; diff --git a/frontend/package.json b/frontend/package.json index 64cf88197..33d6c7d6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.26", + "version": "2.14.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index a40c500f8..84c802797 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -905,3 +905,132 @@ gap: 0.5rem; } } + +.ontology-explorer { + margin-top: 1rem; + padding: 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel, 8px); + background: var(--surface, #fff); +} + +.ontology-explorer-header, +.ontology-explorer-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: flex-start; + justify-content: space-between; +} + +.ontology-search { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin: 0.75rem 0; +} + +.ontology-graph { + max-width: 100%; + overflow: visible; +} + +.ontology-node { + cursor: pointer; + fill: var(--color-background); + stroke: var(--color-text-heading); + stroke-width: 1.5; +} + +.ontology-node text { + fill: var(--color-text-heading); + stroke: none; + font-size: 0.8rem; +} + +.ontology-node-type { + font-size: 0.7rem; + fill: var(--color-text); +} + +.ontology-node-selected { + stroke: var(--color-primary); + stroke-width: 2.5; +} + +.ontology-node:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.ontology-edge { + fill: none; + stroke: var(--color-accent-silver); + stroke-width: 1.5; +} + +.ontology-edge-selected { + stroke: var(--color-primary); + stroke-width: 2.5; +} + +.ontology-edge-label { + fill: var(--color-text); + font-size: 0.7rem; +} + +.ontology-edge-hit { + fill: transparent; + stroke: transparent; + cursor: pointer; +} + +.ontology-edge-hit:focus-visible { + stroke: var(--color-primary); + stroke-width: 3; + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.ontology-exact-values { + margin-top: 1rem; + overflow-x: auto; +} + +.ontology-exact-values table { + width: 100%; + border-collapse: collapse; +} + +.ontology-exact-values th, +.ontology-exact-values td { + border-bottom: 1px solid var(--color-table-border); + padding: 0.4rem 0.6rem; + text-align: left; +} + +.ontology-exact-values .is-selected { + background: var(--color-table-row-hover); +} + +.ontology-drawer { + margin-top: 1rem; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel, 8px); + background: var(--surface-muted, var(--color-palette-gray-100)); +} + +.ontology-legend { + margin: 0.75rem 0; +} + +.ontology-status { + margin: 0.5rem 0; +} + +@media (max-width: 768px) { + .ontology-graph-desktop { + display: none; + } +} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index c5a54f355..560ede9eb 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1412,6 +1412,34 @@ describe("App, authenticated", () => { }), ); } + if (url.includes("/api/ontology/neighborhood")) { + return Promise.resolve( + jsonResponse({ + focus_node_id: "post-1", + focus_node_type_code: "node_post", + truncated: false, + next_cursor: null, + limitation_code: "neighborhood_empty", + nodes: [ + { + node_id: "post-1", + node_type_code: "node_post", + ontology_class_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + display_label: "Public post", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 0, + shape_code: "rectangle", + }, + ], + edges: [], + exact_value_rows: [], + jsonld: { "@graph": [] }, + }), + ); + } if (url.endsWith("/api/corporate-entities/corp-1/related")) { return Promise.resolve( jsonResponse({ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d7eaeb988..505f6d090 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -86,6 +86,7 @@ import { import { CitationChip } from "./components/CitationChip"; import { CutoffKnownBody } from "./components/CutoffKnownBody"; import { LineageEntityPicker } from "./components/LineageEntityPicker"; +import { OntologyExplorer } from "./components/OntologyExplorer"; import { AskEvidenceLayerPopup } from "./components/AskEvidenceLayerPopup"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { chatEvidenceKindLabel } from "./evidenceKindLabels"; @@ -858,6 +859,12 @@ function KeymanPanel({ const [related, setRelated] = useState(null); const [roleHistory, setRoleHistory] = useState([]); const [selectedName, setSelectedName] = useState(null); + const [selectedFocus, setSelectedFocus] = useState<{ + nodeTypeCode: string; + nodeId: string; + label: string; + } | null>(null); + const [ontologyOpen, setOntologyOpen] = useState(false); const [landedRelated, setLandedRelated] = useState(null); const [landedRelatedName, setLandedRelatedName] = useState(null); const [extracting, setExtracting] = useState(false); @@ -873,6 +880,7 @@ function KeymanPanel({ async function handleSelect(personId: string, personName: string) { const requestId = ++relatedRequest.current; setSelectedName(personName); + setSelectedFocus({ nodeTypeCode: NODE_PERSON, nodeId: personId, label: personName }); setRelated(null); setRoleHistory([]); try { @@ -889,6 +897,7 @@ function KeymanPanel({ async function handleSelectEntity(entityId: string, entityName: string) { const requestId = ++relatedRequest.current; setSelectedName(entityName); + setSelectedFocus({ nodeTypeCode: NODE_CORPORATE_ENTITY, nodeId: entityId, label: entityName }); setRelated(null); setRoleHistory([]); try { @@ -902,6 +911,7 @@ function KeymanPanel({ async function handleSelectTeam(teamId: string, teamName: string) { const requestId = ++relatedRequest.current; setSelectedName(teamName); + setSelectedFocus({ nodeTypeCode: NODE_TEAM, nodeId: teamId, label: teamName }); setRelated(null); setRoleHistory([]); try { @@ -919,6 +929,11 @@ function KeymanPanel({ const first = keymen[0]; const requestId = ++relatedRequest.current; setSelectedName(first.person_name); + setSelectedFocus({ + nodeTypeCode: NODE_PERSON, + nodeId: first.person_id, + label: first.person_name, + }); setRelated(null); setRoleHistory([]); fetchRelatedKeymen(accessToken, first.person_id) @@ -972,6 +987,11 @@ function KeymanPanel({ if (!focusPerson) return; const requestId = ++relatedRequest.current; setSelectedName(focusPerson.personName); + setSelectedFocus({ + nodeTypeCode: NODE_PERSON, + nodeId: focusPerson.personId, + label: focusPerson.personName, + }); setRelated(null); setRoleHistory([]); fetchRelatedKeymen(accessToken, focusPerson.personId) @@ -990,6 +1010,11 @@ function KeymanPanel({ if (!focusEntity) return; const requestId = ++relatedRequest.current; setSelectedName(focusEntity.entityName); + setSelectedFocus({ + nodeTypeCode: NODE_CORPORATE_ENTITY, + nodeId: focusEntity.entityId, + label: focusEntity.entityName, + }); setRelated(null); setRoleHistory([]); fetchRelatedEntity(accessToken, focusEntity.entityId) @@ -1005,6 +1030,11 @@ function KeymanPanel({ if (!focusTeam) return; const requestId = ++relatedRequest.current; setSelectedName(focusTeam.teamName); + setSelectedFocus({ + nodeTypeCode: NODE_TEAM, + nodeId: focusTeam.teamId, + label: focusTeam.teamName, + }); setRelated(null); fetchRelatedTeam(accessToken, focusTeam.teamId) .then((result) => { @@ -1151,6 +1181,14 @@ function KeymanPanel({

{t("Keymen")}

+ {canExtract && !orchestratorOff && (
{t("Evidence operations")} @@ -1277,6 +1315,15 @@ function KeymanPanel({ {afterList && landFirstRelated && landedRelatedName && landedRelated !== null ? ( ) : null} + {ontologyOpen ? ( + + ) : null} ); } diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts new file mode 100644 index 000000000..d8020f7e1 --- /dev/null +++ b/frontend/src/api.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BackendError, fetchMe, updateTenantConfig } from "./api"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("backendFetch provider-error boundary", () => { + it("does not expose provider details from server failures", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ detail: "provider secret and upstream stack trace" }), + { status: 502, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + await expect(fetchMe("access-token")).rejects.toMatchObject({ + status: 502, + message: "The service could not complete this request. Try again later.", + }); + }); + + it("turns transport failures into the same safe error type", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("provider secret"))); + + await expect(fetchMe("access-token")).rejects.toBeInstanceOf(BackendError); + await expect(fetchMe("access-token")).rejects.toMatchObject({ + status: 0, + message: "The service is unreachable. Try again later.", + }); + }); + + it("keeps tenant settings on the shared error boundary", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ detail: "provider diagnostic" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + await expect(updateTenantConfig("access-token", "Example tenant")).rejects.toMatchObject({ + status: 500, + message: "The service could not complete this request. Try again later.", + }); + }); +}); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 433dd073a..d90c2e246 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -371,7 +371,15 @@ export class BackendError extends Error { readonly status: number; constructor(path: string, status: number, detail?: string) { - super(detail && detail.trim() ? detail : `${path} -> HTTP ${status}`); + const message = + status === 0 + ? "The service is unreachable. Try again later." + : status >= 500 + ? "The service could not complete this request. Try again later." + : detail && detail.trim() + ? detail + : `${path} -> HTTP ${status}`; + super(message); this.name = "BackendError"; this.status = status; } @@ -382,14 +390,19 @@ async function backendFetch( accessToken: string, init?: RequestInit, ): Promise { - const response = await fetch(`${config.backendBaseUrl}${path}`, { - ...init, - headers: { - Authorization: `Bearer ${accessToken}`, - ...(init?.body ? { "Content-Type": "application/json" } : {}), - ...init?.headers, - }, - }); + let response: Response; + try { + response = await fetch(`${config.backendBaseUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${accessToken}`, + ...(init?.body ? { "Content-Type": "application/json" } : {}), + ...init?.headers, + }, + }); + } catch { + throw new BackendError(path, 0); + } if (!response.ok) { let detail: string | undefined; try { @@ -705,6 +718,96 @@ export function fetchRelatedTeam( return backendFetch(`/api/teams/${teamId}/related`, accessToken); } +export interface OntologyGraphNodePayload { + node_id: string; + node_type_code: string; + ontology_class_iri: string; + display_label: string; + truth_status_code: string | null; + valid_from: string | null; + valid_to: string | null; + recorded_at: string | null; + evidence_count: number; + shape_code: string; +} + +export interface OntologyGraphEdgePayload { + edge_id: string; + source_node_type_code: string; + source_node_id: string; + target_node_type_code: string; + target_node_id: string; + property_code: string; + ontology_property_iri: string; + property_label: string; + truth_status_code: string; + valid_from: string | null; + valid_to: string | null; + recorded_at: string; + provenance_reference: string | null; + evidence_references: string[]; +} + +export interface OntologyExactValueRow { + edge_id: string; + source_node_id: string; + source_label: string; + source_type_code: string; + property_code: string; + property_label: string; + ontology_property_iri: string; + target_node_id: string; + target_label: string; + target_type_code: string; + truth_status_code: string; + recorded_at: string; + valid_from: string; + valid_to: string; + evidence_count: string; +} + +export interface OntologyNeighborhoodPayload { + focus_node_id: string; + focus_node_type_code: string; + truncated: boolean; + next_cursor: string | null; + limitation_code: string | null; + nodes: OntologyGraphNodePayload[]; + edges: OntologyGraphEdgePayload[]; + exact_value_rows: OntologyExactValueRow[]; + jsonld: Record; +} + +export interface OntologyNeighborhoodQuery { + focusNodeType: string; + focusNodeId: string; + maximumDepth?: number; + maximumNodes?: number; + maximumEdges?: number; + allowedPropertyCodes?: string[]; + knowledgeCutoff?: string; + cursor?: string; +} + +export function fetchOntologyNeighborhood( + accessToken: string, + query: OntologyNeighborhoodQuery, +): Promise { + const params = new URLSearchParams({ + focus_node_type: query.focusNodeType, + focus_node_id: query.focusNodeId, + }); + if (query.maximumDepth != null) params.set("maximum_depth", String(query.maximumDepth)); + if (query.maximumNodes != null) params.set("maximum_nodes", String(query.maximumNodes)); + if (query.maximumEdges != null) params.set("maximum_edges", String(query.maximumEdges)); + if (query.knowledgeCutoff) params.set("knowledge_cutoff", query.knowledgeCutoff); + if (query.cursor) params.set("cursor", query.cursor); + for (const code of query.allowedPropertyCodes ?? []) { + params.append("allowed_property_codes", code); + } + return backendFetch(`/api/ontology/neighborhood?${params.toString()}`, accessToken); +} + export function extractPostKeymen( accessToken: string, postId: string, @@ -1122,27 +1225,16 @@ export function fetchRankings(accessToken: string): Promise { return backendFetch("/api/rankings", accessToken); } -export async function fetchTenantConfig(accessToken: string): Promise<{ brandName: string }> { - const response = await fetch(`${config.backendBaseUrl}/api/settings`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch tenant config: ${response.status}`); - } - return response.json(); +export function fetchTenantConfig(accessToken: string): Promise<{ brandName: string }> { + return backendFetch("/api/settings", accessToken); } -export async function updateTenantConfig(accessToken: string, brandName: string): Promise<{ brandName: string }> { - const response = await fetch(`${config.backendBaseUrl}/api/settings`, { +export function updateTenantConfig( + accessToken: string, + brandName: string, +): Promise<{ brandName: string }> { + return backendFetch("/api/settings", accessToken, { method: "PATCH", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, body: JSON.stringify({ brandName }), }); - if (!response.ok) { - throw new Error(`Failed to update tenant config: ${response.status}`); - } - return response.json(); } diff --git a/frontend/src/components/OntologyExplorer.stabilization.test.tsx b/frontend/src/components/OntologyExplorer.stabilization.test.tsx new file mode 100644 index 000000000..e14209344 --- /dev/null +++ b/frontend/src/components/OntologyExplorer.stabilization.test.tsx @@ -0,0 +1,353 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { OntologyNeighborhoodPayload } from "../api"; +import { OntologyExplorer } from "./OntologyExplorer"; + +const POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"; +const PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1"; + +function payload(overrides: Partial = {}): OntologyNeighborhoodPayload { + return { + focus_node_id: POST_ID, + focus_node_type_code: "node_post", + truncated: false, + next_cursor: null, + limitation_code: null, + nodes: [ + { + node_id: POST_ID, + node_type_code: "node_post", + ontology_class_iri: "https://example.test/Post", + display_label: "Demo post", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 1, + shape_code: "rectangle", + }, + { + node_id: PERSON_ID, + node_type_code: "node_person", + ontology_class_iri: "https://example.test/Person", + display_label: "Test person", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 0, + shape_code: "ellipse", + }, + ], + edges: [ + { + edge_id: "mentions:post-person", + source_node_type_code: "node_post", + source_node_id: POST_ID, + target_node_type_code: "node_person", + target_node_id: PERSON_ID, + property_code: "mentions", + ontology_property_iri: "https://example.test/mentions", + property_label: "mentions", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + provenance_reference: "corporate_entity.parent_entity_id", + evidence_references: [], + }, + ], + exact_value_rows: [ + { + edge_id: "mentions:post-person", + source_node_id: POST_ID, + source_label: "Demo post", + source_type_code: "node_post", + property_code: "mentions", + property_label: "mentions", + ontology_property_iri: "https://example.test/mentions", + target_node_id: PERSON_ID, + target_label: "Test person", + target_type_code: "node_person", + truth_status_code: "truth_observed", + recorded_at: "2026-01-10T12:00:00+00:00", + valid_from: "", + valid_to: "", + evidence_count: "0", + }, + ], + jsonld: { "@graph": [] }, + ...overrides, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("OntologyExplorer stabilization contracts", () => { + it("classifies a provided cutoff-bound payload as stale on the first render", () => { + render( + , + ); + + expect( + screen.getByText( + "This neighborhood is bound to a knowledge cutoff. Compare with live evidence next.", + ), + ).toBeInTheDocument(); + }); + + it("loads the next opaque cursor page from a truncated live neighborhood", async () => { + const first = payload({ + truncated: true, + next_cursor: "after:mentions:post-person", + limitation_code: "neighborhood_truncated", + }); + const second = payload({ + truncated: false, + next_cursor: null, + limitation_code: null, + nodes: [ + payload().nodes[0], + { ...payload().nodes[1], node_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2", display_label: "Second person" }, + ], + edges: [ + { + ...payload().edges[0], + edge_id: "mentions:post-second-person", + target_node_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2", + }, + ], + exact_value_rows: [ + { + ...payload().exact_value_rows[0], + edge_id: "mentions:post-second-person", + target_node_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2", + target_label: "Second person", + }, + ], + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => first }) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => second }); + vi.stubGlobal("fetch", fetchMock); + + render( + , + ); + + expect( + await screen.findByText( + "Neighborhood truncated. Load the next relation page or inspect one edge.", + ), + ).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Load next relation page" })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(String(fetchMock.mock.calls[1][0])).toContain( + "cursor=after%3Amentions%3Apost-person", + ); + expect(screen.getAllByText("Test person").length).toBeGreaterThan(0); + expect(screen.getAllByText("Second person").length).toBeGreaterThan(0); + }); + + it("names the next action when a hard source bound has no cursor", () => { + const evidenceBearingEdges = payload().edges.map((edge) => ({ + ...edge, + evidence_references: [POST_ID], + })); + render( + ({ + ...row, + evidence_count: "1", + })), + })} + />, + ); + + expect( + screen.getByText( + "Neighborhood reached the authorized query bound. Narrow the property filter or reduce traversal depth.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Load next relation page" }), + ).not.toBeInTheDocument(); + }); + + it("does not call provenance-only edges hidden evidence", async () => { + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: /Select edge: mentions from/ })); + expect( + screen.getByText( + "No direct evidence post is attached. Review the provenance reference above.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByText("Hidden evidence was removed. No omitted count is shown."), + ).not.toBeInTheDocument(); + }); + + it("hides live refocus when the neighborhood is a static catalog snapshot", async () => { + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Select node: Post Demo post" })); + expect(screen.queryByRole("button", { name: "Focus this node next" })).not.toBeInTheDocument(); + }); + + it("clears a previously loaded neighborhood when the access token is removed", async () => { + const { rerender } = render( + , + ); + + expect(screen.getAllByText("Demo post").length).toBeGreaterThan(0); + rerender( + , + ); + + await waitFor(() => { + expect(screen.queryAllByText("Demo post")).toHaveLength(0); + }); + expect( + screen.getByText( + "No visible ontology relations for this focus. Open a Keyman or affiliated organization next.", + ), + ).toBeInTheDocument(); + }); + + it("fetches a new neighborhood after focusing a node with a live token", async () => { + const first = payload(); + const second = payload({ + focus_node_id: PERSON_ID, + focus_node_type_code: "node_person", + nodes: [ + { + node_id: PERSON_ID, + node_type_code: "node_person", + ontology_class_iri: "https://example.test/Person", + display_label: "Focused person", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 1, + shape_code: "ellipse", + }, + ], + edges: [], + exact_value_rows: [], + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => second, + }); + vi.stubGlobal("fetch", fetchMock); + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Select node: Person Test person" })); + await userEvent.click(screen.getByRole("button", { name: "Focus this node next" })); + expect(await screen.findByText("Focused person")).toBeInTheDocument(); + expect(String(fetchMock.mock.calls[0][0])).toContain(`focus_node_id=${PERSON_ID}`); + + await userEvent.click(screen.getByRole("button", { name: "Reset focus" })); + await waitFor(() => expect(screen.getAllByText("Demo post")).toHaveLength(2)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("accumulates the next page without losing the selected evidence", async () => { + const first = payload({ + truncated: true, + next_cursor: "src.v2.opaque-token", + limitation_code: "neighborhood_truncated", + }); + const second = payload({ + truncated: false, + next_cursor: null, + limitation_code: null, + nodes: [ + { + node_id: PERSON_ID, + node_type_code: "node_person", + ontology_class_iri: "https://example.test/Person", + display_label: "Paged person", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 1, + shape_code: "ellipse", + }, + ], + edges: [], + exact_value_rows: [], + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => first }) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => second }); + vi.stubGlobal("fetch", fetchMock); + render( + , + ); + await userEvent.click(await screen.findByRole("button", { name: /Select edge: mentions from/ })); + expect(screen.getByLabelText("Edge provenance")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Load next relation page" })); + expect(await screen.findByText("Paged person")).toBeInTheDocument(); + expect(screen.getAllByText("Demo post").length).toBeGreaterThan(0); + expect(screen.getByLabelText("Edge provenance")).toBeInTheDocument(); + expect(String(fetchMock.mock.calls[1][0])).toContain("cursor=src.v2.opaque-token"); + }); +}); diff --git a/frontend/src/components/OntologyExplorer.stories.tsx b/frontend/src/components/OntologyExplorer.stories.tsx new file mode 100644 index 000000000..d05861166 --- /dev/null +++ b/frontend/src/components/OntologyExplorer.stories.tsx @@ -0,0 +1,271 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { OntologyNeighborhoodPayload } from "../api"; +import { OntologyExplorer } from "./OntologyExplorer"; + +const POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"; +const PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1"; +const CORP_ID = "cccccccc-cccc-cccc-cccc-ccccccccccc1"; + +const demoNeighborhood: OntologyNeighborhoodPayload = { + focus_node_id: POST_ID, + focus_node_type_code: "node_post", + truncated: false, + next_cursor: null, + limitation_code: null, + nodes: [ + { + node_id: POST_ID, + node_type_code: "node_post", + ontology_class_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Post", + display_label: "Demo public post", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 1, + shape_code: "rectangle", + }, + { + node_id: PERSON_ID, + node_type_code: "node_person", + ontology_class_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#Person", + display_label: "Test Person", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 1, + shape_code: "ellipse", + }, + { + node_id: CORP_ID, + node_type_code: "node_corporate_entity", + ontology_class_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#CorporateEntity", + display_label: "Demo Corp", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 1, + shape_code: "hexagon", + }, + ], + edges: [ + { + edge_id: `mentions:node_post:${POST_ID}:node_person:${PERSON_ID}`, + source_node_type_code: "node_post", + source_node_id: POST_ID, + target_node_type_code: "node_person", + target_node_id: PERSON_ID, + property_code: "mentions", + ontology_property_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#mentions", + property_label: "mentions", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + provenance_reference: "knowledge_graph_edge", + evidence_references: [POST_ID], + }, + { + edge_id: `affiliatedWith:node_person:${PERSON_ID}:node_corporate_entity:${CORP_ID}`, + source_node_type_code: "node_person", + source_node_id: PERSON_ID, + target_node_type_code: "node_corporate_entity", + target_node_id: CORP_ID, + property_code: "affiliatedWith", + ontology_property_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#affiliatedWith", + property_label: "affiliated with", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + provenance_reference: "knowledge_graph_edge", + evidence_references: [POST_ID], + }, + ], + exact_value_rows: [ + { + edge_id: `mentions:node_post:${POST_ID}:node_person:${PERSON_ID}`, + source_node_id: POST_ID, + source_label: "Demo public post", + source_type_code: "node_post", + property_code: "mentions", + property_label: "mentions", + ontology_property_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#mentions", + target_node_id: PERSON_ID, + target_label: "Test Person", + target_type_code: "node_person", + truth_status_code: "truth_observed", + recorded_at: "2026-01-10T12:00:00+00:00", + valid_from: "", + valid_to: "", + evidence_count: "1", + }, + { + edge_id: `affiliatedWith:node_person:${PERSON_ID}:node_corporate_entity:${CORP_ID}`, + source_node_id: PERSON_ID, + source_label: "Test Person", + source_type_code: "node_person", + property_code: "affiliatedWith", + property_label: "affiliated with", + ontology_property_iri: "https://contextualwisdomlab.github.io/lineageweave/ontology#affiliatedWith", + target_node_id: CORP_ID, + target_label: "Demo Corp", + target_type_code: "node_corporate_entity", + truth_status_code: "truth_observed", + recorded_at: "2026-01-10T12:00:00+00:00", + valid_from: "", + valid_to: "", + evidence_count: "1", + }, + ], + jsonld: { + "@context": { lw: "https://contextualwisdomlab.github.io/lineageweave/ontology#" }, + "@graph": [], + }, +}; + +const emptyNeighborhood: OntologyNeighborhoodPayload = { + ...demoNeighborhood, + edges: [], + exact_value_rows: [], + nodes: [demoNeighborhood.nodes[0]], + limitation_code: "neighborhood_empty", +}; + +const truncatedNeighborhood: OntologyNeighborhoodPayload = { + ...demoNeighborhood, + truncated: true, + next_cursor: "after:mentions", + limitation_code: "neighborhood_truncated", + edges: [demoNeighborhood.edges[0]], + exact_value_rows: [demoNeighborhood.exact_value_rows[0]], +}; + +const partialNeighborhood: OntologyNeighborhoodPayload = { + ...demoNeighborhood, + nodes: demoNeighborhood.nodes.slice(0, 2), + edges: [demoNeighborhood.edges[0]], + exact_value_rows: [demoNeighborhood.exact_value_rows[0]], + limitation_code: null, +}; + +const rejectedNeighborhood: OntologyNeighborhoodPayload = { + ...demoNeighborhood, + edges: [ + { + ...demoNeighborhood.edges[1], + truth_status_code: "truth_rejected", + }, + ], + exact_value_rows: [ + { + ...demoNeighborhood.exact_value_rows[1], + truth_status_code: "truth_rejected", + }, + ], +}; + +const meta = { + title: "Evidence/OntologyExplorer", + component: OntologyExplorer, + args: { + focusNodeType: "node_post", + focusNodeId: POST_ID, + neighborhood: demoNeighborhood, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const DesktopNeighborhood: Story = {}; + +export const NarrowExactValue: Story = { + globals: { + viewport: { value: "mobile1", isRotated: false }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const NodeDrawer: Story = { + play: ({ canvasElement }) => { + const node = [...canvasElement.querySelectorAll("[role=button]")].find((element) => + element.getAttribute("aria-label")?.startsWith("Select node:"), + ); + if (!node) throw new Error("Ontology node control was not rendered"); + node.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }, +}; + +export const EdgeDrawer: Story = { + play: ({ canvasElement }) => { + const edge = [...canvasElement.querySelectorAll("[role=button]")].find((element) => + element.getAttribute("aria-label")?.startsWith("Select edge:"), + ); + if (!edge) throw new Error("Ontology edge control was not rendered"); + edge.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }, +}; + +export const LegendAndFilter: Story = { + play: ({ canvasElement }) => { + const search = canvasElement.querySelector( + 'input[aria-label="Search within this neighborhood"]', + ); + if (!search) throw new Error("Ontology search control was not rendered"); + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setValue?.call(search, "Priya"); + search.dispatchEvent(new Event("input", { bubbles: true })); + }, +}; + +export const Empty: Story = { + args: { + neighborhood: emptyNeighborhood, + status: "empty", + }, +}; + +export const Truncated: Story = { + args: { + neighborhood: truncatedNeighborhood, + status: "truncated", + }, +}; + +export const Partial: Story = { + args: { + neighborhood: partialNeighborhood, + status: "ready", + }, +}; + +export const Denied: Story = { + args: { + neighborhood: null, + status: "denied", + }, +}; + +export const StaleCutoff: Story = { + args: { + knowledgeCutoff: "2026-01-15T12:00:00Z", + status: "stale", + }, +}; + +export const RejectedProposal: Story = { + args: { + neighborhood: rejectedNeighborhood, + status: "rejected", + }, +}; diff --git a/frontend/src/components/OntologyExplorer.test.tsx b/frontend/src/components/OntologyExplorer.test.tsx new file mode 100644 index 000000000..6c6dbb8e4 --- /dev/null +++ b/frontend/src/components/OntologyExplorer.test.tsx @@ -0,0 +1,381 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { BackendError, fetchOntologyNeighborhood } from "../api"; +import type { OntologyNeighborhoodPayload } from "../api"; +import { OntologyExplorer } from "./OntologyExplorer"; +import { filterNeighborhood } from "../ontologyLayout"; + +vi.mock("../api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchOntologyNeighborhood: vi.fn() }; +}); + +const POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"; +const PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1"; +const CORP_ID = "cccccccc-cccc-cccc-cccc-ccccccccccc1"; + +function neighborhood(overrides: Partial = {}): OntologyNeighborhoodPayload { + return { + focus_node_id: POST_ID, + focus_node_type_code: "node_post", + truncated: false, + next_cursor: null, + limitation_code: null, + nodes: [ + { + node_id: POST_ID, + node_type_code: "node_post", + ontology_class_iri: "https://example.test/Post", + display_label: "Demo public post", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 1, + shape_code: "rectangle", + }, + { + node_id: PERSON_ID, + node_type_code: "node_person", + ontology_class_iri: "https://example.test/Person", + display_label: "Test Person", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 1, + shape_code: "ellipse", + }, + { + node_id: CORP_ID, + node_type_code: "node_corporate_entity", + ontology_class_iri: "https://example.test/CorporateEntity", + display_label: "Demo Corp", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 1, + shape_code: "hexagon", + }, + ], + edges: [ + { + edge_id: "mentions:post-person", + source_node_type_code: "node_post", + source_node_id: POST_ID, + target_node_type_code: "node_person", + target_node_id: PERSON_ID, + property_code: "mentions", + ontology_property_iri: "https://example.test/mentions", + property_label: "mentions", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + provenance_reference: "knowledge_graph_edge", + evidence_references: [POST_ID], + }, + { + edge_id: "affiliated:person-corp", + source_node_type_code: "node_person", + source_node_id: PERSON_ID, + target_node_type_code: "node_corporate_entity", + target_node_id: CORP_ID, + property_code: "affiliatedWith", + ontology_property_iri: "https://example.test/affiliatedWith", + property_label: "affiliated with", + truth_status_code: "truth_inferred", + valid_from: "2026-01-01T00:00:00+00:00", + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + provenance_reference: "knowledge_graph_edge", + evidence_references: [POST_ID], + }, + ], + exact_value_rows: [ + { + edge_id: "mentions:post-person", + source_node_id: POST_ID, + source_label: "Demo public post", + source_type_code: "node_post", + property_code: "mentions", + property_label: "mentions", + ontology_property_iri: "https://example.test/mentions", + target_node_id: PERSON_ID, + target_label: "Test Person", + target_type_code: "node_person", + truth_status_code: "truth_observed", + recorded_at: "2026-01-10T12:00:00+00:00", + valid_from: "", + valid_to: "", + evidence_count: "1", + }, + { + edge_id: "affiliated:person-corp", + source_node_id: PERSON_ID, + source_label: "Test Person", + source_type_code: "node_person", + property_code: "affiliatedWith", + property_label: "affiliated with", + ontology_property_iri: "https://example.test/affiliatedWith", + target_node_id: CORP_ID, + target_label: "Demo Corp", + target_type_code: "node_corporate_entity", + truth_status_code: "truth_inferred", + recorded_at: "2026-01-10T12:00:00+00:00", + valid_from: "2026-01-01T00:00:00+00:00", + valid_to: "", + evidence_count: "1", + }, + ], + jsonld: { "@graph": [] }, + ...overrides, + }; +} + +describe("OntologyExplorer", () => { + it("keeps loaded pages visible when a continuation page fails", async () => { + const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood); + let rejectContinuation!: (error: BackendError) => void; + fetchNeighborhood + .mockResolvedValueOnce(neighborhood({ truncated: true, next_cursor: "page-2" })) + .mockImplementationOnce( + () => new Promise((_resolve, reject) => { + rejectContinuation = reject; + }), + ); + + render( + , + ); + + await userEvent.click(await screen.findByRole("button", { name: "Select node: Post Demo public post" })); + expect(screen.getByRole("heading", { name: "Demo public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Load next relation page" })); + expect(await screen.findByText("Loading ontology neighborhood...")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Select node: Post Demo public post" })).toBeInTheDocument(); + rejectContinuation(new BackendError("/api/ontology/neighborhood", 500)); + expect(await screen.findByText("Ontology neighborhood is unavailable. Open a visible post next.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Select node: Post Demo public post" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Demo public post" })).toBeInTheDocument(); + expect(fetchNeighborhood).toHaveBeenNthCalledWith( + 2, + "synthetic-access-token", + expect.objectContaining({ cursor: "page-2" }), + ); + }); + + it("retries a failed continuation page with the same cursor", async () => { + const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood); + fetchNeighborhood.mockClear(); + fetchNeighborhood + .mockResolvedValueOnce(neighborhood({ truncated: true, next_cursor: "page-2" })) + .mockRejectedValueOnce(new BackendError("/api/ontology/neighborhood", 500)) + .mockResolvedValueOnce(neighborhood({ next_cursor: null })); + + render( + , + ); + + await userEvent.click(await screen.findByRole("button", { name: "Load next relation page" })); + expect(await screen.findByText("Ontology neighborhood is unavailable. Open a visible post next.")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Load next relation page" })); + await waitFor(() => expect(fetchNeighborhood).toHaveBeenCalledTimes(3)); + expect(screen.queryByText("Ontology neighborhood is unavailable. Open a visible post next.")).not.toBeInTheDocument(); + expect(fetchNeighborhood).toHaveBeenNthCalledWith( + 3, + "synthetic-access-token", + expect.objectContaining({ cursor: "page-2" }), + ); + }); + + it.each([403, 404])("uses one fail-closed surface for hidden and missing focus responses (%s)", async (status) => { + const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood); + fetchNeighborhood.mockClear(); + fetchNeighborhood.mockRejectedValueOnce( + new BackendError("/api/ontology/neighborhood", status), + ); + + render( + , + ); + + expect( + await screen.findByText( + "Access denied for this ontology neighborhood. Open a visible post next.", + ), + ).toBeInTheDocument(); + }); + + it("lets keyboard users open node and edge evidence", async () => { + const onSelectPost = vi.fn(); + const onOpenEvidence = vi.fn(); + render( + , + ); + expect( + screen.getByText(/This is an ontology neighborhood, not Event Lineage/), + ).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Select node: Post Demo public post" })); + expect(screen.getByRole("heading", { name: "Demo public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Open evidence post" })); + expect(onSelectPost).toHaveBeenCalledWith(POST_ID); + expect(onOpenEvidence).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: /Select edge: mentions from/ })); + expect(screen.getByText(/Property IRI/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: `Open evidence: ${POST_ID}` })); + expect(onOpenEvidence).toHaveBeenCalledWith(POST_ID); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + it("names empty, truncated, denied, and rejected next actions", () => { + const { rerender } = render( + , + ); + expect( + screen.getAllByText( + "No visible ontology relations for this focus. Open a Keyman or affiliated organization next.", + ).length, + ).toBeGreaterThan(0); + rerender( + , + ); + expect( + screen.getByText( + "Neighborhood reached the authorized query bound. Narrow the property filter or reduce traversal depth.", + ), + ).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText("Access denied for this ontology neighborhood. Open a visible post next.")).toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText("Rejected proposal. Open the evidence and do not treat it as authoritative.")).toBeInTheDocument(); + }); + + it("does not hide rejected or cutoff warnings behind truncation", () => { + const rejected = neighborhood({ + truncated: true, + next_cursor: "page-2", + edges: [{ ...neighborhood().edges[0], truth_status_code: "truth_rejected" }], + }); + const { rerender } = render( + , + ); + expect(screen.getByText("Rejected proposal. Open the evidence and do not treat it as authoritative.")).toBeInTheDocument(); + expect(screen.queryByText(/Load the next relation page or inspect one edge/)).not.toBeInTheDocument(); + + rerender( + , + ); + expect( + screen.getByText("This neighborhood is bound to a knowledge cutoff. Compare with live evidence next."), + ).toBeInTheDocument(); + }); + + it("does not promise paging for a static truncated neighborhood", () => { + render( + , + ); + expect( + screen.getByText( + "Neighborhood reached the authorized query bound. Narrow the property filter or reduce traversal depth.", + ), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Load next relation page" })).not.toBeInTheDocument(); + }); + + it("searches the loaded graph without inventing omitted counts", async () => { + render( + , + ); + await userEvent.type(screen.getByLabelText("Search within this neighborhood"), "Test"); + expect(screen.getAllByText("Test Person").length).toBeGreaterThan(0); + expect(screen.queryByText(/omitted \d/i)).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Reset focus" })); + expect(screen.getByLabelText("Search within this neighborhood")).toHaveValue(""); + }); + + it("keeps JSON-LD aligned with the filtered graph", () => { + const filtered = filterNeighborhood( + neighborhood({ + jsonld: { + "@graph": [ + { "@id": `lw:node/node_post/${POST_ID}` }, + { "@id": `lw:node/node_person/${PERSON_ID}` }, + { "@id": `lw:node/node_corporate_entity/${CORP_ID}` }, + { "@id": "lw:edge/mentions:post-person" }, + { "@id": "lw:edge/affiliated:person-corp" }, + ], + }, + }), + "Demo public", + ); + const graph = filtered?.jsonld["@graph"] as Array<{ "@id": string }>; + expect(graph.map((item) => item["@id"])).toEqual([ + `lw:node/node_post/${POST_ID}`, + `lw:node/node_person/${PERSON_ID}`, + "lw:edge/mentions:post-person", + ]); + }); +}); diff --git a/frontend/src/components/OntologyExplorer.tsx b/frontend/src/components/OntologyExplorer.tsx new file mode 100644 index 000000000..493da1a45 --- /dev/null +++ b/frontend/src/components/OntologyExplorer.tsx @@ -0,0 +1,620 @@ +import { useEffect, useMemo, useState } from "react"; +import { + BackendError, + fetchOntologyNeighborhood, + type OntologyGraphEdgePayload, + type OntologyGraphNodePayload, + type OntologyNeighborhoodPayload, +} from "../api"; +import { t, tf } from "../i18n"; +import { ontologyExplorerText } from "../ontologyExplorerI18n"; +import { accumulateNeighborhoodPages, filterNeighborhood, layoutOntologyNeighborhood, neighborhoodCsv } from "../ontologyLayout"; + +export type OntologyExplorerStatus = + | "ready" + | "loading" + | "empty" + | "truncated" + | "denied" + | "stale" + | "rejected" + | "error"; + +export type OntologyExplorerProps = { + accessToken?: string; + focusNodeType: string; + focusNodeId: string; + neighborhood?: OntologyNeighborhoodPayload | null; + status?: OntologyExplorerStatus; + knowledgeCutoff?: string; + onSelectPost?: (postId: string) => void; + onOpenEvidence?: (postId: string) => void; +}; + +const NODE_TYPE_LABEL: Record = { + node_post: "Post", + node_person: "Person", + node_corporate_entity: "Organization", + node_team: "Team", +}; + +const TRUTH_LABEL: Record = { + truth_authoritative: "Authoritative", + truth_observed: "Observed", + truth_inferred: "Inferred", + truth_proposed: "Proposed", + truth_superseded: "Superseded", + truth_rejected: "Rejected", +}; + +function nodeKey(node: Pick): string { + return `${node.node_type_code}:${node.node_id}`; +} + +/** + * Inspects a typed ontology neighborhood from an authorized focus node. + * + * Next action: select a node or edge, then open its evidence or traverse. + */ +export function OntologyExplorer({ + accessToken, + focusNodeType, + focusNodeId, + neighborhood: provided, + status: providedStatus, + knowledgeCutoff, + onSelectPost, + onOpenEvidence, +}: OntologyExplorerProps) { + const [loaded, setLoaded] = useState(provided ?? null); + const [status, setStatus] = useState( + providedStatus ?? (provided ? statusFromPayload(provided, knowledgeCutoff) : "loading"), + ); + const [query, setQuery] = useState(""); + const [selectedNodeKey, setSelectedNodeKey] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [focusType, setFocusType] = useState(focusNodeType); + const [focusId, setFocusId] = useState(focusNodeId); + const [cursor, setCursor] = useState(undefined); + const [pageRetry, setPageRetry] = useState(0); + const [liveFocus, setLiveFocus] = useState(false); + + function clearSelection() { + setSelectedNodeKey(null); + setSelectedEdgeId(null); + setQuery(""); + } + + useEffect(() => { + setFocusType(focusNodeType); + setFocusId(focusNodeId); + setCursor(undefined); + setPageRetry(0); + setLiveFocus(false); + clearSelection(); + }, [focusNodeType, focusNodeId]); + + useEffect(() => { + const useProvided = Boolean(provided) && !liveFocus; + if (useProvided && provided) { + setLoaded(provided); + setStatus(providedStatus ?? statusFromPayload(provided, knowledgeCutoff)); + return; + } + if (!accessToken) { + setLoaded(null); + setCursor(undefined); + clearSelection(); + setStatus(providedStatus ?? "empty"); + return; + } + let cancelled = false; + setStatus("loading"); + fetchOntologyNeighborhood(accessToken, { + focusNodeType: focusType, + focusNodeId: focusId, + knowledgeCutoff, + cursor, + }) + .then((payload) => { + if (cancelled) return; + setLoaded((current) => + cursor && current ? accumulateNeighborhoodPages(current, payload) : payload, + ); + setStatus(statusFromPayload(payload, knowledgeCutoff)); + }) + .catch((error: unknown) => { + if (cancelled) return; + if (!cursor) setLoaded(null); + if (error instanceof BackendError && (error.status === 403 || error.status === 404)) { + setStatus("denied"); + return; + } + setStatus("error"); + }); + return () => { + cancelled = true; + }; + }, [accessToken, focusType, focusId, knowledgeCutoff, cursor, pageRetry, provided, providedStatus, liveFocus]); + + const visible = useMemo(() => filterNeighborhood(loaded, query), [loaded, query]); + const layout = useMemo(() => (visible ? layoutOntologyNeighborhood(visible) : null), [visible]); + const selectedNode = visible?.nodes.find((node) => nodeKey(node) === selectedNodeKey) ?? null; + const selectedEdge = visible?.edges.find((edge) => edge.edge_id === selectedEdgeId) ?? null; + const canLoadNextPage = Boolean(loaded?.next_cursor && accessToken && !provided); + + function resetFocus() { + setFocusType(focusNodeType); + setFocusId(focusNodeId); + setCursor(undefined); + setPageRetry(0); + setLiveFocus(false); + setSelectedNodeKey(null); + setSelectedEdgeId(null); + setQuery(""); + } + + function loadNextPage() { + if (!loaded?.next_cursor) return; + if (cursor === loaded.next_cursor) { + setPageRetry((attempt) => attempt + 1); + return; + } + setCursor(loaded.next_cursor); + } + + function exportCsv() { + if (!visible) return; + downloadFile("ontology-neighborhood.csv", neighborhoodCsv(visible), "text/csv"); + } + + function exportJsonld() { + if (!visible) return; + downloadFile( + "ontology-neighborhood.jsonld", + `${JSON.stringify(visible.jsonld, null, 2)}\n`, + "application/ld+json", + ); + } + + return ( +
+
+
+

{t("Ontology neighborhood")}

+

{t("Typed relations, not Event Lineage")}

+

+ {t("This is an ontology neighborhood, not Event Lineage.")}{" "} + {t("Event Lineage shows reconstructed post-to-post parents. This graph shows typed people, organizations, teams, and posts.")} +

+
+
+ + + + +
+
+ + {statusMessage(status, loaded, canLoadNextPage)} + {canLoadNextPage && status !== "loading" ? ( +
+ +
+ ) : null} + + {layout && visible && status !== "denied" && (status !== "loading" || Boolean(loaded)) ? ( + <> +
+ { + setSelectedNodeKey(nodeKey(node)); + setSelectedEdgeId(null); + }} + onSelectEdge={(edge) => { + setSelectedEdgeId(edge.edge_id); + setSelectedNodeKey(null); + }} + /> +
+ { + setSelectedEdgeId(edgeId); + setSelectedNodeKey(null); + }} + /> + + ) : null} + {selectedNode ? ( + { + setLiveFocus(true); + setFocusType(selectedNode.node_type_code); + setFocusId(selectedNode.node_id); + setCursor(undefined); + clearSelection(); + }} + onOpenEvidence={ + selectedNode.node_type_code === "node_post" + ? () => (onSelectPost ?? onOpenEvidence)?.(selectedNode.node_id) + : undefined + } + onClose={() => setSelectedNodeKey(null)} + /> + ) : null} + {selectedEdge ? ( + (onOpenEvidence ?? onSelectPost)?.(postId)} + onClose={() => setSelectedEdgeId(null)} + /> + ) : null} +
+ ); +} + +function statusFromPayload( + payload: OntologyNeighborhoodPayload, + knowledgeCutoff?: string, +): OntologyExplorerStatus { + if (payload.limitation_code === "neighborhood_empty") return "empty"; + if (payload.edges.some((edge) => edge.truth_status_code === "truth_rejected")) return "rejected"; + if (knowledgeCutoff) return "stale"; + if (payload.truncated || payload.limitation_code === "neighborhood_truncated") return "truncated"; + return "ready"; +} + +function statusMessage( + status: OntologyExplorerStatus, + payload: OntologyNeighborhoodPayload | null, + canLoadNextPage: boolean, +) { + if (status === "truncated" && payload?.next_cursor && canLoadNextPage) { + return ( +

+ {ontologyExplorerText("Neighborhood truncated. Load the next relation page or inspect one edge.")} +

+ ); + } + if (status === "truncated" && payload && !canLoadNextPage) { + return ( +

+ {ontologyExplorerText( + "Neighborhood reached the authorized query bound. Narrow the property filter or reduce traversal depth.", + )} +

+ ); + } + const messages: Record = { + ready: "", + loading: t("Loading ontology neighborhood..."), + empty: t("No visible ontology relations for this focus. Open a Keyman or affiliated organization next."), + truncated: t("Neighborhood truncated. Page visible relations, then inspect one edge."), + denied: t("Access denied for this ontology neighborhood. Open a visible post next."), + stale: t("This neighborhood is bound to a knowledge cutoff. Compare with live evidence next."), + rejected: t("Rejected proposal. Open the evidence and do not treat it as authoritative."), + error: t("Ontology neighborhood is unavailable. Open a visible post next."), + }; + const text = messages[status]; + if (!text) return null; + return ( +

+ {text} +

+ ); +} + +function OntologyLegend() { + return ( +
+ {t("Legend")} +

{t("Node types use shape plus text, never color alone. Truth status is labeled on every edge.")}

+
    +
  • {t("Post")} — {t("rectangle")}
  • +
  • {t("Person")} — {t("ellipse")}
  • +
  • {t("Organization")} — {t("hexagon")}
  • +
  • {t("Team")} — {t("rounded rectangle")}
  • +
+
    +
  • {t("Authoritative")}
  • +
  • {t("Observed")}
  • +
  • {t("Inferred")}
  • +
  • {t("Proposed")}
  • +
  • {t("Superseded")}
  • +
  • {t("Rejected")}
  • +
+
+ ); +} + +function OntologyGraph({ + layout, + selectedNodeKey, + selectedEdgeId, + onSelectNode, + onSelectEdge, +}: { + layout: ReturnType; + selectedNodeKey: string | null; + selectedEdgeId: string | null; + onSelectNode: (node: OntologyGraphNodePayload) => void; + onSelectEdge: (edge: OntologyGraphEdgePayload) => void; +}) { + return ( + + {t("Ontology neighborhood")} + {layout.edges.map((edge) => { + const midX = (edge.fromX + edge.toX) / 2; + const midY = (edge.fromY + edge.toY) / 2; + const selected = edge.edge_id === selectedEdgeId; + return ( + + + + {edge.property_label} · {t(TRUTH_LABEL[edge.truth_status_code] ?? edge.truth_status_code)} + + onSelectEdge(edge)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectEdge(edge); + } + }} + /> + + ); + })} + {layout.nodes.map((node) => ( + onSelectNode(node)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectNode(node); + } + }} + > + + + {node.display_label} + + + {t(NODE_TYPE_LABEL[node.node_type_code] ?? node.node_type_code)} + + + ))} + + ); +} + +function OntologyShape({ shape }: { shape: string }) { + if (shape === "ellipse") { + return ; + } + if (shape === "hexagon") { + return ; + } + if (shape === "rounded-rectangle") { + return ; + } + return ; +} + +function OntologyExactValueTable({ + payload, + selectedEdgeId, + onSelectEdge, +}: { + payload: OntologyNeighborhoodPayload; + selectedEdgeId: string | null; + onSelectEdge: (edgeId: string) => void; +}) { + return ( +
+

{t("Exact values")}

+ {payload.exact_value_rows.length === 0 ? ( +

{t("No visible ontology relations for this focus. Open a Keyman or affiliated organization next.")}

+ ) : ( + + + + + + + + + + + + + {payload.exact_value_rows.map((row) => ( + + + + + + + + ))} + +
{t("Exact values")}
{t("Source")}{t("Property")}{t("Target")}{t("Truth status")}{t("Recorded at")}
+ + {row.property_label}{row.target_label}{t(TRUTH_LABEL[row.truth_status_code] ?? row.truth_status_code)}{row.recorded_at.slice(0, 10)}
+ )} +
+ ); +} + +function OntologyNodeDrawer({ + node, + canRefocus, + onFocus, + onOpenEvidence, + onClose, +}: { + node: OntologyGraphNodePayload; + canRefocus: boolean; + onFocus: () => void; + onOpenEvidence?: () => void; + onClose: () => void; +}) { + return ( + + ); +} + +function OntologyEdgeDrawer({ + edge, + payload, + onOpenEvidence, + onClose, +}: { + edge: OntologyGraphEdgePayload; + payload: OntologyNeighborhoodPayload | null; + onOpenEvidence?: (postId: string) => void; + onClose: () => void; +}) { + const source = payload?.nodes.find( + (node) => node.node_type_code === edge.source_node_type_code && node.node_id === edge.source_node_id, + ); + const target = payload?.nodes.find( + (node) => node.node_type_code === edge.target_node_type_code && node.node_id === edge.target_node_id, + ); + return ( + + ); +} + +function downloadFile(name: string, body: string, type: string) { + const blob = new Blob([body], { type }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = name; + document.body.append(link); + try { + link.click(); + } finally { + window.setTimeout(() => { + URL.revokeObjectURL(url); + link.remove(); + }, 0); + } +} diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 2ab5aa3b6..6469a5847 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -29,6 +29,7 @@ describe("i18n", () => { "Refresh", "Close", "Post body", + "Post", "Summary", "Calendar", "Board", @@ -52,6 +53,9 @@ describe("i18n", () => { "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.", "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.", "Showing the first {shown} of {total} posts known at this cutoff.", + "Inspect ontology neighborhood", + "Ontology neighborhood", + "This is an ontology neighborhood, not Event Lineage.", "Rankings", "Title overlap", "RankWeave fused newest-first and title-overlap ranks. This is not a calibrated score.", @@ -84,6 +88,16 @@ describe("i18n", () => { expect(document.documentElement.lang).toBe(locale); }); + it.each([ + ["ko", "글"], + ["zh", "文章"], + ["ja", "投稿"], + ["vi", "Bài viết"], + ] as const)("translates the ontology Post node label in %s", (locale, expected) => { + setLocale(locale); + expect(t("Post")).toBe(expected); + }); + it.each([ ["ko", "DEMO은(는) 이벤트 계보의 현재 항목입니다. 다음으로 Keyman과 평가를 읽으세요."], ["zh", "DEMO 是事件谱系中的当前记录。接下来查看关键联系人和评估。"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index f4d93d9ed..109420724 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -374,6 +374,65 @@ const TRANSLATIONS: Partial>> = { "관측된 원본 작성자 {total}명 중 게시물 수 기준 상위 {shown}명을 표시합니다.", "Interactive questions are unavailable right now; saved evidence remains available.": "대화형 질문을 지금 사용할 수 없습니다. 저장된 근거는 계속 확인할 수 있습니다.", + "Inspect ontology neighborhood": "온톨로지 이웃 검사", + "Ontology neighborhood": "온톨로지 이웃", + Post: "글", + "Typed relations, not Event Lineage": "유형 관계이며 이벤트 계보가 아닙니다", + "This is an ontology neighborhood, not Event Lineage.": + "이벤트 계보가 아니라 온톨로지 이웃입니다.", + "Event Lineage shows reconstructed post-to-post parents. This graph shows typed people, organizations, teams, and posts.": + "이벤트 계보는 복원된 글-글 부모 링크입니다. 이 그래프는 유형이 있는 사람, 조직, 팀, 글을 보여 줍니다.", + "Loading ontology neighborhood...": "온톨로지 이웃을 불러오는 중...", + "No visible ontology relations for this focus. Open a Keyman or affiliated organization next.": + "이 초점에 표시할 온톨로지 관계가 없습니다. 다음으로 Keyman이나 소속 조직을 여세요.", + "Neighborhood truncated. Page visible relations, then inspect one edge.": + "이웃이 잘렸습니다. 표시된 관계를 넘긴 다음 간선을 검사하세요.", + "Access denied for this ontology neighborhood. Open a visible post next.": + "이 온톨로지 이웃을 볼 권한이 없습니다. 다음으로 볼 수 있는 글을 여세요.", + "This neighborhood is bound to a knowledge cutoff. Compare with live evidence next.": + "이 이웃은 지식 기준 시각에 묶여 있습니다. 다음으로 현재 근거와 비교하세요.", + "Rejected proposal. Open the evidence and do not treat it as authoritative.": + "거절된 제안입니다. 근거를 열고 권위 있는 사실로 다루지 마세요.", + "Ontology neighborhood is unavailable. Open a visible post next.": + "온톨로지 이웃을 사용할 수 없습니다. 다음으로 볼 수 있는 글을 여세요.", + "Search within this neighborhood": "이 이웃에서 검색", + "Reset focus": "초점 재설정", + "Export CSV": "CSV 내보내기", + "Export JSON-LD": "JSON-LD 내보내기", + "Print this neighborhood": "이 이웃 인쇄", + Legend: "범례", + "Node types use shape plus text, never color alone. Truth status is labeled on every edge.": + "노드 유형은 색만으로 구분하지 않고 모양과 텍스트를 함께 씁니다. 모든 간선에 진리 상태가 표시됩니다.", + rectangle: "사각형", + ellipse: "타원", + hexagon: "육각형", + "rounded rectangle": "둥근 사각형", + Authoritative: "권위", + Observed: "관측", + Inferred: "추론", + Proposed: "제안", + Superseded: "대체됨", + Rejected: "거절", + "Hidden evidence was removed. No omitted count is shown.": + "숨긴 근거는 제거되었습니다. 생략 개수는 표시하지 않습니다.", + "Select node: {label}": "노드 선택: {label}", + "Select edge: {property} from {source} to {target}": + "간선 선택: {source}에서 {target}(으)로 {property}", + "Exact values": "정확한 값", + Source: "출발", + Property: "속성", + Target: "도착", + "Truth status": "진리 상태", + "Recorded at": "기록 시각", + "Node evidence": "노드 근거", + "Focus this node next": "다음으로 이 노드에 초점", + "Open evidence post": "근거 글 열기", + "Close ontology details": "온톨로지 상세 닫기", + "Edge provenance": "간선 출처", + "Property IRI": "속성 IRI", + Provenance: "출처", + "Valid from": "유효 시작", + "Valid to": "유효 종료", "leftover axis {axis} {share}%": "잔차 축 {axis} {share}%", "Leftover-map axis share": "잔차 지도 축 비율", "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.": @@ -757,6 +816,64 @@ const TRANSLATIONS: Partial>> = { "显示按文章数排序的 {total} 位已观察来源作者中的前 {shown} 位。", "Interactive questions are unavailable right now; saved evidence remains available.": "交互式提问暂不可用;已保存的证据仍可查看。", + "Inspect ontology neighborhood": "检查本体邻域", + "Ontology neighborhood": "本体邻域", + Post: "文章", + "Typed relations, not Event Lineage": "这是类型化关系,不是事件谱系", + "This is an ontology neighborhood, not Event Lineage.": "这是本体邻域,不是事件谱系。", + "Event Lineage shows reconstructed post-to-post parents. This graph shows typed people, organizations, teams, and posts.": + "事件谱系显示重建的文章父子链接。此图显示带类型的人员、组织、团队和文章。", + "Loading ontology neighborhood...": "正在加载本体邻域...", + "No visible ontology relations for this focus. Open a Keyman or affiliated organization next.": + "此焦点没有可见的本体关系。接下来打开关键联系人或所属组织。", + "Neighborhood truncated. Page visible relations, then inspect one edge.": + "邻域已截断。翻看可见关系,然后检查一条边。", + "Access denied for this ontology neighborhood. Open a visible post next.": + "无权查看此本体邻域。接下来打开一篇可见文章。", + "This neighborhood is bound to a knowledge cutoff. Compare with live evidence next.": + "此邻域绑定到知识截止时间。接下来与当前证据比较。", + "Rejected proposal. Open the evidence and do not treat it as authoritative.": + "提案已被拒绝。打开证据,不要将其视为权威事实。", + "Ontology neighborhood is unavailable. Open a visible post next.": + "无法使用本体邻域。接下来打开一篇可见文章。", + "Search within this neighborhood": "在此邻域中搜索", + "Reset focus": "重置焦点", + "Export CSV": "导出 CSV", + "Export JSON-LD": "导出 JSON-LD", + "Print this neighborhood": "打印此邻域", + Legend: "图例", + "Node types use shape plus text, never color alone. Truth status is labeled on every edge.": + "节点类型同时使用形状和文字,从不只靠颜色。每条边都标有真值状态。", + rectangle: "矩形", + ellipse: "椭圆", + hexagon: "六边形", + "rounded rectangle": "圆角矩形", + Authoritative: "权威", + Observed: "观测", + Inferred: "推断", + Proposed: "提议", + Superseded: "已取代", + Rejected: "已拒绝", + "Hidden evidence was removed. No omitted count is shown.": + "隐藏证据已移除。不显示省略数量。", + "Select node: {label}": "选择节点:{label}", + "Select edge: {property} from {source} to {target}": + "选择边:从 {source} 到 {target} 的 {property}", + "Exact values": "精确值", + Source: "起点", + Property: "属性", + Target: "终点", + "Truth status": "真值状态", + "Recorded at": "记录时间", + "Node evidence": "节点证据", + "Focus this node next": "接下来聚焦此节点", + "Open evidence post": "打开证据文章", + "Close ontology details": "关闭本体详情", + "Edge provenance": "边来源", + "Property IRI": "属性 IRI", + Provenance: "来源", + "Valid from": "有效起始", + "Valid to": "有效结束", "leftover axis {axis} {share}%": "残差轴 {axis} {share}%", "Leftover-map axis share": "残差图轴占比", "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.": @@ -1140,6 +1257,65 @@ const TRANSLATIONS: Partial>> = { "投稿数順に、観測された{total}名の元投稿者のうち上位{shown}名を表示しています。", "Interactive questions are unavailable right now; saved evidence remains available.": "対話形式の質問は現在利用できません。保存された証拠は確認できます。", + "Inspect ontology neighborhood": "オントロジー近傍を調べる", + "Ontology neighborhood": "オントロジー近傍", + Post: "投稿", + "Typed relations, not Event Lineage": "型付き関係であり、イベント系譜ではありません", + "This is an ontology neighborhood, not Event Lineage.": + "これはイベント系譜ではなく、オントロジー近傍です。", + "Event Lineage shows reconstructed post-to-post parents. This graph shows typed people, organizations, teams, and posts.": + "イベント系譜は復元された投稿間の親子リンクです。このグラフは型付きの人・組織・チーム・投稿を示します。", + "Loading ontology neighborhood...": "オントロジー近傍を読み込んでいます...", + "No visible ontology relations for this focus. Open a Keyman or affiliated organization next.": + "この焦点に表示できるオントロジー関係はありません。次にキーパーソンまたは所属組織を開いてください。", + "Neighborhood truncated. Page visible relations, then inspect one edge.": + "近傍が切り詰められました。表示中の関係をページ送りし、辺を調べてください。", + "Access denied for this ontology neighborhood. Open a visible post next.": + "このオントロジー近傍を見る権限がありません。次に表示可能な投稿を開いてください。", + "This neighborhood is bound to a knowledge cutoff. Compare with live evidence next.": + "この近傍は知識カットオフに束縛されています。次に現行の証拠と比較してください。", + "Rejected proposal. Open the evidence and do not treat it as authoritative.": + "却下された提案です。証拠を開き、権威ある事実として扱わないでください。", + "Ontology neighborhood is unavailable. Open a visible post next.": + "オントロジー近傍を利用できません。次に表示可能な投稿を開いてください。", + "Search within this neighborhood": "この近傍内を検索", + "Reset focus": "焦点をリセット", + "Export CSV": "CSV を書き出す", + "Export JSON-LD": "JSON-LD を書き出す", + "Print this neighborhood": "この近傍を印刷", + Legend: "凡例", + "Node types use shape plus text, never color alone. Truth status is labeled on every edge.": + "ノード種別は色だけでなく形と文字で示します。すべての辺に真偽状態が付きます。", + rectangle: "長方形", + ellipse: "楕円", + hexagon: "六角形", + "rounded rectangle": "角丸長方形", + Authoritative: "権威", + Observed: "観測", + Inferred: "推論", + Proposed: "提案", + Superseded: "置き換え済み", + Rejected: "却下", + "Hidden evidence was removed. No omitted count is shown.": + "非表示の証拠は除かれました。省略件数は示しません。", + "Select node: {label}": "ノードを選択: {label}", + "Select edge: {property} from {source} to {target}": + "辺を選択: {source} から {target} への {property}", + "Exact values": "正確な値", + Source: "始点", + Property: "プロパティ", + Target: "終点", + "Truth status": "真偽状態", + "Recorded at": "記録時刻", + "Node evidence": "ノードの証拠", + "Focus this node next": "次にこのノードへ焦点", + "Open evidence post": "証拠の投稿を開く", + "Close ontology details": "オントロジー詳細を閉じる", + "Edge provenance": "辺の来歴", + "Property IRI": "プロパティ IRI", + Provenance: "来歴", + "Valid from": "有効開始", + "Valid to": "有効終了", "leftover axis {axis} {share}%": "残差軸 {axis} {share}%", "Leftover-map axis share": "残差マップ軸の比率", "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.": @@ -1523,6 +1699,65 @@ const TRANSLATIONS: Partial>> = { "Đang hiển thị {shown} tác giả nguồn hàng đầu trong số {total} tác giả đã quan sát, xếp theo số bài viết.", "Interactive questions are unavailable right now; saved evidence remains available.": "Câu hỏi tương tác hiện không khả dụng; bằng chứng đã lưu vẫn có thể xem.", + "Inspect ontology neighborhood": "Kiểm tra lân cận bản thể", + "Ontology neighborhood": "Lân cận bản thể", + Post: "Bài viết", + "Typed relations, not Event Lineage": "Quan hệ có kiểu, không phải Dòng sự kiện", + "This is an ontology neighborhood, not Event Lineage.": + "Đây là lân cận bản thể, không phải Dòng sự kiện.", + "Event Lineage shows reconstructed post-to-post parents. This graph shows typed people, organizations, teams, and posts.": + "Dòng sự kiện hiện liên kết cha-con đã tái tạo giữa các bài viết. Đồ thị này hiện người, tổ chức, nhóm và bài viết có kiểu.", + "Loading ontology neighborhood...": "Đang tải lân cận bản thể...", + "No visible ontology relations for this focus. Open a Keyman or affiliated organization next.": + "Không có quan hệ bản thể hiển thị cho tiêu điểm này. Hãy mở Keyman hoặc tổ chức liên kết tiếp theo.", + "Neighborhood truncated. Page visible relations, then inspect one edge.": + "Lân cận bị cắt. Lật các quan hệ hiển thị rồi kiểm tra một cạnh.", + "Access denied for this ontology neighborhood. Open a visible post next.": + "Không có quyền xem lân cận bản thể này. Hãy mở một bài viết hiển thị tiếp theo.", + "This neighborhood is bound to a knowledge cutoff. Compare with live evidence next.": + "Lân cận này bị ràng bởi mốc kiến thức. Hãy so với bằng chứng hiện tại tiếp theo.", + "Rejected proposal. Open the evidence and do not treat it as authoritative.": + "Đề xuất đã bị từ chối. Mở bằng chứng và đừng coi đó là sự thật có thẩm quyền.", + "Ontology neighborhood is unavailable. Open a visible post next.": + "Không dùng được lân cận bản thể. Hãy mở một bài viết hiển thị tiếp theo.", + "Search within this neighborhood": "Tìm trong lân cận này", + "Reset focus": "Đặt lại tiêu điểm", + "Export CSV": "Xuất CSV", + "Export JSON-LD": "Xuất JSON-LD", + "Print this neighborhood": "In lân cận này", + Legend: "Chú giải", + "Node types use shape plus text, never color alone. Truth status is labeled on every edge.": + "Kiểu nút dùng hình dạng kèm chữ, không chỉ màu. Mọi cạnh đều có nhãn trạng thái sự thật.", + rectangle: "hình chữ nhật", + ellipse: "hình elip", + hexagon: "hình lục giác", + "rounded rectangle": "hình chữ nhật bo góc", + Authoritative: "Có thẩm quyền", + Observed: "Quan sát", + Inferred: "Suy luận", + Proposed: "Đề xuất", + Superseded: "Đã thay thế", + Rejected: "Từ chối", + "Hidden evidence was removed. No omitted count is shown.": + "Bằng chứng ẩn đã bị gỡ. Không hiện số lượng bị bỏ.", + "Select node: {label}": "Chọn nút: {label}", + "Select edge: {property} from {source} to {target}": + "Chọn cạnh: {property} từ {source} đến {target}", + "Exact values": "Giá trị chính xác", + Source: "Nguồn", + Property: "Thuộc tính", + Target: "Đích", + "Truth status": "Trạng thái sự thật", + "Recorded at": "Ghi lúc", + "Node evidence": "Bằng chứng nút", + "Focus this node next": "Tiêu điểm nút này tiếp theo", + "Open evidence post": "Mở bài viết bằng chứng", + "Close ontology details": "Đóng chi tiết bản thể", + "Edge provenance": "Nguồn gốc cạnh", + "Property IRI": "IRI thuộc tính", + Provenance: "Nguồn gốc", + "Valid from": "Hiệu lực từ", + "Valid to": "Hiệu lực đến", "leftover axis {axis} {share}%": "trục phần dư {axis} {share}%", "Leftover-map axis share": "Tỷ trọng trục bản đồ phần dư", "Leftover-map axis share is Gabriel inertia of residual SVD axes 1 and 2. Open a leftover pair to read the post–criterion cell. The shares do not invent a leftover score.": diff --git a/frontend/src/ontologyExplorerCss.test.ts b/frontend/src/ontologyExplorerCss.test.ts new file mode 100644 index 000000000..ad35b8868 --- /dev/null +++ b/frontend/src/ontologyExplorerCss.test.ts @@ -0,0 +1,14 @@ +/// +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const here = dirname(fileURLToPath(import.meta.url)); +const appCss = readFileSync(join(here, "App.css"), "utf-8"); + +describe("Ontology Explorer CSS contracts", () => { + it("defines the ontology node focus-visible rule exactly once", () => { + expect(appCss.match(/\.ontology-node:focus-visible\s*\{/g)).toHaveLength(1); + }); +}); diff --git a/frontend/src/ontologyExplorerI18n.test.ts b/frontend/src/ontologyExplorerI18n.test.ts new file mode 100644 index 000000000..c2462ec9e --- /dev/null +++ b/frontend/src/ontologyExplorerI18n.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { setLocale } from "./i18n"; +import { ontologyExplorerText } from "./ontologyExplorerI18n"; + +const KEYS = [ + "Load next relation page", + "Neighborhood truncated. Load the next relation page or inspect one edge.", + "Neighborhood reached the authorized query bound. Narrow the property filter or reduce traversal depth.", + "No direct evidence post is attached. Review the provenance reference above.", +] as const; + +afterEach(() => { + setLocale("en"); +}); + +describe("ontologyExplorerText", () => { + it.each(["ko", "zh", "ja", "vi"] as const)( + "translates paging and evidence guidance in %s", + (locale) => { + setLocale(locale); + for (const key of KEYS) { + expect(ontologyExplorerText(key)).not.toBe(key); + } + }, + ); +}); diff --git a/frontend/src/ontologyExplorerI18n.ts b/frontend/src/ontologyExplorerI18n.ts new file mode 100644 index 000000000..18892fedc --- /dev/null +++ b/frontend/src/ontologyExplorerI18n.ts @@ -0,0 +1,56 @@ +import { getLocale, type Locale } from "./i18n"; + +const ONTOLOGY_EXPLORER_COPY = { + en: { + "Load next relation page": "Load next relation page", + "Neighborhood truncated. Load the next relation page or inspect one edge.": + "Neighborhood truncated. Load the next relation page or inspect one edge.", + "Neighborhood reached the authorized query bound. Narrow the property filter or reduce traversal depth.": + "Neighborhood reached the authorized query bound. Narrow the property filter or reduce traversal depth.", + "No direct evidence post is attached. Review the provenance reference above.": + "No direct evidence post is attached. Review the provenance reference above.", + }, + ko: { + "Load next relation page": "다음 관계 페이지 불러오기", + "Neighborhood truncated. Load the next relation page or inspect one edge.": + "이웃 그래프가 제한되었습니다. 다음 관계 페이지를 불러오거나 연결 하나를 검토하세요.", + "Neighborhood reached the authorized query bound. Narrow the property filter or reduce traversal depth.": + "권한 범위의 조회 한도에 도달했습니다. 관계 속성 필터를 좁히거나 탐색 깊이를 줄이세요.", + "No direct evidence post is attached. Review the provenance reference above.": + "직접 연결된 근거 게시물이 없습니다. 위의 출처 참조를 검토하세요.", + }, + zh: { + "Load next relation page": "加载下一页关系", + "Neighborhood truncated. Load the next relation page or inspect one edge.": + "邻域图已截断。请加载下一页关系或检查一条边。", + "Neighborhood reached the authorized query bound. Narrow the property filter or reduce traversal depth.": + "已达到授权查询上限。请缩小属性筛选范围或降低遍历深度。", + "No direct evidence post is attached. Review the provenance reference above.": + "未附加直接证据帖子。请检查上方的来源引用。", + }, + ja: { + "Load next relation page": "次の関係ページを読み込む", + "Neighborhood truncated. Load the next relation page or inspect one edge.": + "近傍グラフは制限されています。次の関係ページを読み込むか、1本のエッジを確認してください。", + "Neighborhood reached the authorized query bound. Narrow the property filter or reduce traversal depth.": + "認可されたクエリ上限に達しました。プロパティの絞り込みを強めるか、探索深度を下げてください。", + "No direct evidence post is attached. Review the provenance reference above.": + "直接の根拠投稿は添付されていません。上の出典参照を確認してください。", + }, + vi: { + "Load next relation page": "Tải trang quan hệ tiếp theo", + "Neighborhood truncated. Load the next relation page or inspect one edge.": + "Vùng lân cận đã bị giới hạn. Hãy tải trang quan hệ tiếp theo hoặc kiểm tra một cạnh.", + "Neighborhood reached the authorized query bound. Narrow the property filter or reduce traversal depth.": + "Đã đạt giới hạn truy vấn được cấp quyền. Hãy thu hẹp bộ lọc thuộc tính hoặc giảm độ sâu duyệt.", + "No direct evidence post is attached. Review the provenance reference above.": + "Không có bài đăng bằng chứng trực tiếp được đính kèm. Hãy xem tham chiếu nguồn gốc ở trên.", + }, +} as const satisfies Record>; + +export type OntologyExplorerCopyKey = keyof (typeof ONTOLOGY_EXPLORER_COPY)["en"]; + +/** Return ontology-explorer stabilization copy in the active product locale. */ +export function ontologyExplorerText(key: OntologyExplorerCopyKey): string { + return ONTOLOGY_EXPLORER_COPY[getLocale()][key]; +} diff --git a/frontend/src/ontologyLayout.test.ts b/frontend/src/ontologyLayout.test.ts new file mode 100644 index 000000000..7517456ab --- /dev/null +++ b/frontend/src/ontologyLayout.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from "vitest"; +import type { OntologyNeighborhoodPayload } from "./api"; +import { accumulateNeighborhoodPages, layoutOntologyNeighborhood, neighborhoodCsv } from "./ontologyLayout"; + +const POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"; +const PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1"; +const CORP_ID = "cccccccc-cccc-cccc-cccc-ccccccccccc1"; + +function payload(): OntologyNeighborhoodPayload { + return { + focus_node_id: POST_ID, + focus_node_type_code: "node_post", + truncated: false, + next_cursor: null, + limitation_code: null, + nodes: [ + { + node_id: POST_ID, + node_type_code: "node_post", + ontology_class_iri: "https://example.test/Post", + display_label: "Demo public post", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 1, + shape_code: "rectangle", + }, + { + node_id: PERSON_ID, + node_type_code: "node_person", + ontology_class_iri: "https://example.test/Person", + display_label: "Test Person", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 1, + shape_code: "ellipse", + }, + { + node_id: CORP_ID, + node_type_code: "node_corporate_entity", + ontology_class_iri: "https://example.test/CorporateEntity", + display_label: "Demo Corp", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + evidence_count: 1, + shape_code: "hexagon", + }, + ], + edges: [ + { + edge_id: "mentions:post-person", + source_node_type_code: "node_post", + source_node_id: POST_ID, + target_node_type_code: "node_person", + target_node_id: PERSON_ID, + property_code: "mentions", + ontology_property_iri: "https://example.test/mentions", + property_label: "mentions", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + provenance_reference: "knowledge_graph_edge", + evidence_references: [POST_ID], + }, + { + edge_id: "affiliated:person-corp", + source_node_type_code: "node_person", + source_node_id: PERSON_ID, + target_node_type_code: "node_corporate_entity", + target_node_id: CORP_ID, + property_code: "affiliatedWith", + ontology_property_iri: "https://example.test/affiliatedWith", + property_label: "affiliated with", + truth_status_code: "truth_observed", + valid_from: null, + valid_to: null, + recorded_at: "2026-01-10T12:00:00+00:00", + provenance_reference: "knowledge_graph_edge", + evidence_references: [POST_ID], + }, + ], + exact_value_rows: [ + { + edge_id: "mentions:post-person", + source_node_id: POST_ID, + source_label: "Demo public post", + source_type_code: "node_post", + property_code: "mentions", + property_label: "mentions", + ontology_property_iri: "https://example.test/mentions", + target_node_id: PERSON_ID, + target_label: "Test Person", + target_type_code: "node_person", + truth_status_code: "truth_observed", + recorded_at: "2026-01-10T12:00:00+00:00", + valid_from: "", + valid_to: "", + evidence_count: "1", + }, + ], + jsonld: { "@graph": [] }, + }; +} + +describe("ontologyLayout", () => { + it("is deterministic for a fixed payload", () => { + const first = layoutOntologyNeighborhood(payload()); + const second = layoutOntologyNeighborhood(payload()); + expect(first).toEqual(second); + expect(first.nodes[0].node_id).toBe(POST_ID); + expect(new Set(first.nodes.map((node) => `${node.x},${node.y}`)).size).toBe(first.nodes.length); + }); + + it("keeps node identity typed when identifiers collide across catalogs", () => { + const source = payload(); + const collided: OntologyNeighborhoodPayload = { + ...source, + nodes: source.nodes.map((node) => + node.node_type_code === "node_person" ? { ...node, node_id: POST_ID } : node, + ), + edges: source.edges.map((edge) => + edge.source_node_type_code === "node_person" || edge.target_node_type_code === "node_person" + ? { + ...edge, + source_node_id: edge.source_node_type_code === "node_person" ? POST_ID : edge.source_node_id, + target_node_id: edge.target_node_type_code === "node_person" ? POST_ID : edge.target_node_id, + } + : edge, + ), + }; + const layout = layoutOntologyNeighborhood(collided); + expect(layout.nodes).toHaveLength(3); + expect(layout.edges).toHaveLength(2); + }); + + it("exports CSV without leaking omitted counts", () => { + const csv = neighborhoodCsv(payload()); + expect(csv).toContain("Demo public post"); + expect(csv.toLowerCase()).not.toContain("omitted"); + expect(neighborhoodCsv({ ...payload(), exact_value_rows: [] })).toMatch(/^edge_id,/); + const quoted = neighborhoodCsv({ + ...payload(), + exact_value_rows: [ + { + ...payload().exact_value_rows[0], + source_label: 'Demo, "quoted" post', + }, + ], + }); + expect(quoted).toContain('"Demo, ""quoted"" post"'); + const formula = neighborhoodCsv({ + ...payload(), + exact_value_rows: [ + { + ...payload().exact_value_rows[0], + source_label: "=1+1", + }, + ], + }); + expect(formula).toContain("'=1+1"); + }); + + it("uses code-unit ordering instead of the runtime locale", () => { + const unordered = payload(); + const laidOut = layoutOntologyNeighborhood({ + ...unordered, + edges: [], + exact_value_rows: [], + nodes: [ + { ...unordered.nodes[0], display_label: "Focus" }, + { ...unordered.nodes[1], display_label: "ä" }, + { ...unordered.nodes[2], display_label: "z" }, + ], + }); + const byId = new Map(laidOut.nodes.map((node) => [node.node_id, node])); + expect(byId.get(CORP_ID)!.y).toBeLessThan(byId.get(PERSON_ID)!.y); + }); + + it("accumulates later pages without dropping earlier edges", () => { + const first = payload(); + const secondEdge = first.edges[1]; + const second: OntologyNeighborhoodPayload = { + ...first, + truncated: false, + next_cursor: null, + nodes: [first.nodes[2]], + edges: [secondEdge], + exact_value_rows: [ + { + ...first.exact_value_rows[0], + edge_id: secondEdge.edge_id, + source_node_id: PERSON_ID, + source_label: "Test Person", + target_node_id: CORP_ID, + target_label: "Demo Corp", + }, + ], + jsonld: { + "@graph": [{ "@id": `lw:edge/${secondEdge.edge_id}` }], + }, + }; + const merged = accumulateNeighborhoodPages(first, second); + expect(merged.edges.map((edge) => edge.edge_id)).toEqual([ + first.edges[0].edge_id, + first.edges[1].edge_id, + ]); + expect(merged.nodes).toHaveLength(3); + expect(merged.next_cursor).toBeNull(); + }); +}); diff --git a/frontend/src/ontologyLayout.ts b/frontend/src/ontologyLayout.ts new file mode 100644 index 000000000..11f965357 --- /dev/null +++ b/frontend/src/ontologyLayout.ts @@ -0,0 +1,254 @@ +import type { OntologyGraphEdgePayload, OntologyGraphNodePayload, OntologyNeighborhoodPayload } from "./api"; + +export type LaidOutOntologyNode = OntologyGraphNodePayload & { + x: number; + y: number; + depth: number; +}; + +export type LaidOutOntologyEdge = OntologyGraphEdgePayload & { + fromX: number; + fromY: number; + toX: number; + toY: number; +}; + +export type OntologyLayout = { + width: number; + height: number; + nodes: LaidOutOntologyNode[]; + edges: LaidOutOntologyEdge[]; +}; + +const COLUMN_GAP = 220; +const ROW_GAP = 88; +const LEFT = 72; +const TOP = 48; + +function nodeKey(nodeTypeCode: string, nodeId: string): string { + return `${nodeTypeCode}:${nodeId}`; +} + +/** + * Deterministic left-to-right neighborhood layout from the focus node. + * + * Next action: render the coordinates, then select a node or edge to + * inspect its authorized evidence. + */ +export function layoutOntologyNeighborhood(payload: OntologyNeighborhoodPayload): OntologyLayout { + const byKey = new Map(payload.nodes.map((node) => [nodeKey(node.node_type_code, node.node_id), node])); + const adjacency = new Map(); + for (const node of payload.nodes) { + adjacency.set(nodeKey(node.node_type_code, node.node_id), []); + } + for (const edge of payload.edges) { + const sourceKey = nodeKey(edge.source_node_type_code, edge.source_node_id); + const targetKey = nodeKey(edge.target_node_type_code, edge.target_node_id); + if (!byKey.has(sourceKey) || !byKey.has(targetKey)) { + continue; + } + adjacency.get(sourceKey)?.push(targetKey); + adjacency.get(targetKey)?.push(sourceKey); + } + for (const [nodeId, neighbors] of adjacency) { + adjacency.set(nodeId, [...new Set(neighbors)].sort()); + } + + const depth = new Map(); + const focusKey = nodeKey(payload.focus_node_type_code, payload.focus_node_id); + const queue = [focusKey]; + depth.set(focusKey, 0); + while (queue.length > 0) { + const current = queue.shift(); + if (!current) break; + const currentDepth = depth.get(current) ?? 0; + for (const neighbor of adjacency.get(current) ?? []) { + if (!depth.has(neighbor)) { + depth.set(neighbor, currentDepth + 1); + queue.push(neighbor); + } + } + } + + const columns = new Map(); + for (const node of payload.nodes) { + const column = depth.get(nodeKey(node.node_type_code, node.node_id)) ?? 0; + const bucket = columns.get(column) ?? []; + bucket.push(nodeKey(node.node_type_code, node.node_id)); + columns.set(column, bucket); + } + for (const [column, ids] of columns) { + columns.set( + column, + [...ids].sort((left, right) => { + const leftLabel = byKey.get(left)?.display_label ?? left; + const rightLabel = byKey.get(right)?.display_label ?? right; + return compareCodeUnits(leftLabel, rightLabel) || compareCodeUnits(left, right); + }), + ); + } + + const positioned = new Map(); + let maxColumn = 0; + let maxRow = 1; + for (const [column, ids] of [...columns.entries()].sort((left, right) => left[0] - right[0])) { + maxColumn = Math.max(maxColumn, column); + maxRow = Math.max(maxRow, ids.length); + ids.forEach((nodeId, index) => { + const node = byKey.get(nodeId); + if (!node) return; + positioned.set(nodeId, { + ...node, + depth: column, + x: LEFT + column * COLUMN_GAP, + y: TOP + index * ROW_GAP, + }); + }); + } + + const nodes = payload.nodes + .map((node) => positioned.get(nodeKey(node.node_type_code, node.node_id))) + .filter((node): node is LaidOutOntologyNode => Boolean(node)); + const edges: LaidOutOntologyEdge[] = payload.edges.flatMap((edge) => { + const from = positioned.get(nodeKey(edge.source_node_type_code, edge.source_node_id)); + const to = positioned.get(nodeKey(edge.target_node_type_code, edge.target_node_id)); + if (!from || !to) return []; + return [{ ...edge, fromX: from.x, fromY: from.y, toX: to.x, toY: to.y }]; + }); + + return { + width: LEFT * 2 + Math.max(maxColumn, 1) * COLUMN_GAP, + height: TOP * 2 + Math.max(maxRow, 1) * ROW_GAP, + nodes, + edges, + }; +} + +export function neighborhoodCsv(payload: OntologyNeighborhoodPayload): string { + const header = [ + "edge_id", + "source_label", + "property_label", + "target_label", + "truth_status_code", + "recorded_at", + "ontology_property_iri", + ]; + const lines = [header.join(",")]; + for (const row of payload.exact_value_rows) { + lines.push( + header + .map((key) => csvCell(String(row[key as keyof typeof row] ?? ""))) + .join(","), + ); + } + return `${lines.join("\n")}\n`; +} + +function csvCell(value: string): string { + const safeValue = /^[=+\-@]/.test(value) ? `'${value}` : value; + if (/[",\n]/.test(safeValue)) { + return `"${safeValue.replaceAll('"', '""')}"`; + } + return safeValue; +} + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +/** + * Restrict every exportable projection to the graph visible after search. + * + * Next action: export CSV or JSON-LD to carry the same authorized subset. + */ +export function filterNeighborhood( + payload: OntologyNeighborhoodPayload | null, + query: string, +): OntologyNeighborhoodPayload | null { + if (!payload) return null; + const needle = query.trim().toLowerCase(); + if (!needle) return payload; + const nodeMatch = (node: OntologyGraphNodePayload) => + `${node.display_label} ${node.node_type_code} ${node.truth_status_code}`.toLowerCase().includes(needle); + const edgeMatch = (edge: OntologyGraphEdgePayload) => + `${edge.property_label} ${edge.property_code} ${edge.truth_status_code}`.toLowerCase().includes(needle); + const nodesByKey = new Map(payload.nodes.map((node) => [nodeKey(node.node_type_code, node.node_id), node])); + const edges = payload.edges.filter((edge) => { + const source = nodesByKey.get(nodeKey(edge.source_node_type_code, edge.source_node_id)); + const target = nodesByKey.get(nodeKey(edge.target_node_type_code, edge.target_node_id)); + return edgeMatch(edge) || Boolean(source && nodeMatch(source)) || Boolean(target && nodeMatch(target)); + }); + const keep = new Set([nodeKey(payload.focus_node_type_code, payload.focus_node_id)]); + for (const edge of edges) { + keep.add(nodeKey(edge.source_node_type_code, edge.source_node_id)); + keep.add(nodeKey(edge.target_node_type_code, edge.target_node_id)); + } + for (const node of payload.nodes) { + if (nodeMatch(node)) keep.add(nodeKey(node.node_type_code, node.node_id)); + } + const nodes = payload.nodes.filter((node) => keep.has(nodeKey(node.node_type_code, node.node_id))); + const exact_value_rows = payload.exact_value_rows.filter((row) => + edges.some((edge) => edge.edge_id === row.edge_id), + ); + const visibleIds = new Set([ + ...nodes.map((node) => `lw:node/${node.node_type_code}/${node.node_id}`), + ...edges.map((edge) => `lw:edge/${edge.edge_id}`), + ]); + const graph = payload.jsonld["@graph"]; + const jsonld = Array.isArray(graph) + ? { + ...payload.jsonld, + "@graph": graph.filter( + (item): item is Record => + typeof item === "object" && item !== null && + typeof item["@id"] === "string" && + visibleIds.has(item["@id"]), + ), + } + : payload.jsonld; + return { ...payload, nodes, edges, exact_value_rows, jsonld }; +} + +/** + * Accumulate a later neighborhood page onto the already-visible graph. + * + * Next action: keep the selected evidence, then inspect or page again. + */ +export function accumulateNeighborhoodPages( + current: OntologyNeighborhoodPayload, + next: OntologyNeighborhoodPayload, +): OntologyNeighborhoodPayload { + const nodes = new Map(current.nodes.map((node) => [nodeKey(node.node_type_code, node.node_id), node])); + for (const node of next.nodes) { + nodes.set(nodeKey(node.node_type_code, node.node_id), node); + } + const edges = new Map(current.edges.map((edge) => [edge.edge_id, edge])); + for (const edge of next.edges) { + edges.set(edge.edge_id, edge); + } + const rows = new Map(current.exact_value_rows.map((row) => [row.edge_id, row])); + for (const row of next.exact_value_rows) { + rows.set(row.edge_id, row); + } + const graphItems = new Map>(); + for (const payload of [current, next]) { + const graph = payload.jsonld["@graph"]; + if (!Array.isArray(graph)) continue; + for (const item of graph) { + if (typeof item === "object" && item !== null && typeof item["@id"] === "string") { + graphItems.set(item["@id"], item as Record); + } + } + } + return { + ...next, + nodes: [...nodes.values()], + edges: [...edges.values()], + exact_value_rows: [...rows.values()], + jsonld: { + ...next.jsonld, + "@graph": [...graphItems.values()], + }, + }; +} diff --git a/frontend/src/storybookViewportContract.test.ts b/frontend/src/storybookViewportContract.test.ts new file mode 100644 index 000000000..1a1826cb6 --- /dev/null +++ b/frontend/src/storybookViewportContract.test.ts @@ -0,0 +1,25 @@ +/// +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const here = dirname(fileURLToPath(import.meta.url)); +const packageJson = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8")) as { + devDependencies?: Record; +}; +const storybookPreview = readFileSync(join(here, "..", ".storybook", "preview.ts"), "utf-8"); +const ontologyStories = readFileSync( + join(here, "components", "OntologyExplorer.stories.tsx"), + "utf-8", +); + +describe("Storybook viewport contract", () => { + it("uses Storybook 10's built-in viewport module for the ontology narrow view", () => { + expect(ontologyStories).toContain('value: "mobile1"'); + expect(packageJson.devDependencies?.storybook).toBe("^10.5.8"); + expect(packageJson.devDependencies?.["@storybook/addon-viewport"]).toBeUndefined(); + expect(storybookPreview).toContain('import { MINIMAL_VIEWPORTS } from "storybook/viewport";'); + expect(storybookPreview).toContain("options: MINIMAL_VIEWPORTS"); + }); +}); diff --git a/lineageweave/adjudication_client.py b/lineageweave/adjudication_client.py index f86f2cb73..37e4fee7f 100644 --- a/lineageweave/adjudication_client.py +++ b/lineageweave/adjudication_client.py @@ -15,7 +15,7 @@ import re from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json class AdjudicationClient(Protocol): @@ -76,7 +76,7 @@ def judge(self, candidate_label: str, record_label: str) -> float: headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) match = _CONFIDENCE_PATTERN.search(content) if match is None: return 0.0 diff --git a/lineageweave/commitment_extraction.py b/lineageweave/commitment_extraction.py index db5f7761a..6f9ed3250 100644 --- a/lineageweave/commitment_extraction.py +++ b/lineageweave/commitment_extraction.py @@ -30,7 +30,7 @@ from dataclasses import dataclass from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json @dataclass(frozen=True) @@ -165,8 +165,8 @@ def extract(self, post_title: str, post_body: str, reference_date: str) -> Custo headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) commitment = parse_commitment_response(content) if commitment is None: - raise ValueError(f"commitment response did not match the required format: {content!r}") + raise ValueError("commitment response did not match the required format") return commitment diff --git a/lineageweave/corporate_hierarchy_inference.py b/lineageweave/corporate_hierarchy_inference.py index 120caacea..9eecf23ee 100644 --- a/lineageweave/corporate_hierarchy_inference.py +++ b/lineageweave/corporate_hierarchy_inference.py @@ -34,7 +34,7 @@ from functools import lru_cache from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json LEVEL_GROUP = "group" LEVEL_COMPANY = "company" @@ -171,5 +171,5 @@ def infer(self, organization_name: str, context_text: str) -> HierarchyProposal headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) return parse_inference_response(content) diff --git a/lineageweave/customer_hint_resolution.py b/lineageweave/customer_hint_resolution.py index 3169644dc..a063c1e82 100644 --- a/lineageweave/customer_hint_resolution.py +++ b/lineageweave/customer_hint_resolution.py @@ -19,7 +19,7 @@ from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json from .organization_name_resolution import parse_resolution_response _RESOLUTION_PROMPT_TEMPLATE = """\ @@ -93,5 +93,5 @@ def resolve(self, hint_code: str, context_text: str) -> str | None: headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) return parse_resolution_response(content) diff --git a/lineageweave/entity_relationship_classification.py b/lineageweave/entity_relationship_classification.py index 50093885d..331f4957e 100644 --- a/lineageweave/entity_relationship_classification.py +++ b/lineageweave/entity_relationship_classification.py @@ -27,7 +27,7 @@ from dataclasses import dataclass from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json # post_counterparty_entity.relationship_type_code values (common_lookup_value, # category "entity_relationship_type"). VOC/VOM/VOP/VOCC/VOCO are the @@ -192,5 +192,5 @@ def classify( headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) return parse_classification_response(content, organization_names) diff --git a/lineageweave/http_client.py b/lineageweave/http_client.py index b3e2d2238..e62639ece 100644 --- a/lineageweave/http_client.py +++ b/lineageweave/http_client.py @@ -104,6 +104,31 @@ def _decode_json_list(raw: bytes, hostname: str) -> list: return decoded +def chat_completion_content(body: object) -> str: + """Extract text from a provider chat-completion envelope safely. + + Provider error bodies and malformed success bodies must never be echoed by + a consumer through ``KeyError`` or a repr of the response. The caller + receives only a stable validation error and can translate it at its own + product boundary. + """ + if not isinstance(body, dict): + raise TypeError("provider response was not an object") + choices = body.get("choices") + if not isinstance(choices, list) or not choices: + raise ValueError("provider response did not contain a choice") + first_choice = choices[0] + if not isinstance(first_choice, dict): + raise TypeError("provider response choice was not an object") + message = first_choice.get("message") + if not isinstance(message, dict): + raise TypeError("provider response message was not an object") + content = message.get("content") + if not isinstance(content, str) or not content.strip(): + raise TypeError("provider response did not contain text content") + return content + + def post_json( url: str, payload: dict, diff --git a/lineageweave/image_content.py b/lineageweave/image_content.py index 60ad54a0e..f6500ffd3 100644 --- a/lineageweave/image_content.py +++ b/lineageweave/image_content.py @@ -36,7 +36,7 @@ from PIL import Image -from .http_client import post_json +from .http_client import chat_completion_content, post_json _DATA_URI_IMG = re.compile( r']*\bsrc\s*=\s*["\']data:(image/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)["\']', @@ -261,9 +261,7 @@ def _parse_description(content: str) -> ImageDescription: fields["TEXT"].append(_strip_outer_markdown_emphasis(line)) if not fields["TEXT"] and not fields["CAPTION"]: - raise ImageDescriptionParseError( - f"vision response had neither TEXT nor CAPTION content: {content!r}" - ) + raise ImageDescriptionParseError("vision response had no usable TEXT or CAPTION content") extracted_text = "\n".join(fields["TEXT"]).strip() if extracted_text.upper() == "NONE": @@ -342,7 +340,7 @@ def describe(self, image_bytes: bytes, mime_type: str) -> ImageDescription: headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) return _parse_description(content) def locate_regions(self, image_bytes: bytes, mime_type: str) -> tuple[ImageRegion, ...]: @@ -375,13 +373,11 @@ def locate_regions(self, image_bytes: bytes, mime_type: str) -> tuple[ImageRegio headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] - if not isinstance(content, str): - raise ValueError("vision region response was not text JSON") + content = chat_completion_content(body) fenced = re.sub(r"^\s*```(?:json)?\s*|\s*```\s*$", "", content, flags=re.IGNORECASE) document = json.loads(fenced) if not isinstance(document, dict): - raise ValueError("vision region response had no regions list") + raise TypeError("vision region response had no regions list") regions = document.get("regions") if not isinstance(regions, list): single_region = tuple(document.get(name) for name in ("x", "y", "width", "height")) diff --git a/lineageweave/keyman_extraction.py b/lineageweave/keyman_extraction.py index 717a01783..bf1dba769 100644 --- a/lineageweave/keyman_extraction.py +++ b/lineageweave/keyman_extraction.py @@ -24,7 +24,7 @@ from dataclasses import dataclass, field from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json OUR_SIDE = "our_side" COUNTERPARTY = "counterparty" @@ -142,9 +142,9 @@ def parse_keyman_response(content: str) -> list[PersonMention]: try: parsed = json.loads(_strip_code_fence(content).strip()) except json.JSONDecodeError: - return [] + raise ValueError("Keyman response must be a valid JSON array") from None if not isinstance(parsed, list): - return [] + raise ValueError("Keyman response must be a JSON array") mentions: list[PersonMention] = [] for entry in parsed: @@ -209,5 +209,5 @@ def extract_with_hints( headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) return parse_keyman_response(content) diff --git a/lineageweave/ontology_neighborhood.py b/lineageweave/ontology_neighborhood.py new file mode 100644 index 000000000..1cfdab0ef --- /dev/null +++ b/lineageweave/ontology_neighborhood.py @@ -0,0 +1,767 @@ +"""Bounded ontology/provenance neighborhood (ADR 0168 / issue #341). + +PostgreSQL remains authoritative. This module only assembles a typed, +authorization-already-applied graph for GET /api/ontology/neighborhood. +Event Lineage (reconstructed post-to-post parents) is a different surface +and is never mixed in. + +Grounding: RDF 1.1 Concepts (Cyganiak, Wood, & Lanthaler, 2014); RDF +Schema 1.1 (Brickley & Guha, 2014); OWL 2 (W3C, 2012); SKOS (Miles & +Bechhofer, 2009); PROV-O (Lebo, Sahoo, & McGuinness, 2013); OWL-Time +(Cox & Little, 2022); JSON-LD 1.1 (Kellogg, Champin, & Longley, 2020). +""" + +from __future__ import annotations + +from collections import defaultdict, deque +from dataclasses import dataclass, replace +from datetime import datetime +from typing import Mapping, Sequence + +from lineageweave.knowledge_graph import ( + EDGE_AFFILIATION, + EDGE_CO_MENTION, + EDGE_MENTION, + EDGE_MENTION_ORGANIZATION, + EDGE_MENTION_TEAM, + EDGE_TEAM_AFFILIATION, + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_POST, + NODE_TEAM, +) +from lineageweave.ontology import LW, ontology_annotations + +TRUTH_AUTHORITATIVE = "truth_authoritative" +TRUTH_OBSERVED = "truth_observed" +TRUTH_INFERRED = "truth_inferred" +TRUTH_PROPOSED = "truth_proposed" +TRUTH_SUPERSEDED = "truth_superseded" +TRUTH_REJECTED = "truth_rejected" + +TRUTH_STATUS_CODES = frozenset( + { + TRUTH_AUTHORITATIVE, + TRUTH_OBSERVED, + TRUTH_INFERRED, + TRUTH_PROPOSED, + TRUTH_SUPERSEDED, + TRUTH_REJECTED, + } +) + +PROPERTY_MENTIONS = "mentions" +PROPERTY_AFFILIATED_WITH = "affiliatedWith" +PROPERTY_CO_MENTIONED_WITH = "coMentionedWith" +PROPERTY_MENTIONS_TEAM = "mentionsTeam" +PROPERTY_TEAM_AFFILIATED_WITH = "teamAffiliatedWith" +PROPERTY_MENTIONS_ORGANIZATION = "mentionsOrganization" +PROPERTY_SKOS_BROADER = "skos_broader" +PROPERTY_OWL_SUBCLASS_OF = "owl_subclass_of" + +SKOS_BROADER_IRI = "http://www.w3.org/2004/02/skos/core#broader" +JSONLD_CONTEXT = { + "lw": str(LW), + "skos": "http://www.w3.org/2004/02/skos/core#", + "owl": "http://www.w3.org/2002/07/owl#", + "prov": "http://www.w3.org/ns/prov#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", +} + +KNOWN_NODE_TYPES = frozenset({NODE_POST, NODE_PERSON, NODE_CORPORATE_ENTITY, NODE_TEAM}) +_KG_PROPERTY_BY_EDGE = { + EDGE_MENTION: PROPERTY_MENTIONS, + EDGE_AFFILIATION: PROPERTY_AFFILIATED_WITH, + EDGE_CO_MENTION: PROPERTY_CO_MENTIONED_WITH, + EDGE_MENTION_TEAM: PROPERTY_MENTIONS_TEAM, + EDGE_TEAM_AFFILIATION: PROPERTY_TEAM_AFFILIATED_WITH, + EDGE_MENTION_ORGANIZATION: PROPERTY_MENTIONS_ORGANIZATION, +} +_PROPERTY_IRI = { + PROPERTY_MENTIONS: str(LW.mentions), + PROPERTY_AFFILIATED_WITH: str(LW.affiliatedWith), + PROPERTY_CO_MENTIONED_WITH: str(LW.coMentionedWith), + PROPERTY_MENTIONS_TEAM: str(LW.mentionsTeam), + PROPERTY_TEAM_AFFILIATED_WITH: str(LW.teamAffiliatedWith), + PROPERTY_MENTIONS_ORGANIZATION: str(LW.mentionsOrganization), + PROPERTY_SKOS_BROADER: SKOS_BROADER_IRI, +} +INSTANCE_PROPERTY_CODES = frozenset(_PROPERTY_IRI) +ALLOWED_PROPERTY_ALIASES = { + **{code: code for code in INSTANCE_PROPERTY_CODES}, + EDGE_MENTION: PROPERTY_MENTIONS, + EDGE_AFFILIATION: PROPERTY_AFFILIATED_WITH, + EDGE_CO_MENTION: PROPERTY_CO_MENTIONED_WITH, + EDGE_MENTION_TEAM: PROPERTY_MENTIONS_TEAM, + EDGE_TEAM_AFFILIATION: PROPERTY_TEAM_AFFILIATED_WITH, + EDGE_MENTION_ORGANIZATION: PROPERTY_MENTIONS_ORGANIZATION, +} + +DEFAULT_MAXIMUM_DEPTH = 2 +DEFAULT_MAXIMUM_NODES = 40 +DEFAULT_MAXIMUM_EDGES = 80 +HARD_MAXIMUM_DEPTH = 8 +HARD_MAXIMUM_NODES = 200 +HARD_MAXIMUM_EDGES = 400 + +NODE_SHAPE = { + NODE_POST: "rectangle", + NODE_PERSON: "ellipse", + NODE_CORPORATE_ENTITY: "hexagon", + NODE_TEAM: "rounded-rectangle", +} + + +class OntologyNeighborhoodError(ValueError): + """Fail-closed contract violation for the ontology neighborhood.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +@dataclass(frozen=True) +class NeighborhoodFact: + """One typed instance fact supplied by the application layer. + + Endpoints must already exist. Authorization and source eligibility + are the caller's job; this assembler never invents a node, a + property, or a truth promotion. + """ + + source_node_type_code: str + source_node_id: str + target_node_type_code: str + target_node_id: str + property_code: str + truth_status_code: str + recorded_at: datetime + valid_from: datetime | None = None + valid_to: datetime | None = None + evidence_references: tuple[str, ...] = () + provenance_reference: str | None = None + # SQL source windows already computed this relation's focus distance. It + # is internal paging metadata, not buyer-facing evidence. + source_hop_depth: int | None = None + # The SQL keyset order is retained separately from display orientation. + # It keeps source-window page selection and continuation on one order. + source_order_key: tuple[int, str, str, str, str, str] | None = None + + +@dataclass(frozen=True) +class OntologyGraphNode: + """One heterogeneous ontology node in the bounded neighborhood.""" + + node_id: str + node_type_code: str + ontology_class_iri: str + display_label: str + truth_status_code: str | None + valid_from: datetime | None + valid_to: datetime | None + recorded_at: datetime | None + evidence_count: int + shape_code: str + + +@dataclass(frozen=True) +class OntologyNodeMetadata: + """Catalog-owned metadata for one heterogeneous ontology node. + + ``None`` means the catalog has not supplied that value. Edge metadata is + never used as a substitute because doing so changes a node's meaning when + pagination or filtering changes. + """ + + truth_status_code: str | None = None + recorded_at: datetime | None = None + + +@dataclass(frozen=True) +class OntologyGraphEdge: + """One typed ontology/KG edge in the bounded neighborhood.""" + + edge_id: str + source_node_type_code: str + source_node_id: str + target_node_type_code: str + target_node_id: str + property_code: str + ontology_property_iri: str + property_label: str + truth_status_code: str + valid_from: datetime | None + valid_to: datetime | None + recorded_at: datetime + provenance_reference: str | None + evidence_references: tuple[str, ...] + + +@dataclass(frozen=True) +class OntologyNeighborhood: + """Bounded, deterministic neighborhood payload.""" + + focus_node_id: str + focus_node_type_code: str + nodes: tuple[OntologyGraphNode, ...] + edges: tuple[OntologyGraphEdge, ...] + truncated: bool + next_cursor: str | None + limitation_code: str | None + + def exact_value_rows(self) -> tuple[dict[str, str], ...]: + """Keyboard/print/CSV rows for the same visible graph.""" + rows: list[dict[str, str]] = [] + labels = {(node.node_type_code, node.node_id): node.display_label for node in self.nodes} + for edge in self.edges: + rows.append( + { + "edge_id": edge.edge_id, + "source_node_id": edge.source_node_id, + "source_label": _label_for(labels, edge.source_node_type_code, edge.source_node_id), + "source_type_code": edge.source_node_type_code, + "property_code": edge.property_code, + "property_label": edge.property_label, + "ontology_property_iri": edge.ontology_property_iri, + "target_node_id": edge.target_node_id, + "target_label": _label_for(labels, edge.target_node_type_code, edge.target_node_id), + "target_type_code": edge.target_node_type_code, + "truth_status_code": edge.truth_status_code, + "recorded_at": edge.recorded_at.isoformat(), + "valid_from": edge.valid_from.isoformat() if edge.valid_from else "", + "valid_to": edge.valid_to.isoformat() if edge.valid_to else "", + "evidence_count": str(len(edge.evidence_references)), + } + ) + return tuple(rows) + + def jsonld_document(self) -> dict[str, object]: + """JSON-LD 1.1 projection of the visible neighborhood only.""" + graph: list[dict[str, object]] = [] + for node in self.nodes: + graph.append( + { + "@id": f"lw:node/{node.node_type_code}/{node.node_id}", + "@type": node.ontology_class_iri, + "rdfs:label": node.display_label, + "lw:nodeType": node.node_type_code, + } + ) + if node.truth_status_code is not None: + graph[-1]["lw:truthStatus"] = node.truth_status_code + for edge in self.edges: + item: dict[str, object] = { + "@id": f"lw:edge/{edge.edge_id}", + "@type": "prov:Entity", + edge.ontology_property_iri: { + "@id": f"lw:node/{_node_type_for(self.nodes, edge.target_node_type_code, edge.target_node_id)}/{edge.target_node_id}" + }, + "prov:wasDerivedFrom": [ + {"@id": f"lw:evidence/{reference}"} for reference in edge.evidence_references + ], + "lw:truthStatus": edge.truth_status_code, + "lw:source": { + "@id": f"lw:node/{_node_type_for(self.nodes, edge.source_node_type_code, edge.source_node_id)}/{edge.source_node_id}" + }, + } + graph.append(item) + return {"@context": JSONLD_CONTEXT, "@graph": graph} + + +def _node_type_for( + nodes: Sequence[OntologyGraphNode], node_type_code: str, node_id: str +) -> str: + for node in nodes: + if node.node_type_code == node_type_code and node.node_id == node_id: + return node.node_type_code + raise OntologyNeighborhoodError("dangling_endpoint", "visible edge references a missing node") + + +def _label_for(labels: Mapping[tuple[str, str], str], node_type_code: str, node_id: str) -> str: + """Return a visible endpoint label, or fail closed like JSON-LD.""" + label = labels.get((node_type_code, node_id)) + if label is None: + raise OntologyNeighborhoodError("dangling_endpoint", "visible edge references a missing node") + return label + + +def canonicalize_property_code(property_code: str) -> str: + """Map a lookup/edge alias onto the instance property code, or fail.""" + if property_code == PROPERTY_OWL_SUBCLASS_OF: + raise OntologyNeighborhoodError( + "owl_subclass_not_instance", + "OWL class subsumption is schema, not an instance neighborhood edge", + ) + canonical = ALLOWED_PROPERTY_ALIASES.get(property_code) + if canonical is None: + raise OntologyNeighborhoodError("unknown_property", f"unknown property {property_code!r}") + return canonical + + +def fact_from_knowledge_graph_edge( + *, + source_node_type_code: str, + source_node_id: str, + target_node_type_code: str, + target_node_id: str, + edge_type_code: str, + recorded_at: datetime, + evidence_references: Sequence[str] = (), + provenance_reference: str | None = None, + truth_status_code: str = TRUTH_OBSERVED, +) -> NeighborhoodFact: + """Project one ``knowledge_graph_edge`` onto a display-direction fact. + + ``edge_mention`` is stored Person --mentionedIn--> Post. The buyer + neighborhood uses the declared inverse ``mentions`` so the required + path is Post --mentions--> Person --affiliatedWith--> CorporateEntity. + """ + property_code = _KG_PROPERTY_BY_EDGE.get(edge_type_code) + if property_code is None: + raise OntologyNeighborhoodError("unknown_property", f"unknown edge type {edge_type_code!r}") + src_type, src_id, dst_type, dst_id = ( + source_node_type_code, + source_node_id, + target_node_type_code, + target_node_id, + ) + if edge_type_code == EDGE_MENTION: + src_type, src_id, dst_type, dst_id = ( + target_node_type_code, + target_node_id, + source_node_type_code, + source_node_id, + ) + return NeighborhoodFact( + source_node_type_code=src_type, + source_node_id=src_id, + target_node_type_code=dst_type, + target_node_id=dst_id, + property_code=property_code, + truth_status_code=truth_status_code, + recorded_at=recorded_at, + evidence_references=tuple(evidence_references), + provenance_reference=provenance_reference, + ) + + +def skos_broader_fact( + *, + narrower_entity_id: str, + broader_entity_id: str, + recorded_at: datetime, + provenance_reference: str | None = None, +) -> NeighborhoodFact: + """Catalog parent as SKOS broader, never as OWL subclass.""" + return NeighborhoodFact( + source_node_type_code=NODE_CORPORATE_ENTITY, + source_node_id=narrower_entity_id, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=broader_entity_id, + property_code=PROPERTY_SKOS_BROADER, + truth_status_code=TRUTH_AUTHORITATIVE, + recorded_at=recorded_at, + provenance_reference=provenance_reference, + ) + + +def _node_key(node_type_code: str, node_id: str) -> str: + return f"{node_type_code}:{node_id}" + + +def _edge_id(fact: NeighborhoodFact) -> str: + return ( + f"{fact.property_code}:{fact.source_node_type_code}:{fact.source_node_id}:" + f"{fact.target_node_type_code}:{fact.target_node_id}" + ) + + +def _property_label(property_code: str) -> str: + if property_code == PROPERTY_SKOS_BROADER: + return "broader" + annotations = ontology_annotations( + { + PROPERTY_MENTIONS: EDGE_MENTION, + PROPERTY_AFFILIATED_WITH: EDGE_AFFILIATION, + PROPERTY_CO_MENTIONED_WITH: EDGE_CO_MENTION, + PROPERTY_MENTIONS_TEAM: EDGE_MENTION_TEAM, + PROPERTY_TEAM_AFFILIATED_WITH: EDGE_TEAM_AFFILIATION, + PROPERTY_MENTIONS_ORGANIZATION: EDGE_MENTION_ORGANIZATION, + }[property_code] + ) + return annotations.get("ontology_label", property_code) + + +def assemble_ontology_neighborhood( + *, + focus_node_type_code: str, + focus_node_id: str, + facts: Sequence[NeighborhoodFact], + labels: Mapping[tuple[str, str], str], + node_metadata: Mapping[tuple[str, str], OntologyNodeMetadata] | None = None, + hidden_node_keys: frozenset[str] = frozenset(), + knowledge_cutoff: datetime | None = None, + maximum_depth: int = DEFAULT_MAXIMUM_DEPTH, + maximum_nodes: int = DEFAULT_MAXIMUM_NODES, + maximum_edges: int = DEFAULT_MAXIMUM_EDGES, + allowed_property_codes: Sequence[str] | None = None, + cursor: str | None = None, + source_truncated: bool = False, +) -> OntologyNeighborhood: + """Walk a bounded typed neighborhood from an already-visible focus. + + Hidden endpoints remove the edge. Truncation never reports how many + neighbors were omitted. OWL subclass facts are rejected. Inferred + facts stay inferred. A bounded loader can set ``source_truncated`` so + an exhausted in-memory page never masquerades as the complete graph. + """ + if focus_node_type_code not in KNOWN_NODE_TYPES: + raise OntologyNeighborhoodError("unknown_node_type", f"unknown node type {focus_node_type_code!r}") + if not focus_node_id or focus_node_id.strip() != focus_node_id: + raise OntologyNeighborhoodError("invalid_focus_id", "focus node id is empty or padded") + if maximum_depth < 1 or maximum_depth > HARD_MAXIMUM_DEPTH: + raise OntologyNeighborhoodError("excessive_depth", "neighborhood depth is out of bounds") + if maximum_nodes < 1 or maximum_nodes > HARD_MAXIMUM_NODES: + raise OntologyNeighborhoodError("unbounded_request", "neighborhood node bound is out of range") + if maximum_edges < 1 or maximum_edges > HARD_MAXIMUM_EDGES: + raise OntologyNeighborhoodError("unbounded_request", "neighborhood edge bound is out of range") + if cursor is not None and not cursor.startswith("after:"): + raise OntologyNeighborhoodError("malformed_cursor", "cursor must be an opaque after: token") + + allowed: frozenset[str] | None + if allowed_property_codes is None: + allowed = None + else: + allowed = frozenset(canonicalize_property_code(code) for code in allowed_property_codes) + + focus_key = _node_key(focus_node_type_code, focus_node_id) + if focus_key in hidden_node_keys: + raise OntologyNeighborhoodError("focus_hidden", "focus node is not visible") + if (focus_node_type_code, focus_node_id) not in labels: + raise OntologyNeighborhoodError("dangling_endpoint", "focus node has no authorized label") + + visible_facts: list[NeighborhoodFact] = [] + for fact in facts: + _validate_fact(fact) + property_code = canonicalize_property_code(fact.property_code) + if allowed is not None and property_code not in allowed: + continue + if knowledge_cutoff is not None and fact.recorded_at > knowledge_cutoff: + continue + if fact.valid_from is not None and knowledge_cutoff is not None and fact.valid_from > knowledge_cutoff: + continue + if fact.valid_to is not None and knowledge_cutoff is not None and fact.valid_to < knowledge_cutoff: + continue + source_key = _node_key(fact.source_node_type_code, fact.source_node_id) + target_key = _node_key(fact.target_node_type_code, fact.target_node_id) + if source_key in hidden_node_keys or target_key in hidden_node_keys: + continue + if (fact.source_node_type_code, fact.source_node_id) not in labels: + continue + if (fact.target_node_type_code, fact.target_node_id) not in labels: + continue + visible_facts.append( + NeighborhoodFact( + source_node_type_code=fact.source_node_type_code, + source_node_id=fact.source_node_id, + target_node_type_code=fact.target_node_type_code, + target_node_id=fact.target_node_id, + property_code=property_code, + truth_status_code=fact.truth_status_code, + recorded_at=fact.recorded_at, + valid_from=fact.valid_from, + valid_to=fact.valid_to, + evidence_references=fact.evidence_references, + provenance_reference=fact.provenance_reference, + source_hop_depth=fact.source_hop_depth, + source_order_key=fact.source_order_key, + ) + ) + + adjacency: dict[str, list[NeighborhoodFact]] = defaultdict(list) + for fact in visible_facts: + adjacency[_node_key(fact.source_node_type_code, fact.source_node_id)].append(fact) + adjacency[_node_key(fact.target_node_type_code, fact.target_node_id)].append(fact) + + reached: dict[str, int] = {focus_key: 0} + collected: list[NeighborhoodFact] = [] + seen_edges: set[str] = set() + source_window_facts = [ + fact + for fact in visible_facts + if fact.source_hop_depth is not None or fact.source_order_key is not None + ] + if source_window_facts: + # A source-cursor page may begin after the focus edge. PostgreSQL has + # already performed the authorized recursive BFS, so replay its + # bounded hop metadata instead of requiring the page to contain the + # earlier bridge facts. + deduplicated: dict[str, NeighborhoodFact] = {} + def source_page_sort_key( + fact: NeighborhoodFact, + ) -> tuple[int, tuple[int, str, str, str, str, str]]: + """Order source-window facts by their opaque cursor key, falling + back to a deterministic hop/property/node tuple for facts the + cursor page did not carry a position for.""" + if fact.source_order_key is not None: + return (0, fact.source_order_key) + return ( + 1, + ( + fact.source_hop_depth + if fact.source_hop_depth is not None + else maximum_depth, + fact.property_code, + fact.source_node_type_code, + fact.source_node_id, + fact.target_node_type_code, + fact.target_node_id, + ), + ) + + for fact in sorted(visible_facts, key=source_page_sort_key): + edge_id = _edge_id(fact) + existing = deduplicated.get(edge_id) + if existing is None: + deduplicated[edge_id] = fact + continue + hop_depths = [ + depth + for depth in (existing.source_hop_depth, fact.source_hop_depth) + if depth is not None + ] + deduplicated[edge_id] = replace( + existing, + recorded_at=min(existing.recorded_at, fact.recorded_at), + evidence_references=tuple( + sorted(set(existing.evidence_references) | set(fact.evidence_references)) + ), + source_hop_depth=min(hop_depths) if hop_depths else None, + source_order_key=min( + key + for key in (existing.source_order_key, fact.source_order_key) + if key is not None + ) + if existing.source_order_key is not None or fact.source_order_key is not None + else None, + ) + collected = list(deduplicated.values()) + for fact in collected: + seen_edges.add(_edge_id(fact)) + depth = fact.source_hop_depth if fact.source_hop_depth is not None else maximum_depth + for endpoint in ( + _node_key(fact.source_node_type_code, fact.source_node_id), + _node_key(fact.target_node_type_code, fact.target_node_id), + ): + reached.setdefault(endpoint, depth + 1) + else: + queue: deque[str] = deque([focus_key]) + while queue: + current = queue.popleft() + depth = reached[current] + if depth >= maximum_depth: + continue + for fact in sorted(adjacency.get(current, ()), key=_fact_sort_key): + edge_id = _edge_id(fact) + if edge_id in seen_edges: + continue + seen_edges.add(edge_id) + collected.append(fact) + for endpoint in ( + _node_key(fact.source_node_type_code, fact.source_node_id), + _node_key(fact.target_node_type_code, fact.target_node_id), + ): + if endpoint not in reached: + reached[endpoint] = depth + 1 + queue.append(endpoint) + + start = 0 + if cursor is not None: + token = cursor.removeprefix("after:") + matched = next( + (index for index, fact in enumerate(collected) if _edge_id(fact) == token), + None, + ) + if matched is None: + raise OntologyNeighborhoodError("malformed_cursor", "cursor does not name a visible edge") + start = matched + 1 + + page_edges = collected[start : start + maximum_edges] + truncated = source_truncated or (start + len(page_edges)) < len(collected) or len(reached) > maximum_nodes + next_cursor = None + if (start + len(page_edges)) < len(collected) and page_edges: + next_cursor = f"after:{_edge_id(page_edges[-1])}" + + catalog_metadata = node_metadata or {} + node_evidence: dict[str, set[str]] = defaultdict(set) + node_meta: dict[str, tuple[str, str, str | None, datetime | None]] = {} + focus_label = labels[(focus_node_type_code, focus_node_id)] + focus_metadata = catalog_metadata.get((focus_node_type_code, focus_node_id), OntologyNodeMetadata()) + node_meta[focus_key] = ( + focus_node_type_code, + focus_label, + focus_metadata.truth_status_code, + focus_metadata.recorded_at, + ) + for fact in page_edges: + for node_type, node_id in ( + (fact.source_node_type_code, fact.source_node_id), + (fact.target_node_type_code, fact.target_node_id), + ): + key = _node_key(node_type, node_id) + label = labels[(node_type, node_id)] + node_evidence[key].update(fact.evidence_references) + current = node_meta.get(key) + if current is None: + metadata = catalog_metadata.get((node_type, node_id), OntologyNodeMetadata()) + node_meta[key] = ( + node_type, + label, + metadata.truth_status_code, + metadata.recorded_at, + ) + + # Trim by proximity to the focus (BFS/source-hop distance in `reached`), + # not by the "type:id" key string -- otherwise farther nodes of an + # alphabetically-earlier type code survive over closer nodes of a later + # type code. Node key is only a tiebreaker for equal distance. + ordered_keys = [focus_key] + sorted( + (key for key in node_meta if key != focus_key), + key=lambda key: (reached.get(key, maximum_depth + 1), key), + ) + if len(ordered_keys) > maximum_nodes: + keep = set(ordered_keys[:maximum_nodes]) + page_edges = [ + fact + for fact in page_edges + if _node_key(fact.source_node_type_code, fact.source_node_id) in keep + and _node_key(fact.target_node_type_code, fact.target_node_id) in keep + ] + ordered_keys = [key for key in ordered_keys if key in keep] + truncated = True + next_cursor = None + node_evidence = defaultdict(set) + for fact in page_edges: + for node_type, node_id in ( + (fact.source_node_type_code, fact.source_node_id), + (fact.target_node_type_code, fact.target_node_id), + ): + node_evidence[_node_key(node_type, node_id)].update( + fact.evidence_references + ) + + nodes = tuple( + OntologyGraphNode( + node_id=key.split(":", 1)[1], + node_type_code=node_meta[key][0], + ontology_class_iri=ontology_annotations(node_meta[key][0]).get( + "ontology_iri", str(LW[node_meta[key][0]]) + ), + display_label=node_meta[key][1], + truth_status_code=node_meta[key][2], + valid_from=None, + valid_to=None, + recorded_at=node_meta[key][3], + evidence_count=len(node_evidence.get(key, set())), + shape_code=NODE_SHAPE[node_meta[key][0]], + ) + for key in ordered_keys + ) + edges = tuple( + OntologyGraphEdge( + edge_id=_edge_id(fact), + source_node_type_code=fact.source_node_type_code, + source_node_id=fact.source_node_id, + target_node_type_code=fact.target_node_type_code, + target_node_id=fact.target_node_id, + property_code=fact.property_code, + ontology_property_iri=_PROPERTY_IRI[fact.property_code], + property_label=_property_label(fact.property_code), + truth_status_code=fact.truth_status_code, + valid_from=fact.valid_from, + valid_to=fact.valid_to, + recorded_at=fact.recorded_at, + provenance_reference=fact.provenance_reference, + evidence_references=fact.evidence_references, + ) + for fact in page_edges + ) + limitation = "neighborhood_truncated" if truncated else None + if not edges and focus_key in node_meta: + limitation = limitation or "neighborhood_empty" + return OntologyNeighborhood( + focus_node_id=focus_node_id, + focus_node_type_code=focus_node_type_code, + nodes=nodes, + edges=edges, + truncated=truncated, + next_cursor=next_cursor, + limitation_code=limitation, + ) + + +def _validate_fact(fact: NeighborhoodFact) -> None: + if fact.source_node_type_code not in KNOWN_NODE_TYPES or fact.target_node_type_code not in KNOWN_NODE_TYPES: + raise OntologyNeighborhoodError("unknown_node_type", "fact uses an unknown node type") + if fact.truth_status_code not in TRUTH_STATUS_CODES: + raise OntologyNeighborhoodError("unknown_truth_status", f"unknown truth {fact.truth_status_code!r}") + if fact.recorded_at.tzinfo is None: + raise OntologyNeighborhoodError("naive_timestamp", "recorded_at must be offset-aware") + if fact.valid_from is not None and fact.valid_from.tzinfo is None: + raise OntologyNeighborhoodError("naive_timestamp", "valid_from must be offset-aware") + if fact.valid_to is not None and fact.valid_to.tzinfo is None: + raise OntologyNeighborhoodError("naive_timestamp", "valid_to must be offset-aware") + if fact.valid_from is not None and fact.valid_to is not None and fact.valid_to < fact.valid_from: + raise OntologyNeighborhoodError("invalid_interval", "valid_to precedes valid_from") + if fact.property_code == PROPERTY_OWL_SUBCLASS_OF: + raise OntologyNeighborhoodError( + "owl_subclass_not_instance", + "OWL class subsumption is not an instance neighborhood edge", + ) + + +def _fact_sort_key(fact: NeighborhoodFact) -> tuple[str, str, str, str, str]: + return ( + fact.property_code, + fact.source_node_id, + fact.target_node_id, + fact.source_node_type_code, + fact.target_node_type_code, + ) + + +__all__ = [ + "ALLOWED_PROPERTY_ALIASES", + "DEFAULT_MAXIMUM_DEPTH", + "DEFAULT_MAXIMUM_EDGES", + "DEFAULT_MAXIMUM_NODES", + "INSTANCE_PROPERTY_CODES", + "JSONLD_CONTEXT", + "NeighborhoodFact", + "NODE_SHAPE", + "OntologyGraphEdge", + "OntologyGraphNode", + "OntologyNodeMetadata", + "OntologyNeighborhood", + "OntologyNeighborhoodError", + "PROPERTY_AFFILIATED_WITH", + "PROPERTY_CO_MENTIONED_WITH", + "PROPERTY_MENTIONS", + "PROPERTY_MENTIONS_ORGANIZATION", + "PROPERTY_MENTIONS_TEAM", + "PROPERTY_OWL_SUBCLASS_OF", + "PROPERTY_SKOS_BROADER", + "PROPERTY_TEAM_AFFILIATED_WITH", + "SKOS_BROADER_IRI", + "TRUTH_AUTHORITATIVE", + "TRUTH_INFERRED", + "TRUTH_OBSERVED", + "TRUTH_PROPOSED", + "TRUTH_REJECTED", + "TRUTH_STATUS_CODES", + "TRUTH_SUPERSEDED", + "assemble_ontology_neighborhood", + "canonicalize_property_code", + "fact_from_knowledge_graph_edge", + "skos_broader_fact", +] diff --git a/lineageweave/ontology_source_cursor.py b/lineageweave/ontology_source_cursor.py new file mode 100644 index 000000000..336285b7d --- /dev/null +++ b/lineageweave/ontology_source_cursor.py @@ -0,0 +1,299 @@ +"""Opaque AES-GCM source-window cursor for ontology neighborhoods (ADR 0125). + +The in-memory ``after:`` token only pages facts already loaded. This module +mints a versioned, authenticated-encrypted continuation token +so a later request can keyset-paginate the recursive SQL window. The token +never carries hidden endpoint IDs, omitted counts, or tenant identifiers in +plaintext. + +Grounding: AES-GCM authenticated encryption with associated data (Dworkin, +2007, NIST SP 800-38D). HMAC-SHA256 remains the scope digest (RFC 2104). +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import json +import os +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Mapping, Sequence + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from lineageweave.ontology_neighborhood import OntologyNeighborhoodError + +SOURCE_CURSOR_PREFIX = "src.v2." +SOURCE_CURSOR_VERSION = 2 +SOURCE_CURSOR_TTL = timedelta(minutes=15) +SOURCE_CURSOR_MIN_SECRET_BYTES = 32 +_NONCE_BYTES = 12 +_TAG_BYTES = 16 +_ENC_INFO = b"lw-ontology-src-aesgcm-v2" + + +@dataclass(frozen=True) +class OntologySourceKey: + """Deterministic keyset position inside the recursive source window.""" + + hop_depth: int + edge_type_code: str + source_node_type_code: str + source_node_id: str + target_node_type_code: str + target_node_id: str + + +@dataclass(frozen=True) +class OntologySourceCursor: + """Verified continuation claims for one neighborhood request snapshot.""" + + focus_node_type_code: str + focus_node_id: str + knowledge_cutoff: datetime | None + maximum_depth: int + maximum_nodes: int + maximum_edges: int + allowed_property_codes: tuple[str, ...] | None + last_key: OntologySourceKey + snapshot_at: datetime + eligibility_digest: str + expires_at: datetime + + +def source_cursor_secret_from_env(secret: str | None) -> bytes | None: + """Return a usable HMAC secret, or None when continuation must stay closed. + + Next action: pass this to mint/verify, and withhold ``next_cursor`` when + it is None so a missing process secret cannot mint a fake page token. + """ + raw = (secret if secret is not None else os.environ.get("ONTOLOGY_SOURCE_CURSOR_SECRET", "")).strip() + if not raw: + return None + encoded = raw.encode("utf-8") + if len(encoded) < SOURCE_CURSOR_MIN_SECRET_BYTES: + return None + return encoded + + +def scope_digest(secret: bytes, user_account_id: str) -> str: + """HMAC-truncated authorization scope; never the raw account id.""" + digest = hmac.new(secret, f"scope|{user_account_id}".encode("utf-8"), hashlib.sha256).hexdigest() + return digest[:32] + + +def eligibility_digest(visible_post_ids: Sequence[str]) -> str: + """Snapshot hash of the frozen visible-post set used for paging.""" + joined = ",".join(sorted(visible_post_ids)) + return hashlib.sha256(joined.encode("utf-8")).hexdigest() + + +def mint_source_cursor( + *, + secret: bytes, + user_account_id: str, + focus_node_type_code: str, + focus_node_id: str, + knowledge_cutoff: datetime | None, + maximum_depth: int, + maximum_nodes: int, + maximum_edges: int, + allowed_property_codes: Sequence[str] | None, + last_key: OntologySourceKey, + snapshot_at: datetime, + visible_post_ids: Sequence[str], + now: datetime | None = None, +) -> str: + """Seal a source-window continuation token. + + Next action: return this as ``next_cursor`` when the SQL window still has + authorized relations beyond the current page. + """ + clock = now or datetime.now(timezone.utc) + payload = { + "v": SOURCE_CURSOR_VERSION, + "focus_type": focus_node_type_code, + "focus_id": focus_node_id, + "cutoff": knowledge_cutoff.isoformat() if knowledge_cutoff else "", + "depth": maximum_depth, + "max_nodes": maximum_nodes, + "max_edges": maximum_edges, + "properties": ",".join(sorted(allowed_property_codes)) if allowed_property_codes else "", + "last_depth": last_key.hop_depth, + "last_edge_type": last_key.edge_type_code, + "last_source_type": last_key.source_node_type_code, + "last_source_id": last_key.source_node_id, + "last_target_type": last_key.target_node_type_code, + "last_target_id": last_key.target_node_id, + "snapshot": snapshot_at.isoformat(), + "elig": eligibility_digest(visible_post_ids), + "scope": scope_digest(secret, user_account_id), + "exp": int((clock + SOURCE_CURSOR_TTL).timestamp()), + } + plaintext = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + nonce = os.urandom(_NONCE_BYTES) + ciphertext = AESGCM(_enc_key(secret)).encrypt( + nonce, + plaintext, + SOURCE_CURSOR_PREFIX.encode("ascii"), + ) + packed = nonce + ciphertext + return SOURCE_CURSOR_PREFIX + _b64encode(packed) + + +def verify_source_cursor( + token: str, + *, + secret: bytes, + user_account_id: str, + focus_node_type_code: str, + focus_node_id: str, + knowledge_cutoff: datetime | None, + maximum_depth: int, + maximum_nodes: int, + maximum_edges: int, + allowed_property_codes: Sequence[str] | None, + visible_post_ids: Sequence[str], + validate_eligibility: bool = True, + now: datetime | None = None, +) -> OntologySourceCursor: + """Open a source cursor and fail closed on tamper, scope, or snapshot drift. + + ``validate_eligibility=False`` is only for the first authenticated pass of + a continuation request. It verifies the sealed request, scope, expiry, and + snapshot before the caller reconstructs the frozen eligibility set; the + caller must run this function again with that reconstructed set. + + Next action: bind the returned last key into the recursive keyset query. + """ + if not token.startswith(SOURCE_CURSOR_PREFIX): + raise OntologyNeighborhoodError("malformed_cursor", "cursor must be an opaque source token") + packed = _b64decode(token[len(SOURCE_CURSOR_PREFIX) :]) + if len(packed) < _NONCE_BYTES + _TAG_BYTES + 1: + raise OntologyNeighborhoodError("malformed_cursor", "cursor is truncated") + nonce = packed[:_NONCE_BYTES] + ciphertext = packed[_NONCE_BYTES:] + try: + plaintext = AESGCM(_enc_key(secret)).decrypt( + nonce, + ciphertext, + SOURCE_CURSOR_PREFIX.encode("ascii"), + ) + payload = json.loads(plaintext) + except (InvalidTag, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise OntologyNeighborhoodError("malformed_cursor", "cursor payload is not readable") from exc + if not isinstance(payload, dict): + raise OntologyNeighborhoodError("malformed_cursor", "cursor payload is not an object") + return _validated_cursor( + payload, + secret=secret, + user_account_id=user_account_id, + focus_node_type_code=focus_node_type_code, + focus_node_id=focus_node_id, + knowledge_cutoff=knowledge_cutoff, + maximum_depth=maximum_depth, + maximum_nodes=maximum_nodes, + maximum_edges=maximum_edges, + allowed_property_codes=allowed_property_codes, + visible_post_ids=visible_post_ids, + validate_eligibility=validate_eligibility, + now=now or datetime.now(timezone.utc), + ) + + +def source_key_from_row(row: Mapping[str, object]) -> OntologySourceKey: + """Project one SQL fact row onto the keyset continuation key.""" + hop_depth = row["hop_depth"] if "hop_depth" in row else 0 + return OntologySourceKey( + hop_depth=int(hop_depth or 0), + edge_type_code=str(row["edge_type_code"]), + source_node_type_code=str(row["source_node_type_code"]), + source_node_id=str(row["source_node_id"]), + target_node_type_code=str(row["target_node_type_code"]), + target_node_id=str(row["target_node_id"]), + ) + + +def _validated_cursor( + payload: Mapping[str, object], + *, + secret: bytes, + user_account_id: str, + focus_node_type_code: str, + focus_node_id: str, + knowledge_cutoff: datetime | None, + maximum_depth: int, + maximum_nodes: int, + maximum_edges: int, + allowed_property_codes: Sequence[str] | None, + visible_post_ids: Sequence[str], + validate_eligibility: bool, + now: datetime, +) -> OntologySourceCursor: + if payload.get("v") != SOURCE_CURSOR_VERSION: + raise OntologyNeighborhoodError("malformed_cursor", "cursor version is not supported") + expires_at = datetime.fromtimestamp(int(payload["exp"]), tz=timezone.utc) + if now >= expires_at: + raise OntologyNeighborhoodError("malformed_cursor", "cursor has expired") + if payload.get("scope") != scope_digest(secret, user_account_id): + raise OntologyNeighborhoodError("malformed_cursor", "cursor is outside this authorization scope") + if payload.get("focus_type") != focus_node_type_code or payload.get("focus_id") != focus_node_id: + raise OntologyNeighborhoodError("malformed_cursor", "cursor does not match this focus") + expected_cutoff = knowledge_cutoff.isoformat() if knowledge_cutoff else "" + if payload.get("cutoff") != expected_cutoff: + raise OntologyNeighborhoodError("malformed_cursor", "cursor does not match this knowledge cutoff") + if ( + int(payload["depth"]) != maximum_depth + or int(payload["max_nodes"]) != maximum_nodes + or int(payload["max_edges"]) != maximum_edges + ): + raise OntologyNeighborhoodError("malformed_cursor", "cursor does not match request bounds") + expected_properties = ",".join(sorted(allowed_property_codes)) if allowed_property_codes else "" + if payload.get("properties") != expected_properties: + raise OntologyNeighborhoodError("malformed_cursor", "cursor does not match property filter") + if validate_eligibility and payload.get("elig") != eligibility_digest(visible_post_ids): + raise OntologyNeighborhoodError("stale_snapshot", "cursor snapshot no longer matches visible evidence") + snapshot_at = datetime.fromisoformat(str(payload["snapshot"])) + if snapshot_at.tzinfo is None: + raise OntologyNeighborhoodError("malformed_cursor", "cursor snapshot is not offset-aware") + last_key = OntologySourceKey( + hop_depth=int(payload["last_depth"]), + edge_type_code=str(payload["last_edge_type"]), + source_node_type_code=str(payload["last_source_type"]), + source_node_id=str(payload["last_source_id"]), + target_node_type_code=str(payload["last_target_type"]), + target_node_id=str(payload["last_target_id"]), + ) + return OntologySourceCursor( + focus_node_type_code=focus_node_type_code, + focus_node_id=focus_node_id, + knowledge_cutoff=knowledge_cutoff, + maximum_depth=maximum_depth, + maximum_nodes=maximum_nodes, + maximum_edges=maximum_edges, + allowed_property_codes=tuple(allowed_property_codes) if allowed_property_codes else None, + last_key=last_key, + snapshot_at=snapshot_at, + eligibility_digest=str(payload["elig"]), + expires_at=expires_at, + ) + + +def _enc_key(secret: bytes) -> bytes: + return hmac.new(secret, _ENC_INFO, hashlib.sha256).digest() + + +def _b64encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +def _b64decode(token: str) -> bytes: + padding = "=" * ((4 - len(token) % 4) % 4) + try: + return base64.urlsafe_b64decode(token + padding) + except (ValueError, binascii.Error) as exc: + raise OntologyNeighborhoodError("malformed_cursor", "cursor encoding is invalid") from exc diff --git a/lineageweave/organization_name_resolution.py b/lineageweave/organization_name_resolution.py index d79cc60a3..b2a238628 100644 --- a/lineageweave/organization_name_resolution.py +++ b/lineageweave/organization_name_resolution.py @@ -27,7 +27,7 @@ from dataclasses import dataclass from typing import Protocol -from .http_client import HttpClientError, post_json +from .http_client import HttpClientError, chat_completion_content, post_json from .relation_verification import ( STATUS_PENDING, RelationVerificationClient, @@ -150,7 +150,7 @@ def resolve(self, raw_name: str, context_text: str) -> str | None: headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) return parse_resolution_response(content) diff --git a/lineageweave/post_chat.py b/lineageweave/post_chat.py index cb5c9ce0c..4041fe165 100644 --- a/lineageweave/post_chat.py +++ b/lineageweave/post_chat.py @@ -24,7 +24,7 @@ from dataclasses import dataclass, field from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json CANONICAL_CHAT_QUESTION = "What happened between these events?" CANONICAL_INVOLVED_QUESTION = "Who is involved?" @@ -320,8 +320,8 @@ def answer(self, question: str, sources: list[ChatSourceDocument]) -> ChatAnswer headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) answer = _parse_plain_chat_response(content, sources) if answer is None: - raise ValueError(f"chat response did not match the required format: {content!r}") + raise ValueError("chat response did not match the required format") return answer diff --git a/lineageweave/post_evaluation.py b/lineageweave/post_evaluation.py index e884179ea..fd4a1a4b4 100644 --- a/lineageweave/post_evaluation.py +++ b/lineageweave/post_evaluation.py @@ -14,7 +14,7 @@ from fast_mlsirm import ContextualOrchestratorJudge, JudgeCriterion, LLMJudgeResult -from .http_client import post_json +from .http_client import chat_completion_content, post_json RUBRIC_VERSION = "2026-08-13" IRT_CATEGORY_COUNT = 5 @@ -100,7 +100,7 @@ def complete(self, messages: list[dict[str, Any]], mode: str = "auto") -> dict[s timeout=self._timeout, ) return { - "answer": body["choices"][0]["message"]["content"], + "answer": chat_completion_content(body), "mode": mode, "trace": [], } diff --git a/lineageweave/post_summary.py b/lineageweave/post_summary.py index e684bde9a..3b8963c1a 100644 --- a/lineageweave/post_summary.py +++ b/lineageweave/post_summary.py @@ -43,7 +43,7 @@ from dataclasses import dataclass, field from typing import Protocol -from .http_client import post_json +from .http_client import chat_completion_content, post_json # common_lookup_value category "prov_agent_type" -- PROV-O's prov:Person / # prov:Organization for the micro/macro cases, plus a meso-level third @@ -949,10 +949,10 @@ def summarize_with_hints( headers={"authorization": f"Bearer {self._api_key}"}, timeout=self._timeout, ) - content = body["choices"][0]["message"]["content"] + content = chat_completion_content(body) parsed = _parse_plain_summary_response(content) if parsed is None: - raise ValueError(f"summary response did not match the required format: {content!r}") + raise ValueError("summary response did not match the required format") korean_summary, key_events, key_event_details = parsed details_body = post_json( f"{self._base_url}/v1/chat/completions", @@ -975,15 +975,12 @@ def summarize_with_hints( timeout=self._timeout, ) details = _parse_plain_summary_details( - details_body["choices"][0]["message"]["content"], + chat_completion_content(details_body), post_title=post_title, context_hints=context_hints, ) if details is None: - raise ValueError( - "summary semantic response did not match the required format: " - f"{details_body['choices'][0]['message']['content']!r}" - ) + raise ValueError("summary semantic response did not match the required format") roles, projects, actions, five_w1h_evidence = details return PostSummary( korean_summary=korean_summary, diff --git a/migrations/0175_ontology_truth_status.sql b/migrations/0175_ontology_truth_status.sql new file mode 100644 index 000000000..37814367e --- /dev/null +++ b/migrations/0175_ontology_truth_status.sql @@ -0,0 +1,13 @@ +-- Migration 0175: ontology neighborhood truth-status vocabulary (ADR 0168 / issue #341). +-- Instance graph edges stay on knowledge_graph_edge; SKOS broader stays +-- on corporate_entity.parent_entity_id. These lookup rows name the +-- buyer-visible truth status without promoting inference. + +insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values + ('ontology_truth_status', 'truth_authoritative', 'Authoritative', 0), + ('ontology_truth_status', 'truth_observed', 'Observed', 1), + ('ontology_truth_status', 'truth_inferred', 'Inferred', 2), + ('ontology_truth_status', 'truth_proposed', 'Proposed', 3), + ('ontology_truth_status', 'truth_superseded', 'Superseded', 4), + ('ontology_truth_status', 'truth_rejected', 'Rejected', 5) +on conflict (lookup_code) do nothing; diff --git a/pyproject.toml b/pyproject.toml index c5ffda1bf..aa6971514 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.26" +version = "2.14.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } @@ -17,6 +17,7 @@ dependencies = [ # Explicit CA bundle for http_client HTTPS posts -- some interpreter # distributions don't reliably inherit the OS trust store. "certifi>=2024.0.0", + "cryptography>=42.0", # The standard Python RDF/OWL library -- parses and validates # docs/ontology/lineageweave-kg.ttl and the standards-complete PROV-O # support profile (ADR 0011). Pure Python, no Rust/C toolchain. diff --git a/tests/test_analysis_run_authorization.py b/tests/test_analysis_run_authorization.py index 91de6374f..8bfe62642 100644 --- a/tests/test_analysis_run_authorization.py +++ b/tests/test_analysis_run_authorization.py @@ -11,7 +11,11 @@ import pytest from psycopg2 import sql -from backend.app.analysis_run_ingestion import _RUN_DETAIL_SQL, _RUN_LIST_SQL +from backend.app.analysis_run_ingestion import ( + _COUNTS_BY_RUN_SQL, + _RUN_DETAIL_SQL, + _RUN_LIST_SQL, +) _ROOT = Path(__file__).resolve().parents[1] _INITIAL_MIGRATION = _ROOT / "migrations" / "0001_initial_schema.sql" @@ -29,6 +33,8 @@ def test_visible_run_sql_is_parameterized_literals() -> None: assert "$3" in _RUN_DETAIL_SQL assert "{" not in _RUN_LIST_SQL assert "{" not in _RUN_DETAIL_SQL + assert "$1" in _COUNTS_BY_RUN_SQL + assert "{" not in _COUNTS_BY_RUN_SQL def _postgres_available() -> bool: diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index e46aa4a0c..e4753f30f 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -17,6 +17,7 @@ ) from backend.app.lineage_ingestion import records_from_source_posts from lineageweave.fixtures import sample_records +from lineageweave.http_client import HttpClientError from lineageweave.lineage_persistence import lineage_edge_specs from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable @@ -153,6 +154,19 @@ def test_configured_tepp_client_stays_unavailable_without_http() -> None: client.submit_analysis_run(_tepp_request()) +def test_configured_tepp_client_does_not_expose_provider_error(monkeypatch) -> None: + """TEPP transport failures remain a stable unavailable product error.""" + def fail(*_args, **_kwargs): + raise HttpClientError("raw-tepp-provider-secret") + + monkeypatch.setattr("backend.app.analysis_run_start.post_json", fail) + client = configured_tepp_client("https://tepp.example.test/run") + with pytest.raises(TeppNotAvailable) as exc_info: + client.submit_analysis_run(_tepp_request()) + assert str(exc_info.value) == "TEPP transport unavailable" + assert "raw-tepp-provider-secret" not in str(exc_info.value) + + def test_hidden_run_start_is_not_found() -> None: """Operators get a 404 next action, not an internal exception name.""" error = AnalysisRunStartError(404, "This analysis run is not visible.") diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 711e4c346..2954e061b 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -6,7 +6,33 @@ import pytest -from lineageweave.http_client import HttpClientError, get_json, get_json_list, post_form, post_json +from lineageweave.http_client import ( + HttpClientError, + chat_completion_content, + get_json, + post_form, + post_json, +) + + +@pytest.mark.parametrize( + "body", + [ + {"error": "raw-provider-secret"}, + {"choices": []}, + {"choices": [{"message": {"content": 123}}]}, + {"choices": [{"message": {"content": ["raw-provider-secret"]}}]}, + ], +) +def test_chat_completion_content_rejects_unsafe_or_malformed_envelopes(body: object) -> None: + with pytest.raises((TypeError, ValueError)) as error: + chat_completion_content(body) + + assert "raw-provider-secret" not in str(error.value) + + +def test_chat_completion_content_returns_text_without_rewriting_it() -> None: + assert chat_completion_content({"choices": [{"message": {"content": " [] "}}]}) == " [] " class _JsonHandler(BaseHTTPRequestHandler): diff --git a/tests/test_keyman_extraction.py b/tests/test_keyman_extraction.py index d9de45710..68adc5e4a 100644 --- a/tests/test_keyman_extraction.py +++ b/tests/test_keyman_extraction.py @@ -86,12 +86,14 @@ def test_entry_with_invalid_side_is_skipped() -> None: assert parse_keyman_response(content) == [] -def test_invalid_json_returns_empty_list() -> None: - assert parse_keyman_response("not json at all") == [] +def test_invalid_json_fails_closed_without_deleting_existing_mentions() -> None: + with pytest.raises(ValueError, match="valid JSON array"): + parse_keyman_response("not json at all") -def test_non_list_top_level_returns_empty_list() -> None: - assert parse_keyman_response('{"name": "not a list"}') == [] +def test_non_list_top_level_fails_closed_without_deleting_existing_mentions() -> None: + with pytest.raises(ValueError, match="JSON array"): + parse_keyman_response('{"name": "not a list"}') _ORCHESTRATOR_BASE_URL = os.environ.get("LINEAGEWEAVE_TEST_ORCHESTRATOR_BASE_URL") diff --git a/tests/test_ontology_fact_query_bounds.py b/tests/test_ontology_fact_query_bounds.py new file mode 100644 index 000000000..767a677fa --- /dev/null +++ b/tests/test_ontology_fact_query_bounds.py @@ -0,0 +1,40 @@ +"""Regression tests for bounded ontology fact loading.""" + +from __future__ import annotations + +import asyncio + +from backend.app.ontology_neighborhood_ingestion import _load_facts +from lineageweave.knowledge_graph import NODE_POST +from lineageweave.ontology_neighborhood import DEFAULT_MAXIMUM_DEPTH, HARD_MAXIMUM_EDGES + + +class _EmptyConnection: + """Capture a bounded query without requiring a live PostgreSQL instance.""" + + def __init__(self) -> None: + self.query = "" + self.arguments: tuple[object, ...] = () + + async def fetch(self, query: str, *arguments: object) -> list[object]: + self.query = query + self.arguments = arguments + return [] + + +def test_load_facts_passes_the_request_edge_cap_to_sql() -> None: + """Fact retrieval must be deterministic and bounded before assembly.""" + conn = _EmptyConnection() + + assert asyncio.run(_load_facts(conn, ["post-1"], maximum_edges=7)) == [] + + normalized_query = " ".join(conn.query.lower().split()) + assert "with recursive candidate_facts" in normalized_query + assert "hop_depth" in normalized_query + assert "offset" not in normalized_query + assert "limit $5::integer" in normalized_query + assert conn.arguments[:3] == (["post-1"], NODE_POST, "") + assert conn.arguments[3] == DEFAULT_MAXIMUM_DEPTH + assert conn.arguments[4] == min(HARD_MAXIMUM_EDGES, 8) + assert conn.arguments[5] is None + assert conn.arguments[7] is None diff --git a/tests/test_ontology_neighborhood.py b/tests/test_ontology_neighborhood.py new file mode 100644 index 000000000..bf1c7ddae --- /dev/null +++ b/tests/test_ontology_neighborhood.py @@ -0,0 +1,816 @@ +"""Ontology neighborhood assembler: typed facts, not Event Lineage.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from lineageweave.knowledge_graph import ( + EDGE_AFFILIATION, + EDGE_CO_MENTION, + EDGE_MENTION, + EDGE_MENTION_ORGANIZATION, + EDGE_MENTION_TEAM, + EDGE_TEAM_AFFILIATION, + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_POST, + NODE_TEAM, +) +from lineageweave.ontology import LW +from lineageweave.ontology_neighborhood import ( + PROPERTY_AFFILIATED_WITH, + PROPERTY_CO_MENTIONED_WITH, + PROPERTY_MENTIONS, + PROPERTY_MENTIONS_ORGANIZATION, + PROPERTY_MENTIONS_TEAM, + PROPERTY_OWL_SUBCLASS_OF, + PROPERTY_SKOS_BROADER, + PROPERTY_TEAM_AFFILIATED_WITH, + SKOS_BROADER_IRI, + TRUTH_AUTHORITATIVE, + TRUTH_INFERRED, + TRUTH_OBSERVED, + HARD_MAXIMUM_NODES, + NeighborhoodFact, + OntologyGraphEdge, + OntologyNodeMetadata, + OntologyNeighborhood, + OntologyNeighborhoodError, + assemble_ontology_neighborhood, + canonicalize_property_code, + fact_from_knowledge_graph_edge, + skos_broader_fact, +) + +POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1" +PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1" +CORP_ID = "cccccccc-cccc-cccc-cccc-ccccccccccc1" +GROUP_ID = "dddddddd-dddd-dddd-dddd-ddddddddddd1" +HIDDEN_PERSON = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeee1" +TEAM_ID = "ffffffff-ffff-ffff-ffff-fffffffffff1" +TZ = timezone.utc +T0 = datetime(2026, 1, 10, 12, 0, tzinfo=TZ) +T_LATE = datetime(2026, 1, 20, 12, 0, tzinfo=TZ) +CUTOFF = datetime(2026, 1, 15, 12, 0, tzinfo=TZ) + + +def _labels() -> dict[tuple[str, str], str]: + return { + (NODE_POST, POST_ID): "Demo public post", + (NODE_PERSON, PERSON_ID): "Test Person", + (NODE_CORPORATE_ENTITY, CORP_ID): "Demo Corp", + (NODE_CORPORATE_ENTITY, GROUP_ID): "Demo Group", + (NODE_PERSON, HIDDEN_PERSON): "Hidden Person", + (NODE_TEAM, TEAM_ID): "Demo Team", + } + + +def _mention_affiliation() -> list[NeighborhoodFact]: + mention = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code=EDGE_MENTION, + recorded_at=T0, + evidence_references=(POST_ID,), + provenance_reference="kg-edge-mention", + ) + affiliation = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_AFFILIATION, + recorded_at=T0, + evidence_references=(POST_ID,), + provenance_reference="kg-edge-affiliation", + ) + return [mention, affiliation] + + +def test_post_mentions_person_affiliated_with_corporate_entity_round_trips() -> None: + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=_mention_affiliation(), + labels=_labels(), + maximum_depth=2, + ) + properties = {(edge.source_node_id, edge.property_code, edge.target_node_id) for edge in neighborhood.edges} + assert (POST_ID, PROPERTY_MENTIONS, PERSON_ID) in properties + assert (PERSON_ID, PROPERTY_AFFILIATED_WITH, CORP_ID) in properties + mentions = next(edge for edge in neighborhood.edges if edge.property_code == PROPERTY_MENTIONS) + assert mentions.ontology_property_iri == str(LW.mentions) + assert mentions.truth_status_code == TRUTH_OBSERVED + person = next(node for node in neighborhood.nodes if node.node_id == PERSON_ID) + assert person.evidence_count == 1 + document = neighborhood.jsonld_document() + assert document["@context"]["lw"] == str(LW) + iris = {node.ontology_class_iri for node in neighborhood.nodes} + assert str(LW.Post) in iris + assert str(LW.Person) in iris + assert str(LW.CorporateEntity) in iris + rows = neighborhood.exact_value_rows() + assert {row["property_code"] for row in rows} == {PROPERTY_MENTIONS, PROPERTY_AFFILIATED_WITH} + assert all(row["source_label"] and row["target_label"] for row in rows) + + +def test_jsonld_keeps_colliding_identifiers_typed() -> None: + facts = [ + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=POST_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code=EDGE_MENTION, + recorded_at=T0, + evidence_references=(POST_ID,), + ), + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=POST_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_AFFILIATION, + recorded_at=T0, + evidence_references=(POST_ID,), + ), + ] + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=facts, + labels={ + (NODE_POST, POST_ID): "Test post", + (NODE_PERSON, POST_ID): "Test person", + (NODE_CORPORATE_ENTITY, CORP_ID): "Test organization", + }, + maximum_depth=2, + ) + edge_ids = { + item["@id"]: item + for item in neighborhood.jsonld_document()["@graph"] + if str(item["@id"]).startswith("lw:edge/") + } + mentions = next(item for key, item in edge_ids.items() if "/mentions:" in str(key)) + assert mentions["lw:source"]["@id"] == f"lw:node/{NODE_POST}/{POST_ID}" + + +def test_skos_broader_is_distinct_from_owl_class_subsumption() -> None: + facts = _mention_affiliation() + [ + skos_broader_fact( + narrower_entity_id=CORP_ID, + broader_entity_id=GROUP_ID, + recorded_at=T0, + provenance_reference="corporate_entity.parent_entity_id", + ) + ] + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_CORPORATE_ENTITY, + focus_node_id=CORP_ID, + facts=facts, + labels=_labels(), + maximum_depth=2, + ) + broader = next(edge for edge in neighborhood.edges if edge.property_code == PROPERTY_SKOS_BROADER) + assert broader.ontology_property_iri == SKOS_BROADER_IRI + assert broader.truth_status_code == TRUTH_AUTHORITATIVE + assert broader.source_node_id == CORP_ID + assert broader.target_node_id == GROUP_ID + assert all(edge.ontology_property_iri != str(LW) + "subClassOf" for edge in neighborhood.edges) + with pytest.raises(OntologyNeighborhoodError) as raised: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[ + NeighborhoodFact( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + property_code=PROPERTY_OWL_SUBCLASS_OF, + truth_status_code=TRUTH_AUTHORITATIVE, + recorded_at=T0, + ) + ], + labels=_labels(), + ) + assert raised.value.code == "owl_subclass_not_instance" + + +def test_inferred_edge_is_never_serialized_as_authoritative() -> None: + inferred = NeighborhoodFact( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + property_code=PROPERTY_AFFILIATED_WITH, + truth_status_code=TRUTH_INFERRED, + recorded_at=T0, + evidence_references=(POST_ID,), + ) + broader = skos_broader_fact( + narrower_entity_id=CORP_ID, + broader_entity_id=GROUP_ID, + recorded_at=T0, + ) + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[inferred, broader], + labels=_labels(), + ) + inferred_edge = next(edge for edge in neighborhood.edges if edge.property_code == PROPERTY_AFFILIATED_WITH) + assert inferred_edge.truth_status_code == TRUTH_INFERRED + assert inferred_edge.truth_status_code != TRUTH_AUTHORITATIVE + corporate_node = next(node for node in neighborhood.nodes if node.node_id == CORP_ID) + assert corporate_node.truth_status_code != TRUTH_AUTHORITATIVE + assert corporate_node.evidence_count == 1 + document = neighborhood.jsonld_document() + node_item = next(item for item in document["@graph"] if item["@id"].endswith(f"/{CORP_ID}")) + assert node_item.get("lw:truthStatus") != TRUTH_AUTHORITATIVE + statuses = [item["lw:truthStatus"] for item in document["@graph"] if "lw:truthStatus" in item] + assert TRUTH_INFERRED in statuses + assert TRUTH_AUTHORITATIVE in statuses + + +def test_hidden_endpoint_removes_edge_without_count_side_channel() -> None: + hidden_affiliation = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=HIDDEN_PERSON, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_AFFILIATION, + recorded_at=T0, + evidence_references=(POST_ID,), + ) + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_CORPORATE_ENTITY, + focus_node_id=CORP_ID, + facts=_mention_affiliation() + [hidden_affiliation], + labels=_labels(), + hidden_node_keys=frozenset({f"{NODE_PERSON}:{HIDDEN_PERSON}"}), + ) + assert HIDDEN_PERSON not in {node.node_id for node in neighborhood.nodes} + assert HIDDEN_PERSON not in {edge.source_node_id for edge in neighborhood.edges} + assert HIDDEN_PERSON not in {edge.target_node_id for edge in neighborhood.edges} + payload = neighborhood.jsonld_document() + serialized = str(payload) + assert HIDDEN_PERSON not in serialized + assert "omitted" not in serialized.lower() + assert neighborhood.limitation_code != "hidden_count" + + +def test_cutoff_excludes_later_available_evidence() -> None: + late = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_AFFILIATION, + recorded_at=T_LATE, + evidence_references=(POST_ID,), + ) + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=[_mention_affiliation()[0], late], + labels=_labels(), + knowledge_cutoff=CUTOFF, + maximum_depth=2, + ) + assert all(edge.property_code != PROPERTY_AFFILIATED_WITH for edge in neighborhood.edges) + assert any(edge.property_code == PROPERTY_MENTIONS for edge in neighborhood.edges) + + +def test_unknown_property_and_unlabeled_edge_fail_closed() -> None: + with pytest.raises(OntologyNeighborhoodError) as unknown: + canonicalize_property_code("edge_invented") + assert unknown.value.code == "unknown_property" + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=_mention_affiliation(), + labels={(NODE_POST, POST_ID): "Demo public post", (NODE_PERSON, PERSON_ID): "Test Person"}, + ) + assert [edge.property_code for edge in neighborhood.edges] == [PROPERTY_MENTIONS] + assert [node.node_id for node in neighborhood.nodes] == [POST_ID, PERSON_ID] + + +def test_naive_timestamp_and_invalid_interval_fail_closed() -> None: + with pytest.raises(OntologyNeighborhoodError) as naive: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[ + NeighborhoodFact( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + property_code=PROPERTY_AFFILIATED_WITH, + truth_status_code=TRUTH_OBSERVED, + recorded_at=datetime(2026, 1, 10, 12, 0), + ) + ], + labels=_labels(), + ) + assert naive.value.code == "naive_timestamp" + with pytest.raises(OntologyNeighborhoodError) as interval: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[ + NeighborhoodFact( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + property_code=PROPERTY_AFFILIATED_WITH, + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + valid_from=T0, + valid_to=T0 - timedelta(days=1), + ) + ], + labels=_labels(), + ) + assert interval.value.code == "invalid_interval" + + +def test_excessive_depth_and_malformed_cursor_fail_closed() -> None: + with pytest.raises(OntologyNeighborhoodError) as depth: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=_mention_affiliation(), + labels=_labels(), + maximum_depth=99, + ) + assert depth.value.code == "excessive_depth" + with pytest.raises(OntologyNeighborhoodError) as cursor: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=_mention_affiliation(), + labels=_labels(), + cursor="1", + ) + assert cursor.value.code == "malformed_cursor" + + +def test_invalid_focus_id_is_distinct_from_unknown_node_type() -> None: + for invalid_id in ("", " padded", "padded "): + with pytest.raises(OntologyNeighborhoodError) as raised: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=invalid_id, + facts=[], + labels=_labels(), + ) + assert raised.value.code == "invalid_focus_id" + + +def test_node_metadata_is_catalog_owned_and_missing_values_stay_absent() -> None: + metadata = { + (NODE_PERSON, PERSON_ID): OntologyNodeMetadata( + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + ) + } + with_metadata = assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[_mention_affiliation()[1]], + labels=_labels(), + node_metadata=metadata, + ) + person = next(node for node in with_metadata.nodes if node.node_id == PERSON_ID) + assert person.truth_status_code == TRUTH_OBSERVED + assert person.recorded_at == T0 + without_metadata = assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[], + labels=_labels(), + ) + assert without_metadata.nodes[0].truth_status_code is None + assert without_metadata.nodes[0].recorded_at is None + + +def test_truncation_is_flagged_without_omission_counts() -> None: + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=_mention_affiliation(), + labels=_labels(), + maximum_depth=2, + maximum_edges=1, + ) + assert neighborhood.truncated is True + assert neighborhood.next_cursor is not None + assert neighborhood.next_cursor.startswith("after:") + assert neighborhood.limitation_code == "neighborhood_truncated" + page_two = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=_mention_affiliation(), + labels=_labels(), + maximum_depth=2, + maximum_edges=1, + cursor=neighborhood.next_cursor, + ) + assert {edge.edge_id for edge in neighborhood.edges}.isdisjoint({edge.edge_id for edge in page_two.edges}) + + +def test_empty_visible_neighborhood_names_the_empty_limitation() -> None: + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=[], + labels=_labels(), + ) + assert neighborhood.edges == () + assert neighborhood.nodes[0].node_id == POST_ID + assert neighborhood.limitation_code == "neighborhood_empty" + + +def test_unknown_node_type_and_unknown_truth_fail_closed() -> None: + with pytest.raises(OntologyNeighborhoodError) as node_type: + assemble_ontology_neighborhood( + focus_node_type_code="node_invented", + focus_node_id=POST_ID, + facts=[], + labels=_labels(), + ) + assert node_type.value.code == "unknown_node_type" + with pytest.raises(OntologyNeighborhoodError) as truth: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[ + NeighborhoodFact( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + property_code=PROPERTY_AFFILIATED_WITH, + truth_status_code="truth_guessed", + recorded_at=T0, + ) + ], + labels=_labels(), + ) + assert truth.value.code == "unknown_truth_status" + + +def test_allowed_property_filter_keeps_mentions_alias() -> None: + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=_mention_affiliation(), + labels=_labels(), + allowed_property_codes=[EDGE_MENTION], + maximum_depth=2, + ) + assert {edge.property_code for edge in neighborhood.edges} == {PROPERTY_MENTIONS} + + +def test_hidden_focus_fails_closed() -> None: + with pytest.raises(OntologyNeighborhoodError) as hidden: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=HIDDEN_PERSON, + facts=_mention_affiliation(), + labels=_labels(), + hidden_node_keys=frozenset({f"{NODE_PERSON}:{HIDDEN_PERSON}"}), + ) + assert hidden.value.code == "focus_hidden" + + +def test_unbounded_request_and_empty_focus_fail_closed() -> None: + with pytest.raises(OntologyNeighborhoodError) as nodes: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=[], + labels=_labels(), + maximum_nodes=HARD_MAXIMUM_NODES + 1, + ) + assert nodes.value.code == "unbounded_request" + with pytest.raises(OntologyNeighborhoodError) as edges: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=[], + labels=_labels(), + maximum_edges=0, + ) + assert edges.value.code == "unbounded_request" + with pytest.raises(OntologyNeighborhoodError) as empty: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=" ", + facts=[], + labels=_labels(), + ) + assert empty.value.code == "invalid_focus_id" + + +def test_team_and_organization_mention_edges_round_trip() -> None: + facts = _mention_affiliation() + [ + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_TEAM, + source_node_id=TEAM_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code=EDGE_MENTION_TEAM, + recorded_at=T0, + evidence_references=(POST_ID,), + ), + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_TEAM, + source_node_id=TEAM_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_TEAM_AFFILIATION, + recorded_at=T0, + evidence_references=(POST_ID,), + ), + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_CORPORATE_ENTITY, + source_node_id=CORP_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code=EDGE_MENTION_ORGANIZATION, + recorded_at=T0, + evidence_references=(POST_ID,), + ), + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_PERSON, + target_node_id=HIDDEN_PERSON, + edge_type_code=EDGE_CO_MENTION, + recorded_at=T0, + evidence_references=(POST_ID,), + ), + ] + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=facts, + labels=_labels(), + maximum_depth=2, + ) + codes = {edge.property_code for edge in neighborhood.edges} + assert PROPERTY_MENTIONS_TEAM in codes + assert PROPERTY_TEAM_AFFILIATED_WITH in codes + assert PROPERTY_MENTIONS_ORGANIZATION in codes + assert PROPERTY_CO_MENTIONED_WITH in codes + + +def test_unknown_kg_edge_and_cutoff_validity_windows() -> None: + with pytest.raises(OntologyNeighborhoodError) as unknown: + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code="edge_invented", + recorded_at=T0, + ) + assert unknown.value.code == "unknown_property" + too_late_start = NeighborhoodFact( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + property_code=PROPERTY_AFFILIATED_WITH, + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + valid_from=T_LATE, + ) + already_ended = NeighborhoodFact( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + property_code=PROPERTY_AFFILIATED_WITH, + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + valid_to=datetime(2026, 1, 1, tzinfo=TZ), + ) + kept = assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[too_late_start, already_ended, _mention_affiliation()[1]], + labels=_labels(), + knowledge_cutoff=CUTOFF, + ) + assert all(edge.valid_from is None for edge in kept.edges) + naive_valid = NeighborhoodFact( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + property_code=PROPERTY_AFFILIATED_WITH, + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + valid_from=datetime(2026, 1, 1), + ) + with pytest.raises(OntologyNeighborhoodError) as naive: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[naive_valid], + labels=_labels(), + ) + assert naive.value.code == "naive_timestamp" + naive_to = NeighborhoodFact( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + property_code=PROPERTY_AFFILIATED_WITH, + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + valid_to=datetime(2026, 2, 1), + ) + with pytest.raises(OntologyNeighborhoodError) as naive_end: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[naive_to], + labels=_labels(), + ) + assert naive_end.value.code == "naive_timestamp" + + +def test_node_bound_truncation_drops_cursor_and_jsonld_rejects_dangling() -> None: + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=_mention_affiliation(), + labels=_labels(), + maximum_depth=2, + maximum_nodes=1, + ) + assert neighborhood.truncated is True + assert neighborhood.next_cursor is None + assert len(neighborhood.nodes) == 1 + node = neighborhood.nodes[0] + assert node.evidence_count == 0 + dangling = OntologyNeighborhood( + focus_node_id=POST_ID, + focus_node_type_code=NODE_POST, + nodes=(node,), + edges=( + OntologyGraphEdge( + edge_id="mentions:node_post:x:node_person:y", + source_node_type_code=NODE_POST, + source_node_id=POST_ID, + target_node_type_code=NODE_PERSON, + target_node_id=PERSON_ID, + property_code=PROPERTY_MENTIONS, + ontology_property_iri=str(LW.mentions), + property_label="mentions", + truth_status_code=TRUTH_OBSERVED, + valid_from=T0, + valid_to=T_LATE, + recorded_at=T0, + provenance_reference="kg", + evidence_references=(POST_ID,), + ), + ), + truncated=False, + next_cursor=None, + limitation_code=None, + ) + with pytest.raises(OntologyNeighborhoodError) as raised: + dangling.jsonld_document() + assert raised.value.code == "dangling_endpoint" + with pytest.raises(OntologyNeighborhoodError) as exact_values: + dangling.exact_value_rows() + assert exact_values.value.code == "dangling_endpoint" + rows = neighborhood.exact_value_rows() + assert rows == () + + +def test_node_bound_truncation_keeps_nearer_hop_over_farther_alphabetically_earlier_type() -> None: + """Trim by BFS distance, not by the raw "type:id" key string. + + Two hop-1 ``node_post`` neighbors and one hop-2 ``node_corporate_entity`` + neighbor straddle ``maximum_nodes``. "node_corporate_entity" sorts + before "node_post" lexicographically, so a key-string trim would keep + the farther corporate entity and drop a nearer post. Distance-based + trimming must keep both nearer posts instead. + """ + post_b_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2" + labels = { + **_labels(), + (NODE_POST, post_b_id): "Second post", + } + mention_a = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code=EDGE_MENTION, + recorded_at=T0, + evidence_references=(POST_ID,), + provenance_reference="kg-edge-mention-a", + ) + mention_b = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_POST, + target_node_id=post_b_id, + edge_type_code=EDGE_MENTION, + recorded_at=T0, + evidence_references=(post_b_id,), + provenance_reference="kg-edge-mention-b", + ) + mention_organization = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_CORPORATE_ENTITY, + source_node_id=CORP_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code=EDGE_MENTION_ORGANIZATION, + recorded_at=T0, + evidence_references=(POST_ID,), + provenance_reference="kg-edge-mention-organization", + ) + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[mention_a, mention_b, mention_organization], + labels=labels, + maximum_depth=2, + maximum_nodes=3, + ) + assert neighborhood.truncated is True + kept = {(node.node_type_code, node.node_id) for node in neighborhood.nodes} + assert kept == { + (NODE_PERSON, PERSON_ID), + (NODE_POST, POST_ID), + (NODE_POST, post_b_id), + } + assert (NODE_CORPORATE_ENTITY, CORP_ID) not in kept + + +def test_malformed_cursor_token_and_owl_alias_fail_closed() -> None: + with pytest.raises(OntologyNeighborhoodError) as owl: + canonicalize_property_code(PROPERTY_OWL_SUBCLASS_OF) + assert owl.value.code == "owl_subclass_not_instance" + with pytest.raises(OntologyNeighborhoodError) as cursor: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=_mention_affiliation(), + labels=_labels(), + cursor="after:not-a-visible-edge", + ) + assert cursor.value.code == "malformed_cursor" + + +def test_unlabeled_focus_fails_closed() -> None: + with pytest.raises(OntologyNeighborhoodError) as unlabeled: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id="missing-post", + facts=[], + labels=_labels(), + ) + assert unlabeled.value.code == "dangling_endpoint" + + +def test_unlabeled_source_is_skipped_and_unknown_fact_node_type_fails_closed() -> None: + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_CORPORATE_ENTITY, + focus_node_id=CORP_ID, + facts=[_mention_affiliation()[1]], + labels={(NODE_CORPORATE_ENTITY, CORP_ID): "Demo Corp"}, + ) + assert neighborhood.edges == () + with pytest.raises(OntologyNeighborhoodError) as node_type: + assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=[ + NeighborhoodFact( + source_node_type_code="node_invented", + source_node_id=PERSON_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + property_code=PROPERTY_MENTIONS, + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + ) + ], + labels=_labels(), + ) + assert node_type.value.code == "unknown_node_type" diff --git a/tests/test_ontology_neighborhood_cutoff_and_ids.py b/tests/test_ontology_neighborhood_cutoff_and_ids.py new file mode 100644 index 000000000..bfebc7d5a --- /dev/null +++ b/tests/test_ontology_neighborhood_cutoff_and_ids.py @@ -0,0 +1,118 @@ +"""Focused regression tests for cutoff-safe and canonical ontology traversal.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Any + +import backend.app.ontology_neighborhood_ingestion as ingestion +from lineageweave.knowledge_graph import NODE_POST + +POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1" +CUTOFF = datetime(2026, 1, 15, 12, 0, tzinfo=timezone.utc) + + +class CapturingConnection: + """Capture the fact-loading SQL and parameters without a database.""" + + def __init__(self) -> None: + self.sql = "" + self.args: tuple[object, ...] = () + + async def fetch(self, sql: str, *args: object) -> list[object]: + """Record one query and return an empty deterministic result.""" + self.sql = " ".join(sql.split()) + self.args = args + return [] + + +def test_load_facts_filters_available_evidence_before_sql_limit() -> None: + """The cutoff must enter SQL before candidate ordering and LIMIT.""" + conn = CapturingConnection() + + facts = asyncio.run( + ingestion._load_facts( + conn, # type: ignore[arg-type] + [POST_ID], + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + maximum_depth=2, + maximum_edges=10, + knowledge_cutoff=CUTOFF, + ) + ) + + assert facts == [] + assert "post.created_at <= $6::timestamptz" in conn.sql + assert "offset" not in conn.sql.lower() + assert CUTOFF in conn.args + + +def test_visible_neighborhood_canonicalizes_uppercase_uuid_before_traversal( + monkeypatch: Any, +) -> None: + """UUID spelling must not change recursive-neighborhood reachability.""" + seen: dict[str, object] = {} + sentinel = object() + + async def fake_focus_exists(_conn: object, node_type: str, node_id: str) -> bool: + seen["exists"] = (node_type, node_id) + return True + + async def fake_visible_posts( + _conn: object, + node_type: str, + node_id: str, + _can_see_post: object, + ) -> list[str]: + seen["visible"] = (node_type, node_id) + return [POST_ID] + + async def fake_load_facts( + _conn: object, + _visible_post_ids: list[str], + **kwargs: object, + ) -> list[object]: + seen["facts"] = kwargs + return [] + + async def fake_empty(*_args: object, **_kwargs: object) -> list[object]: + return [] + + async def fake_labels(*_args: object, **_kwargs: object) -> dict[object, str]: + return {} + + async def fake_metadata(*_args: object, **_kwargs: object) -> dict[object, object]: + return {} + + def fake_assemble(**kwargs: object) -> object: + seen["assemble"] = kwargs + return sentinel + + monkeypatch.setattr(ingestion, "focus_catalog_exists", fake_focus_exists) + monkeypatch.setattr(ingestion, "visible_post_ids_for_focus", fake_visible_posts) + monkeypatch.setattr(ingestion, "_load_facts", fake_load_facts) + monkeypatch.setattr(ingestion, "_load_skos_facts", fake_empty) + monkeypatch.setattr(ingestion, "_load_labels", fake_labels) + monkeypatch.setattr(ingestion, "_load_node_metadata", fake_metadata) + monkeypatch.setattr(ingestion, "assemble_ontology_neighborhood", fake_assemble) + + result = asyncio.run( + ingestion.visible_ontology_neighborhood( + object(), # type: ignore[arg-type] + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID.upper(), + can_see_post=lambda _row: True, + knowledge_cutoff=CUTOFF, + ) + ) + + assert result is sentinel + assert seen["exists"] == (NODE_POST, POST_ID) + assert seen["visible"] == (NODE_POST, POST_ID) + assert isinstance(seen["facts"], dict) + assert seen["facts"]["focus_node_id"] == POST_ID # type: ignore[index] + assert seen["facts"]["knowledge_cutoff"] == CUTOFF # type: ignore[index] + assert isinstance(seen["assemble"], dict) + assert seen["assemble"]["focus_node_id"] == POST_ID # type: ignore[index] diff --git a/tests/test_ontology_neighborhood_ingestion.py b/tests/test_ontology_neighborhood_ingestion.py new file mode 100644 index 000000000..31bd4ae56 --- /dev/null +++ b/tests/test_ontology_neighborhood_ingestion.py @@ -0,0 +1,792 @@ +"""Authorization-gated ontology neighborhood loader (ADR 0168).""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone + +import pytest + +from backend.app.ontology_neighborhood_ingestion import ( + _load_facts, + _load_labels, + _load_node_metadata, + _load_skos_facts, + focus_catalog_exists, + neighborhood_error_detail, + neighborhood_error_http_status, + neighborhood_to_payload, + parse_allowed_property_query, + visible_ontology_neighborhood, + visible_post_ids_for_focus, +) +from lineageweave.knowledge_graph import ( + EDGE_AFFILIATION, + EDGE_MENTION, + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_POST, + NODE_TEAM, +) +from lineageweave.ontology_neighborhood import ( + NeighborhoodFact, + OntologyNeighborhoodError, + OntologyNodeMetadata, + PROPERTY_AFFILIATED_WITH, + TRUTH_OBSERVED, + assemble_ontology_neighborhood, + fact_from_knowledge_graph_edge, +) + +POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1" +PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1" +CORP_ID = "cccccccc-cccc-cccc-cccc-ccccccccccc1" +GROUP_ID = "dddddddd-dddd-dddd-dddd-ddddddddddd1" +TEAM_ID = "ffffffff-ffff-ffff-ffff-fffffffffff1" +T0 = datetime(2026, 1, 10, 12, 0, tzinfo=timezone.utc) + + +class ScriptedConn: + """Minimal asyncpg stand-in keyed by distinctive SQL fragments.""" + + def __init__(self, script: dict[str, object]) -> None: + self.script = script + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + def _match(self, sql: str) -> object | None: + compact = " ".join(sql.split()) + hits = [(key, value) for key, value in self.script.items() if key in compact] + if not hits: + return None + hits.sort(key=lambda item: len(item[0]), reverse=True) + return hits[0][1] + + async def fetch(self, sql: str, *args: object) -> list[object]: + self.calls.append((sql, args)) + value = self._match(sql) + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + async def fetchrow(self, sql: str, *args: object) -> object | None: + self.calls.append((sql, args)) + value = self._match(sql) + if isinstance(value, list): + return value[0] if value else None + return value + + async def fetchval(self, sql: str, *args: object) -> object | None: + row = await self.fetchrow(sql, *args) + if row is None: + return None + if isinstance(row, dict): + return next(iter(row.values())) + return row + + +def test_parse_allowed_property_query_splits_and_drops_empty() -> None: + assert parse_allowed_property_query(None) is None + assert parse_allowed_property_query([" ", ","]) is None + assert parse_allowed_property_query(["mentions, affiliatedWith", " skos_broader "]) == [ + "mentions", + "affiliatedWith", + "skos_broader", + ] + + +def test_neighborhood_error_http_status_is_fail_closed() -> None: + assert neighborhood_error_http_status(OntologyNeighborhoodError("focus_hidden", "x")) == 404 + assert neighborhood_error_http_status(OntologyNeighborhoodError("focus_not_visible", "x")) == 404 + assert neighborhood_error_http_status(OntologyNeighborhoodError("unknown_node_type", "x")) == 404 + assert neighborhood_error_http_status(OntologyNeighborhoodError("dangling_endpoint", "x")) == 404 + assert neighborhood_error_http_status(OntologyNeighborhoodError("invalid_focus_id", "x")) == 422 + assert neighborhood_error_http_status(OntologyNeighborhoodError("unbounded_request", "x")) == 422 + assert neighborhood_error_http_status(OntologyNeighborhoodError("stale_snapshot", "x")) == 422 + + +def test_neighborhood_error_detail_hides_focus_existence() -> None: + for code in ("focus_hidden", "focus_not_visible", "unknown_node_type", "dangling_endpoint"): + assert neighborhood_error_detail(OntologyNeighborhoodError(code, "catalog-secret")) == ( + "focus node is unavailable" + ) + assert neighborhood_error_detail(OntologyNeighborhoodError("invalid_focus_id", "malformed")) == ( + "malformed" + ) + + +def test_focus_catalog_exists_rejects_unknown_and_non_uuid() -> None: + conn = ScriptedConn({}) + assert asyncio.run(focus_catalog_exists(conn, NODE_POST, "not-a-uuid")) is False + with pytest.raises(OntologyNeighborhoodError) as raised: + asyncio.run(focus_catalog_exists(conn, "node_invented", POST_ID)) + assert raised.value.code == "unknown_node_type" + assert asyncio.run( + focus_catalog_exists( + ScriptedConn({"select 1 from source_post": {"ignored": 1}}), NODE_POST, POST_ID + ) + ) + assert asyncio.run( + focus_catalog_exists( + ScriptedConn({"select 1 from cataloged_person": {"ignored": 1}}), NODE_PERSON, PERSON_ID + ) + ) + assert asyncio.run( + focus_catalog_exists( + ScriptedConn({"select 1 from corporate_entity": {"ignored": 1}}), + NODE_CORPORATE_ENTITY, + CORP_ID, + ) + ) + assert asyncio.run( + focus_catalog_exists( + ScriptedConn({"select 1 from cataloged_team": {"ignored": 1}}), NODE_TEAM, TEAM_ID + ) + ) + empty = ScriptedConn({}) + assert asyncio.run(focus_catalog_exists(empty, NODE_PERSON, PERSON_ID)) is False + assert asyncio.run(focus_catalog_exists(empty, NODE_CORPORATE_ENTITY, CORP_ID)) is False + assert asyncio.run(focus_catalog_exists(empty, NODE_TEAM, TEAM_ID)) is False + assert asyncio.run(focus_catalog_exists(empty, NODE_POST, POST_ID)) is False + + +def test_visible_post_ids_for_each_focus_type() -> None: + post_row = {"post_id": POST_ID, "visibility_code": "public", "corporate_entity_id": CORP_ID} + conn = ScriptedConn({"select post_id, visibility_code": post_row}) + assert asyncio.run(visible_post_ids_for_focus(conn, NODE_POST, POST_ID, lambda row: True)) == [POST_ID] + assert asyncio.run(visible_post_ids_for_focus(conn, NODE_POST, POST_ID, lambda row: False)) == [] + assert asyncio.run(visible_post_ids_for_focus(ScriptedConn({}), NODE_POST, POST_ID, lambda row: True)) == [] + assert asyncio.run( + visible_post_ids_for_focus( + ScriptedConn({"combined_post_person_mention": [post_row]}), + NODE_PERSON, + PERSON_ID, + lambda row: True, + ) + ) == [POST_ID] + assert asyncio.run( + visible_post_ids_for_focus( + ScriptedConn({"person_affiliation affiliation": [post_row]}), + NODE_CORPORATE_ENTITY, + CORP_ID, + lambda row: True, + ) + ) == [POST_ID] + assert asyncio.run( + visible_post_ids_for_focus( + ScriptedConn({"post_team_mention": [post_row]}), + NODE_TEAM, + TEAM_ID, + lambda row: True, + ) + ) == [POST_ID] + with pytest.raises(OntologyNeighborhoodError) as raised: + asyncio.run(visible_post_ids_for_focus(conn, "node_invented", POST_ID, lambda row: True)) + assert raised.value.code == "unknown_node_type" + + +def test_load_facts_skos_and_labels() -> None: + assert asyncio.run(_load_facts(ScriptedConn({}), [])) == [] + assert asyncio.run(_load_skos_facts(ScriptedConn({}), [])) == [] + conn = ScriptedConn( + { + "from knowledge_graph_edge edge": [ + { + "source_node_type_code": NODE_PERSON, + "source_node_id": PERSON_ID, + "target_node_type_code": NODE_POST, + "target_node_id": POST_ID, + "edge_type_code": EDGE_MENTION, + "available_at": T0, + "evidence_ids": [POST_ID], + }, + { + "source_node_type_code": NODE_PERSON, + "source_node_id": PERSON_ID, + "target_node_type_code": NODE_CORPORATE_ENTITY, + "target_node_id": CORP_ID, + "edge_type_code": EDGE_AFFILIATION, + "available_at": T0, + "evidence_ids": None, + }, + ] + } + ) + facts = asyncio.run(_load_facts(conn, [POST_ID])) + assert "with recursive" in conn.calls[0][0].lower() + assert facts[0].property_code == "mentions" + assert facts[0].source_node_id == POST_ID + assert facts[1].evidence_references == () + skos = asyncio.run( + _load_skos_facts( + ScriptedConn( + { + "from corporate_entity": [ + { + "corporate_entity_id": CORP_ID, + "parent_entity_id": GROUP_ID, + "created_at": T0, + } + ] + } + ), + [CORP_ID], + ) + ) + assert skos[0].target_node_id == GROUP_ID + mention = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code=EDGE_MENTION, + recorded_at=T0, + evidence_references=(POST_ID,), + ) + team_fact = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_TEAM, + source_node_id=TEAM_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code="edge_mention_team", + recorded_at=T0, + ) + affiliation = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_AFFILIATION, + recorded_at=T0, + ) + labels = asyncio.run( + _load_labels( + ScriptedConn( + { + "from cataloged_person": [{"person_id": PERSON_ID, "person_name": "Test Person"}], + "from source_post where post_id = any": [ + {"post_id": POST_ID, "post_title": "Demo public post"} + ], + "from corporate_entity": [ + {"corporate_entity_id": CORP_ID, "entity_name": "Demo Corp"} + ], + "from cataloged_team": [{"team_id": TEAM_ID, "team_name": "Demo Team"}], + } + ), + [mention, team_fact, affiliation], + ) + ) + assert labels[(NODE_PERSON, PERSON_ID)] == "Test Person" + assert labels[(NODE_POST, POST_ID)] == "Demo public post" + assert labels[(NODE_CORPORATE_ENTITY, CORP_ID)] == "Demo Corp" + assert labels[(NODE_TEAM, TEAM_ID)] == "Demo Team" + empty_labels = asyncio.run(_load_labels(ScriptedConn({}), [])) + assert empty_labels == {} + + +def test_load_facts_does_not_report_exact_hard_cap_as_truncated(monkeypatch) -> None: + """Fetch one lookahead row even when the response is at its hard cap.""" + + monkeypatch.setattr( + "backend.app.ontology_neighborhood_ingestion.HARD_MAXIMUM_EDGES", 2 + ) + rows = [ + { + "source_node_type_code": NODE_PERSON, + "source_node_id": PERSON_ID, + "target_node_type_code": NODE_POST, + "target_node_id": POST_ID, + "edge_type_code": EDGE_MENTION, + "available_at": T0, + "evidence_ids": [POST_ID], + }, + { + "source_node_type_code": NODE_PERSON, + "source_node_id": PERSON_ID, + "target_node_type_code": NODE_CORPORATE_ENTITY, + "target_node_id": CORP_ID, + "edge_type_code": EDGE_AFFILIATION, + "available_at": T0, + "evidence_ids": [POST_ID], + }, + ] + conn = ScriptedConn({"from knowledge_graph_edge edge": rows}) + + window = asyncio.run(_load_facts(conn, [POST_ID], maximum_edges=2)) + + assert len(window) == 2 + assert window.truncated is False + assert conn.calls[0][1][4] == 3 + + +def test_null_labels_are_not_returned_and_catalog_metadata_is_optional() -> None: + fact = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code=EDGE_MENTION, + recorded_at=T0, + ) + labels = asyncio.run( + _load_labels( + ScriptedConn( + { + "from cataloged_person": [{"person_id": PERSON_ID, "person_name": None}], + "from source_post where post_id": [{"post_id": POST_ID, "post_title": None}], + } + ), + [fact], + ) + ) + assert labels == {} + affiliation = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_AFFILIATION, + recorded_at=T0, + ) + team = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_TEAM, + source_node_id=TEAM_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code="edge_mention_team", + recorded_at=T0, + ) + assert asyncio.run( + _load_labels( + ScriptedConn( + { + "from corporate_entity": [{"corporate_entity_id": CORP_ID, "entity_name": None}], + "from cataloged_team": [{"team_id": TEAM_ID, "team_name": None}], + } + ), + [affiliation, team], + ) + ) == {} + metadata = asyncio.run( + _load_node_metadata( + ScriptedConn( + { + "select person_id, created_at": [{"person_id": PERSON_ID, "created_at": T0}], + "select post_id, created_at": [{"post_id": POST_ID, "created_at": T0}], + } + ), + [fact], + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + ) + ) + assert metadata[(NODE_PERSON, PERSON_ID)].recorded_at == T0 + assert metadata[(NODE_PERSON, PERSON_ID)].truth_status_code is None + + +def test_visible_ontology_neighborhood_round_trips_and_payload() -> None: + conn = ScriptedConn( + { + "select 1 from source_post": {"ignored": 1}, + "select post_id, visibility_code": { + "post_id": POST_ID, + "visibility_code": "visibility_public", + "corporate_entity_id": CORP_ID, + }, + "select post.post_id, post.visibility_code": [ + { + "post_id": POST_ID, + "visibility_code": "visibility_public", + "corporate_entity_id": CORP_ID, + } + ], + "select distinct post.post_id": [ + { + "post_id": POST_ID, + "visibility_code": "visibility_public", + "corporate_entity_id": CORP_ID, + } + ], + "knowledge_graph_edge": [ + { + "source_node_type_code": NODE_PERSON, + "source_node_id": PERSON_ID, + "target_node_type_code": NODE_POST, + "target_node_id": POST_ID, + "edge_type_code": EDGE_MENTION, + "available_at": T0, + "evidence_ids": [POST_ID], + }, + { + "source_node_type_code": NODE_PERSON, + "source_node_id": PERSON_ID, + "target_node_type_code": NODE_CORPORATE_ENTITY, + "target_node_id": CORP_ID, + "edge_type_code": EDGE_AFFILIATION, + "available_at": T0, + "evidence_ids": [POST_ID], + }, + ], + "select person_id, person_name": [{"person_id": PERSON_ID, "person_name": "Test Person"}], + "select post_id, post_title": [ + {"post_id": POST_ID, "post_title": "Demo public post"} + ], + "select corporate_entity_id, entity_name": [ + {"corporate_entity_id": CORP_ID, "entity_name": "Demo Corp"} + ], + "select post_title from source_post": "Demo public post", + } + ) + neighborhood = asyncio.run( + visible_ontology_neighborhood( + conn, + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + can_see_post=lambda row: True, + maximum_depth=2, + ) + ) + payload = neighborhood_to_payload(neighborhood) + assert payload["focus_node_id"] == POST_ID + assert payload["truncated"] is False + properties = {edge["property_code"] for edge in payload["edges"]} + assert "mentions" in properties + assert "affiliatedWith" in properties + assert payload["edges"][0]["source_node_type_code"] + assert payload["edges"][0]["target_node_type_code"] + assert payload["jsonld"]["@graph"] + assert payload["exact_value_rows"] + assert all(node["shape_code"] for node in payload["nodes"]) + + +def test_visible_neighborhood_drops_unlabeled_non_focus_edges() -> None: + neighborhood = asyncio.run( + visible_ontology_neighborhood( + ScriptedConn( + { + "select 1 from source_post": {"ignored": 1}, + "select post_id, visibility_code": { + "post_id": POST_ID, + "visibility_code": "visibility_public", + "corporate_entity_id": CORP_ID, + }, + "knowledge_graph_edge": [ + { + "source_node_type_code": NODE_PERSON, + "source_node_id": PERSON_ID, + "target_node_type_code": NODE_POST, + "target_node_id": POST_ID, + "edge_type_code": EDGE_MENTION, + "available_at": T0, + "evidence_ids": [POST_ID], + } + ], + "select person_id, person_name": [], + "combined_post_person_mention": [], + "select post_id, post_title": [ + {"post_id": POST_ID, "post_title": "Demo public post"} + ], + "select post_title from source_post": "Demo public post", + } + ), + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + can_see_post=lambda row: True, + ) + ) + assert neighborhood.focus_node_id == POST_ID + assert neighborhood.edges == () + assert neighborhood.limitation_code == "neighborhood_empty" + + +def test_visible_neighborhood_focus_variants_and_fail_closed() -> None: + with pytest.raises(OntologyNeighborhoodError) as invalid: + asyncio.run( + visible_ontology_neighborhood( + ScriptedConn({}), + focus_node_type_code=NODE_POST, + focus_node_id=" ", + can_see_post=lambda row: True, + ) + ) + assert invalid.value.code == "invalid_focus_id" + with pytest.raises(OntologyNeighborhoodError) as unknown: + asyncio.run( + visible_ontology_neighborhood( + ScriptedConn({}), + focus_node_type_code="node_invented", + focus_node_id=POST_ID, + can_see_post=lambda row: True, + ) + ) + assert unknown.value.code == "unknown_node_type" + with pytest.raises(OntologyNeighborhoodError) as missing: + asyncio.run( + visible_ontology_neighborhood( + ScriptedConn({}), + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + can_see_post=lambda row: True, + ) + ) + assert missing.value.code == "unknown_node_type" + with pytest.raises(OntologyNeighborhoodError) as forbidden: + asyncio.run( + visible_ontology_neighborhood( + ScriptedConn({"select 1 from source_post": {"ignored": 1}}), + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + can_see_post=lambda row: False, + ) + ) + assert forbidden.value.code == "focus_not_visible" + + person_neighborhood = asyncio.run( + visible_ontology_neighborhood( + ScriptedConn( + { + "select 1 from cataloged_person": {"ignored": 1}, + "combined_post_person_mention": [ + { + "post_id": POST_ID, + "visibility_code": "public", + "corporate_entity_id": CORP_ID, + } + ], + "select person_name from cataloged_person": "Test Person", + } + ), + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + can_see_post=lambda row: True, + ) + ) + assert person_neighborhood.focus_node_id == PERSON_ID + assert person_neighborhood.limitation_code == "neighborhood_empty" + + corp_neighborhood = asyncio.run( + visible_ontology_neighborhood( + ScriptedConn( + { + "select 1 from corporate_entity": {"ignored": 1}, + "person_affiliation affiliation": [ + { + "post_id": POST_ID, + "visibility_code": "public", + "corporate_entity_id": CORP_ID, + } + ], + "parent_entity_id": [ + { + "corporate_entity_id": CORP_ID, + "parent_entity_id": GROUP_ID, + "created_at": T0, + } + ], + "select corporate_entity_id, entity_name": [ + {"corporate_entity_id": CORP_ID, "entity_name": "Demo Corp"}, + {"corporate_entity_id": GROUP_ID, "entity_name": "Demo Group"}, + ], + "select entity_name from corporate_entity": "Demo Corp", + } + ), + focus_node_type_code=NODE_CORPORATE_ENTITY, + focus_node_id=CORP_ID, + can_see_post=lambda row: True, + ) + ) + assert corp_neighborhood.focus_node_type_code == NODE_CORPORATE_ENTITY + + team_neighborhood = asyncio.run( + visible_ontology_neighborhood( + ScriptedConn( + { + "select 1 from cataloged_team": {"ignored": 1}, + "post_team_mention": [ + { + "post_id": POST_ID, + "visibility_code": "public", + "corporate_entity_id": CORP_ID, + } + ], + "select team_name from cataloged_team": "Demo Team", + } + ), + focus_node_type_code=NODE_TEAM, + focus_node_id=TEAM_ID, + can_see_post=lambda row: True, + ) + ) + assert team_neighborhood.nodes[0].display_label == "Demo Team" + + +def test_hidden_non_focus_node_is_removed_before_label_loading() -> None: + conn = ScriptedConn( + { + "select 1 from source_post": {"ignored": 1}, + "select post_id, visibility_code": { + "post_id": POST_ID, + "visibility_code": "visibility_public", + "corporate_entity_id": CORP_ID, + }, + "select post.post_id, post.visibility_code": [], + "knowledge_graph_edge": [ + { + "source_node_type_code": NODE_PERSON, + "source_node_id": PERSON_ID, + "target_node_type_code": NODE_POST, + "target_node_id": POST_ID, + "edge_type_code": EDGE_MENTION, + "available_at": T0, + "evidence_ids": [POST_ID], + } + ], + "select post_title from source_post": "Demo public post", + } + ) + neighborhood = asyncio.run( + visible_ontology_neighborhood( + conn, + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + can_see_post=lambda row: True, + ) + ) + assert neighborhood.edges == () + assert [node.node_id for node in neighborhood.nodes] == [POST_ID] + + +def test_focus_label_fetch_may_be_empty_when_facts_already_labeled() -> None: + mention_row = { + "source_node_type_code": NODE_PERSON, + "source_node_id": PERSON_ID, + "target_node_type_code": NODE_POST, + "target_node_id": POST_ID, + "edge_type_code": EDGE_MENTION, + "available_at": T0, + "evidence_ids": [POST_ID], + } + affiliation_row = { + "source_node_type_code": NODE_PERSON, + "source_node_id": PERSON_ID, + "target_node_type_code": NODE_CORPORATE_ENTITY, + "target_node_id": CORP_ID, + "edge_type_code": EDGE_AFFILIATION, + "available_at": T0, + "evidence_ids": [POST_ID], + } + team_row = { + "source_node_type_code": NODE_TEAM, + "source_node_id": TEAM_ID, + "target_node_type_code": NODE_POST, + "target_node_id": POST_ID, + "edge_type_code": "edge_mention_team", + "available_at": T0, + "evidence_ids": [POST_ID], + } + post_row = {"post_id": POST_ID, "visibility_code": "public", "corporate_entity_id": CORP_ID} + shared_labels = { + "select person_id, person_name": [{"person_id": PERSON_ID, "person_name": "Test Person"}], + "select post_id, post_title": [{"post_id": POST_ID, "post_title": "Demo public post"}], + "select corporate_entity_id, entity_name": [ + {"corporate_entity_id": CORP_ID, "entity_name": "Demo Corp"} + ], + "select team_id, team_name": [{"team_id": TEAM_ID, "team_name": "Demo Team"}], + "select post_title from source_post": None, + "select person_name from cataloged_person": None, + "select entity_name from corporate_entity": None, + "select team_name from cataloged_team": None, + "knowledge_graph_edge": [mention_row, affiliation_row, team_row], + "select post_id, visibility_code": post_row, + "select post.post_id, post.visibility_code": [post_row], + "combined_post_person_mention": [post_row], + "person_affiliation affiliation": [post_row], + "post_team_mention": [post_row], + } + post_neighborhood = asyncio.run( + visible_ontology_neighborhood( + ScriptedConn({**shared_labels, "select 1 from source_post": {"ignored": 1}}), + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + can_see_post=lambda row: True, + ) + ) + assert post_neighborhood.nodes[0].display_label == "Demo public post" + person_neighborhood = asyncio.run( + visible_ontology_neighborhood( + ScriptedConn({**shared_labels, "select 1 from cataloged_person": {"ignored": 1}}), + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + can_see_post=lambda row: True, + ) + ) + assert person_neighborhood.focus_node_id == PERSON_ID + corp_neighborhood = asyncio.run( + visible_ontology_neighborhood( + ScriptedConn({**shared_labels, "select 1 from corporate_entity": {"ignored": 1}}), + focus_node_type_code=NODE_CORPORATE_ENTITY, + focus_node_id=CORP_ID, + can_see_post=lambda row: True, + ) + ) + assert corp_neighborhood.focus_node_id == CORP_ID + team_neighborhood = asyncio.run( + visible_ontology_neighborhood( + ScriptedConn({**shared_labels, "select 1 from cataloged_team": {"ignored": 1}}), + focus_node_type_code=NODE_TEAM, + focus_node_id=TEAM_ID, + can_see_post=lambda row: True, + ) + ) + assert team_neighborhood.focus_node_id == TEAM_ID + + +def test_load_labels_ignores_unknown_node_types() -> None: + unknown = NeighborhoodFact( + source_node_type_code="node_invented", + source_node_id=PERSON_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + property_code=PROPERTY_AFFILIATED_WITH, + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + ) + labels = asyncio.run(_load_labels(ScriptedConn({}), [unknown])) + assert labels == {} + + +def test_payload_serializes_optional_validity() -> None: + fact = NeighborhoodFact( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + property_code=PROPERTY_AFFILIATED_WITH, + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + valid_from=T0, + valid_to=T0, + evidence_references=(POST_ID,), + provenance_reference="knowledge_graph_edge", + ) + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[fact], + labels={ + (NODE_PERSON, PERSON_ID): "Test Person", + (NODE_CORPORATE_ENTITY, CORP_ID): "Demo Corp", + }, + node_metadata={ + (NODE_PERSON, PERSON_ID): OntologyNodeMetadata( + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + ) + }, + ) + payload = neighborhood_to_payload(neighborhood) + assert payload["edges"][0]["valid_from"] is not None + assert payload["edges"][0]["valid_to"] is not None + assert payload["nodes"][0]["truth_status_code"] == TRUTH_OBSERVED + assert payload["nodes"][0]["valid_from"] is None diff --git a/tests/test_ontology_neighborhood_visibility_batch.py b/tests/test_ontology_neighborhood_visibility_batch.py new file mode 100644 index 000000000..1d16882ae --- /dev/null +++ b/tests/test_ontology_neighborhood_visibility_batch.py @@ -0,0 +1,306 @@ +"""Batched endpoint-visibility and depth-expansion regressions.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Any + +import backend.app.ontology_neighborhood_ingestion as ingestion +from lineageweave.knowledge_graph import ( + EDGE_AFFILIATION, + EDGE_MENTION, + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_POST, + NODE_TEAM, +) +from lineageweave.ontology_neighborhood import ( + PROPERTY_MENTIONS_TEAM, + TRUTH_OBSERVED, + NeighborhoodFact, + fact_from_knowledge_graph_edge, + skos_broader_fact, +) + +POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1" +SECOND_POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2" +THIRD_POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3" +PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1" +CORP_ID = "cccccccc-cccc-cccc-cccc-ccccccccccc1" +TEAM_ID = "dddddddd-dddd-dddd-dddd-ddddddddddd1" +T0 = datetime(2026, 1, 10, 12, 0, tzinfo=timezone.utc) + + +class BatchConnection: + """Return deterministic visibility rows for four node categories.""" + + def __init__(self) -> None: + self.fetch_calls: list[str] = [] + self.fetch_ids: list[object] = [] + + async def fetch(self, sql: str, ids: object = None, *_args: object) -> list[RecordLikeRow]: + """Return a row keyed by the SQL category marker and bound ids.""" + normalized = " ".join(sql.split()) + self.fetch_calls.append(normalized) + self.fetch_ids.append(ids) + if "from source_post post" in normalized and "post.post_id as node_id" in normalized: + assert ids == [POST_ID] + return [_row(POST_ID, POST_ID)] + if "from combined_post_person_mention mention" in normalized: + assert ids == [PERSON_ID] + return [_row(PERSON_ID, SECOND_POST_ID)] + if "from post_team_mention mention" in normalized: + assert ids == [TEAM_ID] + return [_row(TEAM_ID, SECOND_POST_ID)] + if "affiliation.affiliated_corporate_entity_id as node_id" in normalized: + assert ids == [CORP_ID] + return [_row(CORP_ID, SECOND_POST_ID)] + raise AssertionError(f"unexpected batch query: {normalized}") + + +class RecordLikeRow: + """Model asyncpg.Record's keyed access without inheriting from dict.""" + + def __init__(self, values: dict[str, object]) -> None: + self._values = values + + def __getitem__(self, key: str) -> object: + return self._values[key] + + +def _row(node_id: str, post_id: str) -> RecordLikeRow: + return RecordLikeRow( + { + "node_id": node_id, + "post_id": post_id, + "visibility_code": "public", + "corporate_entity_id": None, + "created_at": T0, + } + ) + + +def test_endpoint_visibility_is_batched_by_node_type() -> None: + """A wide neighborhood must not issue one query per endpoint.""" + conn = BatchConnection() + keys = { + (NODE_POST, POST_ID), + (NODE_PERSON, PERSON_ID), + (NODE_CORPORATE_ENTITY, CORP_ID), + (NODE_TEAM, TEAM_ID), + } + + visible = asyncio.run( + ingestion._visible_post_ids_by_nodes( # type: ignore[attr-defined] + conn, # type: ignore[arg-type] + keys, + lambda row: row["visibility_code"] == "public", + ) + ) + + assert len(conn.fetch_calls) == 4 + assert visible[(NODE_POST, POST_ID)] == [POST_ID] + assert visible[(NODE_PERSON, PERSON_ID)] == [SECOND_POST_ID] + assert visible[(NODE_CORPORATE_ENTITY, CORP_ID)] == [SECOND_POST_ID] + assert visible[(NODE_TEAM, TEAM_ID)] == [SECOND_POST_ID] + assert conn.fetch_ids == [[POST_ID], [PERSON_ID], [CORP_ID], [TEAM_ID]] + + +def test_visible_neighbor_evidence_keeps_final_depth_endpoint_authorized(monkeypatch: Any) -> None: + """A final expansion cannot silently drop a newly discovered endpoint.""" + mention = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code=EDGE_MENTION, + recorded_at=T0, + evidence_references=(POST_ID,), + ) + affiliation = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_AFFILIATION, + recorded_at=T0, + evidence_references=(SECOND_POST_ID,), + ) + final_depth_fact = NeighborhoodFact( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_TEAM, + target_node_id=TEAM_ID, + property_code=PROPERTY_MENTIONS_TEAM, + truth_status_code=TRUTH_OBSERVED, + recorded_at=T0, + evidence_references=(THIRD_POST_ID,), + ) + fact_calls: list[tuple[str, ...]] = [] + + async def fake_exists(*_args: object) -> bool: + return True + + async def fake_focus_posts(*_args: object) -> list[str]: + return [POST_ID] + + async def fake_load_facts( + _conn: object, + post_ids: list[str], + **_kwargs: object, + ) -> ingestion._LoadedFactWindow: # type: ignore[attr-defined] + fact_calls.append(tuple(post_ids)) + facts = [mention] + if SECOND_POST_ID in post_ids: + facts.append(affiliation) + if THIRD_POST_ID in post_ids: + facts.append(final_depth_fact) + return ingestion._LoadedFactWindow(facts) # type: ignore[attr-defined] + + async def fake_visible_nodes( + _conn: object, + keys: set[tuple[str, str]], + _can_see_post: object, + ) -> dict[tuple[str, str], list[str]]: + return { + key: [THIRD_POST_ID] + if key in { + (NODE_CORPORATE_ENTITY, CORP_ID), + (NODE_TEAM, TEAM_ID), + } + else [SECOND_POST_ID] + if key == (NODE_PERSON, PERSON_ID) + else [POST_ID] + for key in keys + } + + async def fake_no_skos(*_args: object) -> list[object]: + return [] + + async def fake_labels(*_args: object) -> dict[tuple[str, str], str]: + return { + (NODE_POST, POST_ID): "Focus", + (NODE_PERSON, PERSON_ID): "Person", + (NODE_CORPORATE_ENTITY, CORP_ID): "Organization", + (NODE_TEAM, TEAM_ID): "Team", + } + + async def fake_metadata(*_args: object, **_kwargs: object) -> dict[object, object]: + return {} + + class FocusConnection: + async def fetchval(self, _sql: str, *_args: object) -> str: + return "Focus" + + monkeypatch.setattr(ingestion, "focus_catalog_exists", fake_exists) + monkeypatch.setattr(ingestion, "visible_post_ids_for_focus", fake_focus_posts) + monkeypatch.setattr(ingestion, "_load_facts", fake_load_facts) + monkeypatch.setattr(ingestion, "_visible_post_ids_by_nodes", fake_visible_nodes, raising=False) + monkeypatch.setattr(ingestion, "_load_skos_facts", fake_no_skos) + monkeypatch.setattr(ingestion, "_load_labels", fake_labels) + monkeypatch.setattr(ingestion, "_load_node_metadata", fake_metadata) + + neighborhood = asyncio.run( + ingestion.visible_ontology_neighborhood( + FocusConnection(), # type: ignore[arg-type] + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + can_see_post=lambda _row: True, + maximum_depth=2, + ) + ) + + assert fact_calls == [ + (POST_ID,), + (POST_ID, SECOND_POST_ID), + (POST_ID, SECOND_POST_ID, THIRD_POST_ID), + ] + assert {edge.property_code for edge in neighborhood.edges} == { + "mentions", + "affiliatedWith", + PROPERTY_MENTIONS_TEAM, + } + + +GROUP_ID = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeee1" + + +def test_skos_parent_requires_own_visible_post_evidence(monkeypatch: Any) -> None: + """A visible child must not reveal a parent that has no authorized post.""" + affiliation = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_AFFILIATION, + recorded_at=T0, + evidence_references=(POST_ID,), + ) + parent_queries: list[set[tuple[str, str]]] = [] + + async def fake_exists(*_args: object) -> bool: + return True + + async def fake_focus_posts(*_args: object) -> list[str]: + return [POST_ID] + + async def fake_load_facts(*_args: object, **_kwargs: object) -> ingestion._LoadedFactWindow: + return ingestion._LoadedFactWindow([affiliation]) # type: ignore[attr-defined] + + async def fake_visible_nodes( + _conn: object, + keys: set[tuple[str, str]], + _can_see_post: object, + ) -> dict[tuple[str, str], list[str]]: + parent_queries.append(set(keys)) + return { + key: ([POST_ID] if key != (NODE_CORPORATE_ENTITY, GROUP_ID) else []) + for key in keys + } + + async def fake_skos(*_args: object) -> list[object]: + return [ + skos_broader_fact( + narrower_entity_id=CORP_ID, + broader_entity_id=GROUP_ID, + recorded_at=T0, + ) + ] + + async def fake_labels(*_args: object) -> dict[tuple[str, str], str]: + return { + (NODE_POST, POST_ID): "Focus", + (NODE_PERSON, PERSON_ID): "Person", + (NODE_CORPORATE_ENTITY, CORP_ID): "Plant", + (NODE_CORPORATE_ENTITY, GROUP_ID): "Hidden Group", + } + + async def fake_metadata(*_args: object, **_kwargs: object) -> dict[object, object]: + return {} + + class FocusConnection: + async def fetchval(self, _sql: str, *_args: object) -> str: + return "Plant" + + monkeypatch.setattr(ingestion, "focus_catalog_exists", fake_exists) + monkeypatch.setattr(ingestion, "visible_post_ids_for_focus", fake_focus_posts) + monkeypatch.setattr(ingestion, "_load_facts", fake_load_facts) + monkeypatch.setattr(ingestion, "_visible_post_ids_by_nodes", fake_visible_nodes, raising=False) + monkeypatch.setattr(ingestion, "_load_skos_facts", fake_skos) + monkeypatch.setattr(ingestion, "_load_labels", fake_labels) + monkeypatch.setattr(ingestion, "_load_node_metadata", fake_metadata) + + neighborhood = asyncio.run( + ingestion.visible_ontology_neighborhood( + FocusConnection(), # type: ignore[arg-type] + focus_node_type_code=NODE_CORPORATE_ENTITY, + focus_node_id=CORP_ID, + can_see_post=lambda _row: True, + maximum_depth=2, + ) + ) + + assert any((NODE_CORPORATE_ENTITY, GROUP_ID) in keys for keys in parent_queries) + assert all(edge.property_code != "skos_broader" for edge in neighborhood.edges) + assert all(node.node_id != GROUP_ID for node in neighborhood.nodes) diff --git a/tests/test_ontology_neighborhood_windowing.py b/tests/test_ontology_neighborhood_windowing.py new file mode 100644 index 000000000..1409cda59 --- /dev/null +++ b/tests/test_ontology_neighborhood_windowing.py @@ -0,0 +1,592 @@ +"""Bounded-window and proximity-first ontology pagination regressions.""" + +from __future__ import annotations + +import asyncio +from dataclasses import replace +from datetime import datetime, timezone + +from backend.app import ontology_neighborhood_ingestion as ingestion +from backend.app.ontology_neighborhood_ingestion import _load_facts +from lineageweave.knowledge_graph import ( + EDGE_AFFILIATION, + EDGE_MENTION, + NODE_CORPORATE_ENTITY, + NODE_PERSON, + NODE_POST, +) +from lineageweave.ontology_neighborhood import ( + PROPERTY_AFFILIATED_WITH, + PROPERTY_MENTIONS, + assemble_ontology_neighborhood, + fact_from_knowledge_graph_edge, + skos_broader_fact, +) +from lineageweave.ontology_source_cursor import OntologySourceCursor, OntologySourceKey + +POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1" +PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1" +CORP_ID = "cccccccc-cccc-cccc-cccc-ccccccccccc1" +GROUP_ID = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeee1" +T0 = datetime(2026, 1, 10, 12, 0, tzinfo=timezone.utc) + + +class WindowConnection: + """Return one more row than the requested bounded fact window.""" + + async def fetch(self, _sql: str, *_args: object) -> list[dict[str, object]]: + """Return four deterministic rows for a three-row window.""" + return [ + { + "source_node_type_code": NODE_POST, + "source_node_id": POST_ID, + "target_node_type_code": NODE_PERSON, + "target_node_id": f"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb{index}", + "edge_type_code": EDGE_MENTION, + "available_at": T0, + "evidence_ids": [POST_ID], + } + for index in range(1, 5) + ] + + +def test_fact_window_reports_source_truncation_without_exceeding_bound() -> None: + """One look-ahead row must expose a bounded-window limitation.""" + window = asyncio.run( + _load_facts( + WindowConnection(), # type: ignore[arg-type] + [POST_ID], + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + maximum_depth=1, + maximum_edges=1, + ) + ) + + assert len(window) == 1 + assert window.truncated is True + + +def test_source_window_truncation_remains_visible_without_a_fake_cursor() -> None: + """A bounded SQL window must never be reported as a complete graph.""" + fact = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_POST, + source_node_id=POST_ID, + target_node_type_code=NODE_PERSON, + target_node_id=PERSON_ID, + edge_type_code=EDGE_MENTION, + recorded_at=T0, + evidence_references=(POST_ID,), + ) + + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=[fact], + labels={(NODE_POST, POST_ID): "Focus", (NODE_PERSON, PERSON_ID): "Person"}, + maximum_edges=10, + source_truncated=True, + ) + + assert neighborhood.truncated is True + assert neighborhood.next_cursor is None + + +def test_first_page_preserves_breadth_first_proximity_before_property_sort() -> None: + """A one-edge first page must stay connected to the focus node.""" + focus_edge = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_POST, + source_node_id=POST_ID, + target_node_type_code=NODE_PERSON, + target_node_id=PERSON_ID, + edge_type_code=EDGE_MENTION, + recorded_at=T0, + ) + second_hop = fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_AFFILIATION, + recorded_at=T0, + ) + + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=[second_hop, focus_edge], + labels={ + (NODE_POST, POST_ID): "Focus", + (NODE_PERSON, PERSON_ID): "Person", + (NODE_CORPORATE_ENTITY, CORP_ID): "Organization", + }, + maximum_depth=2, + maximum_edges=1, + ) + + assert len(neighborhood.edges) == 1 + assert neighborhood.edges[0].property_code == PROPERTY_MENTIONS + assert neighborhood.edges[0].property_code != PROPERTY_AFFILIATED_WITH + + +def test_source_page_keeps_sql_reachable_edges_without_focus_bridge() -> None: + """A later source page may contain only a relation beyond the focus edge.""" + second_hop = replace( + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_AFFILIATION, + recorded_at=T0, + ), + source_hop_depth=1, + ) + + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=[second_hop], + labels={ + (NODE_POST, POST_ID): "Focus", + (NODE_PERSON, PERSON_ID): "Person", + (NODE_CORPORATE_ENTITY, CORP_ID): "Organization", + }, + maximum_depth=2, + maximum_edges=1, + source_truncated=True, + ) + + assert len(neighborhood.edges) == 1 + assert neighborhood.edges[0].property_code == PROPERTY_AFFILIATED_WITH + + +def test_source_page_deduplicates_edges_and_unions_evidence() -> None: + """Window expansion must not duplicate one relation or drop evidence.""" + first_evidence = replace( + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_AFFILIATION, + recorded_at=T0, + evidence_references=(POST_ID,), + ), + source_hop_depth=1, + ) + second_evidence = replace(first_evidence, evidence_references=("other-evidence",)) + + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=[first_evidence, second_evidence], + labels={ + (NODE_POST, POST_ID): "Focus", + (NODE_PERSON, PERSON_ID): "Person", + (NODE_CORPORATE_ENTITY, CORP_ID): "Organization", + }, + maximum_depth=2, + maximum_edges=2, + source_truncated=True, + ) + + assert len(neighborhood.edges) == 1 + assert neighborhood.edges[0].evidence_references == (POST_ID, "other-evidence") + + +def test_source_page_uses_sql_order_when_display_order_differs() -> None: + """Source paging must select and continue in the sealed SQL order.""" + first_in_sql = replace( + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code=EDGE_MENTION, + recorded_at=T0, + ), + source_hop_depth=0, + source_order_key=(0, EDGE_MENTION, NODE_PERSON, "z", NODE_POST, POST_ID), + ) + second_in_sql = replace( + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2", + target_node_type_code=NODE_POST, + target_node_id=POST_ID, + edge_type_code=EDGE_MENTION, + recorded_at=T0, + ), + source_hop_depth=0, + source_order_key=(0, EDGE_MENTION, NODE_PERSON, "a", NODE_POST, POST_ID), + ) + + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + facts=[second_in_sql, first_in_sql], + labels={ + (NODE_POST, POST_ID): "Focus", + (NODE_PERSON, PERSON_ID): "Person A", + (NODE_PERSON, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2"): "Person Z", + }, + maximum_edges=1, + source_truncated=True, + ) + + assert len(neighborhood.edges) == 1 + assert neighborhood.edges[0].target_node_id == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2" + + +class CapturingWindowConnection: + """Record the keyset query without a live PostgreSQL instance.""" + + def __init__(self) -> None: + self.query = "" + self.arguments: tuple[object, ...] = () + + async def fetch(self, query: str, *arguments: object) -> list[object]: + """Capture SQL and return no rows.""" + self.query = query + self.arguments = arguments + return [] + + +def test_load_facts_uses_keyset_not_offset() -> None: + """Source continuation must resume after the last SQL key, never OFFSET.""" + from lineageweave.ontology_source_cursor import OntologySourceKey + + conn = CapturingWindowConnection() + after = OntologySourceKey( + hop_depth=0, + edge_type_code=EDGE_MENTION, + source_node_type_code=NODE_POST, + source_node_id=POST_ID, + target_node_type_code=NODE_PERSON, + target_node_id=PERSON_ID, + ) + window = asyncio.run( + _load_facts( + conn, # type: ignore[arg-type] + [POST_ID], + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + maximum_depth=1, + maximum_edges=1, + after_key=after, + ) + ) + normalized = " ".join(conn.query.lower().split()) + assert "offset" not in normalized + assert "$8::integer is null" in normalized + assert conn.arguments[7] == 0 + assert conn.arguments[8] == EDGE_MENTION + assert conn.arguments[10] == POST_ID + assert conn.arguments[12] == PERSON_ID + assert window == [] + + +def test_load_facts_binds_edges_to_the_sealed_snapshot() -> None: + """New graph edges must not splice into an already sealed continuation.""" + conn = CapturingWindowConnection() + asyncio.run( + _load_facts( + conn, # type: ignore[arg-type] + [POST_ID], + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + maximum_depth=1, + maximum_edges=1, + snapshot_at=T0, + ) + ) + normalized = " ".join(conn.query.lower().split()) + assert "edge.created_at <= $7::timestamptz" in normalized + assert conn.arguments[6] == T0 + + +def test_loaded_display_edge_keeps_its_raw_sql_cursor_key() -> None: + """A reversed display relation must still resume with the raw SQL orientation.""" + window = asyncio.run( + _load_facts( + WindowConnection(), # type: ignore[arg-type] + [POST_ID], + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + maximum_depth=1, + maximum_edges=1, + ) + ) + display_key = next(iter(window.source_keys_by_edge)) + source_key = window.source_keys_by_edge[display_key] + assert display_key[0] == PROPERTY_MENTIONS + assert display_key[1] == NODE_PERSON + assert source_key.edge_type_code == EDGE_MENTION + assert source_key.source_node_id == POST_ID + + +class CursorConnection: + """Supply the focus label used by the in-memory continuation regression.""" + + async def fetchval(self, query: str, *_args: object) -> str | None: + """Return only the synthetic focus title.""" + return "Focus" if "post_title" in query else None + + +def test_source_continuation_uses_sealed_snapshot_and_rechecks_page_endpoints(monkeypatch) -> None: + """Continuation must use the sealed time and authorize newly loaded endpoints.""" + fact = replace( + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_POST, + source_node_id=POST_ID, + target_node_type_code=NODE_PERSON, + target_node_id=PERSON_ID, + edge_type_code=EDGE_MENTION, + recorded_at=T0, + evidence_references=(POST_ID,), + ), + source_hop_depth=0, + ) + last_key = OntologySourceKey( + hop_depth=0, + edge_type_code=EDGE_MENTION, + source_node_type_code=NODE_POST, + source_node_id=POST_ID, + target_node_type_code=NODE_PERSON, + target_node_id=PERSON_ID, + ) + claims = OntologySourceCursor( + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + knowledge_cutoff=None, + maximum_depth=1, + maximum_nodes=10, + maximum_edges=1, + allowed_property_codes=None, + last_key=last_key, + snapshot_at=T0, + eligibility_digest="digest", + expires_at=T0, + ) + load_snapshots: list[datetime | None] = [] + load_after_keys: list[OntologySourceKey | None] = [] + verify_modes: list[bool] = [] + minted_keys: list[OntologySourceKey] = [] + display_edge_key = ( + fact.property_code, + fact.source_node_type_code, + fact.source_node_id, + fact.target_node_type_code, + fact.target_node_id, + ) + + async def fake_load_facts(*_args, snapshot_at=None, after_key=None, **_kwargs): + load_snapshots.append(snapshot_at) + load_after_keys.append(after_key) + return ingestion._LoadedFactWindow( + [fact], + truncated=True, + last_source_key=last_key, + source_keys_by_edge={display_edge_key: last_key}, + ) + + async def fake_visible_post_ids(*_args, **_kwargs): + return [POST_ID] + + async def fake_focus_exists(*_args, **_kwargs): + return True + + async def fake_visible_by_nodes(_conn, endpoint_keys, _can_see_post): + assert (NODE_PERSON, PERSON_ID) in endpoint_keys + return {(NODE_PERSON, PERSON_ID): [POST_ID]} + + async def fake_skos(*_args, **_kwargs): + return [] + + async def fake_labels(*_args, **_kwargs): + return {(NODE_POST, POST_ID): "Focus", (NODE_PERSON, PERSON_ID): "Person"} + + async def fake_metadata(*_args, **_kwargs): + return {} + + def fake_verify(_token, **kwargs): + verify_modes.append(kwargs["validate_eligibility"]) + return claims + + def fake_mint(**kwargs): + minted_keys.append(kwargs["last_key"]) + return "src.v2.next" + + monkeypatch.setattr(ingestion, "focus_catalog_exists", fake_focus_exists) + monkeypatch.setattr(ingestion, "visible_post_ids_for_focus", fake_visible_post_ids) + monkeypatch.setattr(ingestion, "_load_facts", fake_load_facts) + monkeypatch.setattr(ingestion, "_visible_post_ids_by_nodes", fake_visible_by_nodes) + monkeypatch.setattr(ingestion, "_load_skos_facts", fake_skos) + monkeypatch.setattr(ingestion, "_load_labels", fake_labels) + monkeypatch.setattr(ingestion, "_load_node_metadata", fake_metadata) + monkeypatch.setattr(ingestion, "verify_source_cursor", fake_verify) + monkeypatch.setattr(ingestion, "mint_source_cursor", fake_mint) + + result = asyncio.run( + ingestion.visible_ontology_neighborhood( + CursorConnection(), # type: ignore[arg-type] + focus_node_type_code=NODE_POST, + focus_node_id=POST_ID, + can_see_post=lambda _row: True, + maximum_depth=1, + maximum_nodes=10, + maximum_edges=1, + cursor="src.v2.synthetic", + source_cursor_secret="s" * 32, + source_cursor_scope="account", + ) + ) + + assert verify_modes == [False, True] + assert load_snapshots == [T0, T0] + assert load_after_keys == [None, last_key] + assert minted_keys == [last_key] + assert result.edges + + +def test_skos_overflow_mints_source_cursor_for_continuation(monkeypatch) -> None: + """Derived SKOS overflow must not expose an unusable in-memory cursor.""" + fact = replace( + fact_from_knowledge_graph_edge( + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + edge_type_code=EDGE_AFFILIATION, + recorded_at=T0, + evidence_references=(POST_ID,), + ), + source_hop_depth=0, + source_order_key=( + 0, + EDGE_AFFILIATION, + NODE_PERSON, + PERSON_ID, + NODE_CORPORATE_ENTITY, + CORP_ID, + ), + ) + skos = skos_broader_fact( + narrower_entity_id=CORP_ID, + broader_entity_id=GROUP_ID, + recorded_at=T0, + ) + last_key = OntologySourceKey( + hop_depth=0, + edge_type_code=EDGE_AFFILIATION, + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_CORPORATE_ENTITY, + target_node_id=CORP_ID, + ) + claims = OntologySourceCursor( + focus_node_type_code=NODE_CORPORATE_ENTITY, + focus_node_id=CORP_ID, + knowledge_cutoff=None, + maximum_depth=1, + maximum_nodes=10, + maximum_edges=1, + allowed_property_codes=None, + last_key=last_key, + snapshot_at=T0, + eligibility_digest="digest", + expires_at=T0, + ) + load_after_keys: list[OntologySourceKey | None] = [] + minted_keys: list[OntologySourceKey] = [] + + async def fake_load_facts(*_args, after_key=None, **_kwargs): + load_after_keys.append(after_key) + if after_key is not None: + return ingestion._LoadedFactWindow() # type: ignore[attr-defined] + return ingestion._LoadedFactWindow( # type: ignore[attr-defined] + [fact], + source_keys_by_edge={ + ( + fact.property_code, + fact.source_node_type_code, + fact.source_node_id, + fact.target_node_type_code, + fact.target_node_id, + ): last_key + }, + ) + + async def fake_visible_post_ids(*_args, **_kwargs): + return [POST_ID] + + async def fake_focus_exists(*_args, **_kwargs): + return True + + async def fake_visible_by_nodes(_conn, endpoint_keys, _can_see_post): + return {key: [POST_ID] for key in endpoint_keys} + + async def fake_skos(*_args, **_kwargs): + return [skos] + + async def fake_labels(*_args, **_kwargs): + return { + (NODE_PERSON, PERSON_ID): "Person", + (NODE_CORPORATE_ENTITY, CORP_ID): "Organization", + (NODE_CORPORATE_ENTITY, GROUP_ID): "Group", + } + + async def fake_metadata(*_args, **_kwargs): + return {} + + def fake_verify(_token, **_kwargs): + return claims + + def fake_mint(**kwargs): + minted_keys.append(kwargs["last_key"]) + return "src.v2.synthetic-next" + + monkeypatch.setattr(ingestion, "focus_catalog_exists", fake_focus_exists) + monkeypatch.setattr(ingestion, "visible_post_ids_for_focus", fake_visible_post_ids) + monkeypatch.setattr(ingestion, "_load_facts", fake_load_facts) + monkeypatch.setattr(ingestion, "_visible_post_ids_by_nodes", fake_visible_by_nodes) + monkeypatch.setattr(ingestion, "_load_skos_facts", fake_skos) + monkeypatch.setattr(ingestion, "_load_labels", fake_labels) + monkeypatch.setattr(ingestion, "_load_node_metadata", fake_metadata) + monkeypatch.setattr(ingestion, "verify_source_cursor", fake_verify) + monkeypatch.setattr(ingestion, "mint_source_cursor", fake_mint) + + first_page = asyncio.run( + ingestion.visible_ontology_neighborhood( + CursorConnection(), # type: ignore[arg-type] + focus_node_type_code=NODE_CORPORATE_ENTITY, + focus_node_id=CORP_ID, + can_see_post=lambda _row: True, + maximum_depth=1, + maximum_nodes=10, + maximum_edges=1, + source_cursor_secret="s" * 32, + source_cursor_scope="account", + ) + ) + second_page = asyncio.run( + ingestion.visible_ontology_neighborhood( + CursorConnection(), # type: ignore[arg-type] + focus_node_type_code=NODE_CORPORATE_ENTITY, + focus_node_id=CORP_ID, + can_see_post=lambda _row: True, + maximum_depth=1, + maximum_nodes=10, + maximum_edges=1, + cursor=first_page.next_cursor, + source_cursor_secret="s" * 32, + source_cursor_scope="account", + ) + ) + + assert first_page.next_cursor == "src.v2.synthetic-next" + assert [edge.property_code for edge in second_page.edges] == ["skos_broader"] + assert second_page.next_cursor is None + assert load_after_keys == [None, None, last_key] + assert minted_keys == [last_key] diff --git a/tests/test_ontology_source_cursor.py b/tests/test_ontology_source_cursor.py new file mode 100644 index 000000000..1afaf8009 --- /dev/null +++ b/tests/test_ontology_source_cursor.py @@ -0,0 +1,148 @@ +"""Opaque source-cursor integrity, scope, and snapshot regressions.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from lineageweave.knowledge_graph import EDGE_MENTION, NODE_PERSON, NODE_POST +from lineageweave.ontology_neighborhood import OntologyNeighborhoodError +from lineageweave.ontology_source_cursor import ( + OntologySourceKey, + SOURCE_CURSOR_PREFIX, + mint_source_cursor, + source_cursor_secret_from_env, + verify_source_cursor, +) + +SECRET = b"ontology-source-cursor-secret-32b" +ACCOUNT = "account-aaaaaaaa-aaaa-aaaa-aaaa-01" +POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1" +PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1" +T0 = datetime(2026, 1, 10, 12, 0, tzinfo=timezone.utc) +SNAPSHOT = datetime(2026, 1, 10, 12, 5, tzinfo=timezone.utc) + +LAST_KEY = OntologySourceKey( + hop_depth=0, + edge_type_code=EDGE_MENTION, + source_node_type_code=NODE_PERSON, + source_node_id=PERSON_ID, + target_node_type_code=NODE_POST, + target_node_id=POST_ID, +) + + +def _mint(**overrides: object) -> str: + values: dict[str, object] = { + "secret": SECRET, + "user_account_id": ACCOUNT, + "focus_node_type_code": NODE_POST, + "focus_node_id": POST_ID, + "knowledge_cutoff": T0, + "maximum_depth": 2, + "maximum_nodes": 40, + "maximum_edges": 80, + "allowed_property_codes": None, + "last_key": LAST_KEY, + "snapshot_at": SNAPSHOT, + "visible_post_ids": [POST_ID], + "now": SNAPSHOT, + } + values.update(overrides) + return mint_source_cursor(**values) # type: ignore[arg-type] + + +def _verify(token: str, **overrides: object) -> object: + values: dict[str, object] = { + "secret": SECRET, + "user_account_id": ACCOUNT, + "focus_node_type_code": NODE_POST, + "focus_node_id": POST_ID, + "knowledge_cutoff": T0, + "maximum_depth": 2, + "maximum_nodes": 40, + "maximum_edges": 80, + "allowed_property_codes": None, + "visible_post_ids": [POST_ID], + "now": SNAPSHOT, + } + values.update(overrides) + return verify_source_cursor(token, **values) # type: ignore[arg-type] + + +def test_missing_or_short_secret_keeps_source_cursor_closed() -> None: + assert source_cursor_secret_from_env("") is None + assert source_cursor_secret_from_env("short") is None + assert source_cursor_secret_from_env(SECRET.decode()) == SECRET + + +def test_round_trip_hides_sql_keys_and_account_ids() -> None: + token = _mint() + assert token.startswith(SOURCE_CURSOR_PREFIX) + assert ACCOUNT not in token + assert PERSON_ID not in token + assert EDGE_MENTION not in token + assert "after:" not in token + claims = _verify(token) + assert claims.last_key == LAST_KEY + assert claims.snapshot_at == SNAPSHOT + + +def test_tampered_token_fails_closed() -> None: + token = _mint() + mutated = token[:-2] + ("A" if token[-2] != "A" else "B") + token[-1] + with pytest.raises(OntologyNeighborhoodError) as raised: + _verify(mutated) + assert raised.value.code == "malformed_cursor" + + +def test_scope_focus_cutoff_bounds_and_version_fail_closed() -> None: + token = _mint() + with pytest.raises(OntologyNeighborhoodError) as scope: + _verify(token, user_account_id="other-account-bbbbbbbb-02") + assert scope.value.code == "malformed_cursor" + with pytest.raises(OntologyNeighborhoodError) as focus: + _verify(token, focus_node_id="cccccccc-cccc-cccc-cccc-ccccccccccc1") + assert focus.value.code == "malformed_cursor" + with pytest.raises(OntologyNeighborhoodError) as cutoff: + _verify(token, knowledge_cutoff=None) + assert cutoff.value.code == "malformed_cursor" + with pytest.raises(OntologyNeighborhoodError) as bounds: + _verify(token, maximum_edges=10) + assert bounds.value.code == "malformed_cursor" + + +def test_changed_visible_posts_fail_as_stale_snapshot() -> None: + token = _mint() + with pytest.raises(OntologyNeighborhoodError) as raised: + _verify(token, visible_post_ids=[POST_ID, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2"]) + assert raised.value.code == "stale_snapshot" + + +def test_cursor_claims_can_be_authenticated_before_eligibility_reconstruction() -> None: + """Continuation can recover its sealed snapshot before rebuilding its post set.""" + token = _mint() + claims = _verify(token, visible_post_ids=[], validate_eligibility=False) + assert claims.snapshot_at == SNAPSHOT + assert claims.last_key == LAST_KEY + + +def test_expired_cursor_fails_closed() -> None: + token = _mint(now=SNAPSHOT) + with pytest.raises(OntologyNeighborhoodError) as raised: + _verify(token, now=SNAPSHOT + timedelta(minutes=16)) + assert raised.value.code == "malformed_cursor" + + +def test_unknown_prefix_fails_closed() -> None: + with pytest.raises(OntologyNeighborhoodError) as raised: + _verify("after:mentions:post-person") + assert raised.value.code == "malformed_cursor" + + +def test_v1_custom_cursor_format_is_rejected_after_aead_upgrade() -> None: + legacy_token = _mint().replace("src.v2.", "src.v1.", 1) + with pytest.raises(OntologyNeighborhoodError) as raised: + _verify(legacy_token) + assert raised.value.code == "malformed_cursor" diff --git a/tests/test_post_content_worker.py b/tests/test_post_content_worker.py index dddace990..f58932544 100644 --- a/tests/test_post_content_worker.py +++ b/tests/test_post_content_worker.py @@ -230,6 +230,7 @@ async def persist(*_args, **_kwargs): updates = [args for query, args in connection.executed if "set status_code" in query] assert any(args[1] == QUEUED and args[6] == "post_content_ingestion_failed" for args in updates) + assert "provider timeout" not in str(connection.executed) def test_failure_at_attempt_limit_is_terminal_and_visible() -> None: diff --git a/tests/test_post_summary.py b/tests/test_post_summary.py index 34abac38a..7bc782cc5 100644 --- a/tests/test_post_summary.py +++ b/tests/test_post_summary.py @@ -14,7 +14,10 @@ import pytest -from backend.app.post_summary_ingestion import require_summary_source_body, seeded_fixture_summary +from backend.app.post_summary_ingestion import ( + require_summary_source_body, + seeded_fixture_summary, +) from lineageweave.fixtures import ( ambiguous_commitment_post, ambiguous_keyman_post, @@ -22,13 +25,13 @@ sample_records, ) from lineageweave.post_summary import ( + _SUMMARY_REQUEST_PROMPT_TEMPLATE, ContextualOrchestratorPostSummaryClient, NullPostSummaryClient, RoleResponsibility, - _SUMMARY_REQUEST_PROMPT_TEMPLATE, _parse_optional_project_key, - _parse_plain_summary_response, _parse_plain_summary_details, + _parse_plain_summary_response, parse_summary_response, ) @@ -337,6 +340,43 @@ def fake_post_json(url, payload, *, headers, timeout): assert summary.project_mentions[0].canonical_name == "hvdc-pilot" +def test_summary_details_parse_failure_does_not_expose_provider_response(monkeypatch) -> None: + """Malformed provider output gets a stable parser error, never raw text.""" + responses = iter( + ( + { + "choices": [ + {"message": {"content": "본문 근거 요약\nKEY EVENTS: 후속 확인"}} + ] + }, + { + "choices": [ + { + "message": { + "content": "provider-secret-and-gateway-prompt" + } + } + ] + }, + ) + ) + + monkeypatch.setattr( + "lineageweave.post_summary.post_json", + lambda *args, **kwargs: next(responses), + ) + + with pytest.raises( + ValueError, + match="summary semantic response did not match the required format", + ) as exc_info: + ContextualOrchestratorPostSummaryClient("https://orchestrator.test", "token").summarize( + "Synthetic title", "Synthetic body" + ) + + assert "provider-secret-and-gateway-prompt" not in str(exc_info.value) + + def test_title_match_can_supply_explicit_project_evidence_but_not_a_guess() -> None: details = _parse_plain_summary_details( "ROLES:\nNONE\nPROJECTS:\nNorthridge transformer bid | NONE | 1", diff --git a/uv.lock b/uv.lock index 8bf26bf75..c7185f951 100644 --- a/uv.lock +++ b/uv.lock @@ -454,10 +454,11 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.26" +version = "2.14.0" source = { editable = "." } dependencies = [ { name = "certifi" }, + { name = "cryptography" }, { name = "pillow" }, { name = "rankweave" }, { name = "rdflib" }, @@ -486,6 +487,7 @@ requires-dist = [ { name = "asyncpg", marker = "extra == 'backend'", specifier = ">=0.29.0" }, { name = "certifi", specifier = ">=2024.0.0" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, + { name = "cryptography", specifier = ">=42.0" }, { name = "fast-mlsirm", marker = "extra == 'backend'", git = "https://github.com/ContextualWisdomLab/fast-mlsirm.git?rev=5006c38286a4fa1d81bcf57eeed5ce27ae743f50" }, { name = "fastapi", marker = "extra == 'backend'", specifier = ">=0.115.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },