From 30f604fb0e82735bd2f077dd3c164341f9857104 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:37:45 +0000 Subject: [PATCH 01/74] feat: add bounded ontology and provenance explorer GET /api/ontology/neighborhood returns a typed Post/Person/CorporateEntity/Team neighborhood with SKOS broader distinct from OWL subclass, truth-status vocabulary, knowledge-cutoff binding, and no hidden-count side channels. Buyers inspect it from the Keyman panel, not a second GNB destination (ADR 0119 / #341). --- AGENTS.md | 4 +- ARCHITECTURE.md | 1 + CHANGELOG.d/2.13.0-ontology-explorer.md | 10 + CHANGELOG.md | 7 + backend/app/main.py | 52 ++ .../app/ontology_neighborhood_ingestion.py | 357 ++++++++++ docker/postgres-init/migrate.sh | 2 +- docs/adr/0119-ontology-provenance-explorer.md | 46 ++ docs/adr/README.md | 1 + .../doctoring/ONTOLOGY_EXPLORER_REFERENCES.md | 37 + docs/product-technical-gap-baseline.md | 1 + docs/storybook-inventory.md | 1 + frontend/package.json | 2 +- frontend/src/App.css | 117 ++++ frontend/src/App.test.tsx | 28 + frontend/src/App.tsx | 46 ++ frontend/src/api.ts | 88 +++ .../components/OntologyExplorer.stories.tsx | 218 ++++++ .../src/components/OntologyExplorer.test.tsx | 208 ++++++ frontend/src/components/OntologyExplorer.tsx | 563 +++++++++++++++ frontend/src/i18n.test.ts | 3 + frontend/src/i18n.ts | 231 ++++++ frontend/src/ontologyLayout.test.ts | 132 ++++ frontend/src/ontologyLayout.ts | 144 ++++ lineageweave/ontology_neighborhood.py | 648 +++++++++++++++++ migrations/0104_ontology_truth_status.sql | 13 + pyproject.toml | 2 +- tests/test_ontology_neighborhood.py | 655 ++++++++++++++++++ tests/test_ontology_neighborhood_ingestion.py | 564 +++++++++++++++ uv.lock | 2 +- 30 files changed, 4178 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.d/2.13.0-ontology-explorer.md create mode 100644 backend/app/ontology_neighborhood_ingestion.py create mode 100644 docs/adr/0119-ontology-provenance-explorer.md create mode 100644 docs/doctoring/ONTOLOGY_EXPLORER_REFERENCES.md create mode 100644 frontend/src/components/OntologyExplorer.stories.tsx create mode 100644 frontend/src/components/OntologyExplorer.test.tsx create mode 100644 frontend/src/components/OntologyExplorer.tsx create mode 100644 frontend/src/ontologyLayout.test.ts create mode 100644 frontend/src/ontologyLayout.ts create mode 100644 lineageweave/ontology_neighborhood.py create mode 100644 migrations/0104_ontology_truth_status.sql create mode 100644 tests/test_ontology_neighborhood.py create mode 100644 tests/test_ontology_neighborhood_ingestion.py diff --git a/AGENTS.md b/AGENTS.md index 1728f9e61..1c8ae3a24 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 0119); do not mix those graphs. ## Hard rule: no real data in repository artifacts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0280ff97..ea67a785f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -79,6 +79,7 @@ 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 0119); PostgreSQL stays authoritative, OWL subclass is not an instance edge | | `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..93d090f48 --- /dev/null +++ b/CHANGELOG.d/2.13.0-ontology-explorer.md @@ -0,0 +1,10 @@ +# 2.13.0 — Ontology provenance explorer + +- Added `GET /api/ontology/neighborhood` as a bounded typed instance graph + (ADR 0119 / 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. +- Buyer surface: Keyman **Inspect ontology neighborhood**, exact-value table, + CSV/JSON-LD export, and Storybook states. No second GNB destination. diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..1680322f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to this project are documented here. Format follows ## [Unreleased] +### Added + +- 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 0119 / #341). + ### Fixed - `make smoke` and `make seed` now run through the locked project `uv` diff --git a/backend/app/main.py b/backend/app/main.py index fb943315f..664f9cda7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -166,6 +166,18 @@ visible_team_mention_post_ids, ) from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph +from backend.app.ontology_neighborhood_ingestion import ( + 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, + OntologyNeighborhoodError, +) from backend.app.post_chat_ingestion import ( fetch_persisted_chat, fetch_persisted_chats, @@ -1927,6 +1939,46 @@ 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), + maximum_nodes: int = Query(DEFAULT_MAXIMUM_NODES), + maximum_edges: int = Query(DEFAULT_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, + ) + except OntologyNeighborhoodError as exc: + raise HTTPException(neighborhood_error_http_status(exc), str(exc)) from exc + return neighborhood_to_payload(neighborhood) + + @app.get("/api/posts/{post_id}/counterparties") async def read_post_counterparties( post_id: str, diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py new file mode 100644 index 000000000..6fc4db577 --- /dev/null +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -0,0 +1,357 @@ +"""Load an ABAC-visible ontology neighborhood from PostgreSQL (ADR 0119).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Callable, 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, + KNOWN_NODE_TYPES, + NeighborhoodFact, + OntologyNeighborhood, + OntologyNeighborhoodError, + assemble_ontology_neighborhood, + fact_from_knowledge_graph_edge, + skos_broader_fact, +) + +FORBIDDEN_NEIGHBORHOOD_CODES = frozenset({"focus_hidden", "focus_not_visible"}) +NOT_FOUND_NEIGHBORHOOD_CODES = frozenset({"unknown_node_type", "dangling_endpoint"}) + + +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 FORBIDDEN_NEIGHBORHOOD_CODES: + return 403 + if error.code in NOT_FOUND_NEIGHBORHOOD_CODES: + return 404 + return 422 + + +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 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] +) -> list[NeighborhoodFact]: + if not visible_post_ids: + return [] + rows = await conn.fetch( + """ + select edge.source_node_type_code, + edge.source_node_id, + edge.target_node_type_code, + edge.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[]) + group by edge.source_node_type_code, edge.source_node_id, + edge.target_node_type_code, edge.target_node_id, + edge.edge_type_code + """, + visible_post_ids, + ) + facts: list[NeighborhoodFact] = [] + for row in rows: + facts.append( + 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", + ) + ) + return facts + + +async def _load_skos_facts( + conn: asyncpg.Connection, corporate_entity_ids: list[str] +) -> list[NeighborhoodFact]: + 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]: + person_ids: list[str] = [] + post_ids: list[str] = [] + corp_ids: list[str] = [] + team_ids: list[str] = [] + for fact in facts: + for node_type, node_id in ( + (fact.source_node_type_code, fact.source_node_id), + (fact.target_node_type_code, fact.target_node_id), + ): + if node_type == NODE_PERSON: + person_ids.append(node_id) + elif node_type == NODE_POST: + post_ids.append(node_id) + elif node_type == NODE_CORPORATE_ENTITY: + corp_ids.append(node_id) + elif node_type == NODE_TEAM: + team_ids.append(node_id) + 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, + ): + labels[(NODE_PERSON, str(row["person_id"]))] = 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, + ): + labels[(NODE_POST, str(row["post_id"]))] = 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, + ): + labels[(NODE_CORPORATE_ENTITY, str(row["corporate_entity_id"]))] = 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, + ): + labels[(NODE_TEAM, str(row["team_id"]))] = row["team_name"] + return labels + + +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(), + "evidence_count": node.evidence_count, + "shape_code": node.shape_code, + } + for node in neighborhood.nodes + ], + "edges": [ + { + "edge_id": edge.edge_id, + "source_node_id": edge.source_node_id, + "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, +) -> 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 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") + facts = await _load_facts(conn, visible_post_ids) + 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) + facts.extend(await _load_skos_facts(conn, list(dict.fromkeys(corp_ids)))) + labels = await _load_labels(conn, facts) + 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 + return assemble_ontology_neighborhood( + focus_node_type_code=focus_node_type_code, + focus_node_id=focus_node_id, + facts=facts, + labels=labels, + knowledge_cutoff=knowledge_cutoff, + maximum_depth=maximum_depth, + maximum_nodes=maximum_nodes, + maximum_edges=maximum_edges, + allowed_property_codes=allowed_property_codes, + cursor=cursor, + ) diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index f329117d6..3a16458d4 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0060_*|0100_*|0101_*|0102_*) ;; + 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0119-ontology-provenance-explorer.md b/docs/adr/0119-ontology-provenance-explorer.md new file mode 100644 index 000000000..d18165bc8 --- /dev/null +++ b/docs/adr/0119-ontology-provenance-explorer.md @@ -0,0 +1,46 @@ +# ADR 0119: 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 Buyer 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. +4. Truth status is one of `truth_authoritative`, `truth_observed`, `truth_inferred`, `truth_proposed`, `truth_superseded`, `truth_rejected`. Display never promotes inference to authority. +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 Buyer 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). 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. + +**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 Recommendation). 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/ diff --git a/docs/adr/README.md b/docs/adr/README.md index 762f1c051..2f94b0b83 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -14,6 +14,7 @@ decision from them. | [`PROV_O_IMPLEMENTATION.md`](../PROV_O_IMPLEMENTATION.md) | [0065](0065-prov-o-provenance-boundary.md) | | [`PROV_O_IMPLEMENTATION_MATRIX.md`](../PROV_O_IMPLEMENTATION_MATRIX.md) | [0065](0065-prov-o-provenance-boundary.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), [0119](0119-ontology-provenance-explorer.md) | Runtime evidence under `docs/doctoring/` is not converted into an ADR: it records observed results for already-decided behavior. diff --git a/docs/doctoring/ONTOLOGY_EXPLORER_REFERENCES.md b/docs/doctoring/ONTOLOGY_EXPLORER_REFERENCES.md new file mode 100644 index 000000000..47276848a --- /dev/null +++ b/docs/doctoring/ONTOLOGY_EXPLORER_REFERENCES.md @@ -0,0 +1,37 @@ +# Ontology explorer — doctoring + +These are the standards that ground ADR 0119 / 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 +Recommendation). 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/ diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e65883463..7fb53252b 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -7,6 +7,7 @@ - **Image/Table OCR**: `post=00505695-7571-1fd1-83dd-3d22a61a5734` fails text recognition for tables inside images, markdown parsing fails, and image OCR description is too shallow for Ontology & Semantics. - **Math/Superscripts**: `post=00505695-9612-1fe1-83a7-e30153323f25` fails to parse superscripts like m^3 properly. Needs strict Ontology grammar for math formulas. - **Missing UI Elements**: DAG (Directed Acyclic Graph) view is currently missing from the frontend for `post=00505695-7571-1fd1-83c5-895ed333cdbc`. +- **Ontology neighborhood (ADR 0119 / #341)**: Event Lineage remains post-to-post reconstruction. Typed `Post -> mentions -> Person -> affiliatedWith -> CorporateEntity` inspection now has `GET /api/ontology/neighborhood` plus the Keyman **Inspect ontology neighborhood** control. Remaining work is independent APPROVE + exact-head CI, not a second GNB destination. ## 2. LLM Extraction & Knowledge Graph Gaps - **Multiple Project Extraction**: (Resolved) LLM prompt updated to request key_events as objects with project_name, separating events correctly. diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 28c59bd48..2d5446962 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -9,6 +9,7 @@ buyer-facing control you can click before changing product CSS. | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | | `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` | Repeated web objects must use `frontend/src/styles/tokens.css` and a module under `frontend/src/components/`. Do not add a second Node package manager; diff --git a/frontend/package.json b/frontend/package.json index e2e996bbe..20fda0d5f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.6", + "version": "2.13.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index c72aab078..89bcbacea 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -846,3 +846,120 @@ 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-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-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 7462abd2c..12667ad50 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1359,6 +1359,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 6fba0dd41..fa02f57ca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -85,6 +85,7 @@ import { import { CitationChip } from "./components/CitationChip"; import { CutoffKnownBody } from "./components/CutoffKnownBody"; import { LineageEntityPicker } from "./components/LineageEntityPicker"; +import { OntologyExplorer } from "./components/OntologyExplorer"; import { PopupCloseButton } from "./components/PopupCloseButton"; import { BuyerNav, type BuyerDestination } from "./components/BuyerNav"; import { LineageDag } from "./LineageDag"; @@ -865,6 +866,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); @@ -880,6 +887,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 { @@ -896,6 +904,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 { @@ -909,6 +918,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 { @@ -926,6 +936,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) @@ -979,6 +994,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) @@ -997,6 +1017,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) @@ -1012,6 +1037,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) => { @@ -1158,6 +1188,13 @@ function KeymanPanel({

{t("Keymen")}

+ {canExtract && !orchestratorOff && (
{t("Evidence operations")} @@ -1284,6 +1321,15 @@ function KeymanPanel({ {afterList && landFirstRelated && landedRelatedName && landedRelated !== null ? ( ) : null} + {ontologyOpen ? ( + + ) : null} ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index cd0141a32..cc809bb98 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -693,6 +693,94 @@ 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; + valid_from: string | null; + valid_to: string | null; + recorded_at: string; + evidence_count: number; + shape_code: string; +} + +export interface OntologyGraphEdgePayload { + edge_id: string; + source_node_id: 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, diff --git a/frontend/src/components/OntologyExplorer.stories.tsx b/frontend/src/components/OntologyExplorer.stories.tsx new file mode 100644 index 000000000..0deeb6d3d --- /dev/null +++ b/frontend/src/components/OntologyExplorer.stories.tsx @@ -0,0 +1,218 @@ +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: "Priya Nair", + 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_id: POST_ID, + 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_id: PERSON_ID, + 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: "Priya Nair", + 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: "Priya Nair", + 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 rejectedNeighborhood: OntologyNeighborhoodPayload = { + ...demoNeighborhood, + edges: [ + { + ...demoNeighborhood.edges[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" } }, +}; + +export const NodeDrawer: Story = {}; + +export const EdgeDrawer: Story = {}; + +export const LegendAndFilter: Story = {}; + +export const Empty: Story = { + args: { + neighborhood: emptyNeighborhood, + status: "empty", + }, +}; + +export const Truncated: Story = { + args: { + neighborhood: truncatedNeighborhood, + status: "truncated", + }, +}; + +export const Partial: Story = { + args: { + neighborhood: truncatedNeighborhood, + status: "truncated", + }, +}; + +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..95af9648e --- /dev/null +++ b/frontend/src/components/OntologyExplorer.test.tsx @@ -0,0 +1,208 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +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"; + +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: "Priya Nair", + 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_id: POST_ID, + 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_id: PERSON_ID, + 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: "Priya Nair", + 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: "Priya Nair", + 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("lets keyboard users open node and edge evidence", async () => { + const onSelectPost = 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); + await userEvent.click(screen.getByRole("button", { name: /Select edge: mentions from/ })); + expect(screen.getByText(/Property IRI/)).toBeInTheDocument(); + 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 truncated. Page visible relations, then inspect one edge.")).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("searches the loaded graph without inventing omitted counts", async () => { + render( + , + ); + await userEvent.type(screen.getByLabelText("Search within this neighborhood"), "Priya"); + expect(screen.getAllByText("Priya Nair").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(""); + }); +}); diff --git a/frontend/src/components/OntologyExplorer.tsx b/frontend/src/components/OntologyExplorer.tsx new file mode 100644 index 000000000..b007f4f31 --- /dev/null +++ b/frontend/src/components/OntologyExplorer.tsx @@ -0,0 +1,563 @@ +import { useEffect, useMemo, useState } from "react"; +import { + BackendError, + fetchOntologyNeighborhood, + type OntologyGraphEdgePayload, + type OntologyGraphNodePayload, + type OntologyNeighborhoodPayload, +} from "../api"; +import { t, tf } from "../i18n"; +import { 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", +}; + +/** + * 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) : "loading")); + const [query, setQuery] = useState(""); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [focusType, setFocusType] = useState(focusNodeType); + const [focusId, setFocusId] = useState(focusNodeId); + + useEffect(() => { + setFocusType(focusNodeType); + setFocusId(focusNodeId); + }, [focusNodeType, focusNodeId]); + + useEffect(() => { + if (provided) { + setLoaded(provided); + setStatus(providedStatus ?? statusFromPayload(provided)); + return; + } + if (!accessToken) { + setStatus(providedStatus ?? "empty"); + return; + } + let cancelled = false; + setStatus("loading"); + fetchOntologyNeighborhood(accessToken, { + focusNodeType: focusType, + focusNodeId: focusId, + knowledgeCutoff, + }) + .then((payload) => { + if (cancelled) return; + setLoaded(payload); + setStatus(statusFromPayload(payload, knowledgeCutoff)); + }) + .catch((error: unknown) => { + if (cancelled) return; + setLoaded(null); + if (error instanceof BackendError && error.status === 403) { + setStatus("denied"); + return; + } + setStatus("error"); + }); + return () => { + cancelled = true; + }; + }, [accessToken, focusType, focusId, knowledgeCutoff, provided, providedStatus]); + + const visible = useMemo(() => filterNeighborhood(loaded, query), [loaded, query]); + const layout = useMemo(() => (visible ? layoutOntologyNeighborhood(visible) : null), [visible]); + const selectedNode = visible?.nodes.find((node) => node.node_id === selectedNodeId) ?? null; + const selectedEdge = visible?.edges.find((edge) => edge.edge_id === selectedEdgeId) ?? null; + + function resetFocus() { + setFocusType(focusNodeType); + setFocusId(focusNodeId); + setSelectedNodeId(null); + setSelectedEdgeId(null); + setQuery(""); + } + + 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)} + + {layout && visible && status !== "denied" && status !== "error" && status !== "loading" ? ( + <> +
+ { + setSelectedNodeId(node.node_id); + setSelectedEdgeId(null); + }} + onSelectEdge={(edge) => { + setSelectedEdgeId(edge.edge_id); + setSelectedNodeId(null); + }} + /> +
+ { + setSelectedEdgeId(edgeId); + setSelectedNodeId(null); + }} + /> + + ) : null} + {selectedNode ? ( + { + setFocusType(selectedNode.node_type_code); + setFocusId(selectedNode.node_id); + }} + onOpenEvidence={ + selectedNode.node_type_code === "node_post" + ? () => (onSelectPost ?? onOpenEvidence)?.(selectedNode.node_id) + : undefined + } + onClose={() => setSelectedNodeId(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.truncated || payload.limitation_code === "neighborhood_truncated") return "truncated"; + if (payload.edges.some((edge) => edge.truth_status_code === "truth_rejected")) return "rejected"; + if (knowledgeCutoff) return "stale"; + return "ready"; +} + +function statusMessage(status: OntologyExplorerStatus) { + 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")}
  • +
+

{t("Hidden evidence was removed. No omitted count is shown.")}

+
+ ); +} + +function OntologyGraph({ + layout, + selectedNodeId, + selectedEdgeId, + onSelectNode, + onSelectEdge, +}: { + layout: ReturnType; + selectedNodeId: 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, + onFocus, + onOpenEvidence, + onClose, +}: { + node: OntologyGraphNodePayload; + 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_id === edge.source_node_id); + const target = payload?.nodes.find((node) => node.node_id === edge.target_node_id); + return ( + + ); +} + +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 nodesById = new Map(payload.nodes.map((node) => [node.node_id, node])); + const edges = payload.edges.filter((edge) => { + const source = nodesById.get(edge.source_node_id); + const target = nodesById.get(edge.target_node_id); + return edgeMatch(edge) || Boolean(source && nodeMatch(source)) || Boolean(target && nodeMatch(target)); + }); + const keep = new Set([payload.focus_node_id]); + for (const edge of edges) { + keep.add(edge.source_node_id); + keep.add(edge.target_node_id); + } + for (const node of payload.nodes) { + if (nodeMatch(node)) keep.add(node.node_id); + } + const nodes = payload.nodes.filter((node) => keep.has(node.node_id)); + const exact_value_rows = payload.exact_value_rows.filter((row) => + edges.some((edge) => edge.edge_id === row.edge_id), + ); + return { ...payload, nodes, edges, exact_value_rows }; +} + +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; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 5a3afbfc3..d565c25ac 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -36,6 +36,9 @@ describe("i18n", () => { "Page", "Answer", "Showing the first {shown} of {total} posts known at this cutoff.", + "Inspect ontology neighborhood", + "Ontology neighborhood", + "This is an ontology neighborhood, not Event Lineage.", ] as const; it("supports the five product locales", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 650acfca8..652b9c430 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -356,6 +356,64 @@ const TRANSLATIONS: Partial>> = { "관측된 원본 작성자 {total}명 중 게시물 수 기준 상위 {shown}명을 표시합니다.", "Interactive questions are unavailable right now; saved evidence remains available.": "대화형 질문을 지금 사용할 수 없습니다. 저장된 근거는 계속 확인할 수 있습니다.", + "Inspect ontology neighborhood": "온톨로지 이웃 검사", + "Ontology neighborhood": "온톨로지 이웃", + "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": "유효 종료", }, zh: { "Unknown": "未知", @@ -694,6 +752,63 @@ const TRANSLATIONS: Partial>> = { "显示按文章数排序的 {total} 位已观察来源作者中的前 {shown} 位。", "Interactive questions are unavailable right now; saved evidence remains available.": "交互式提问暂不可用;已保存的证据仍可查看。", + "Inspect ontology neighborhood": "检查本体邻域", + "Ontology neighborhood": "本体邻域", + "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": "有效结束", }, ja: { "Unknown": "不明", @@ -1032,6 +1147,64 @@ const TRANSLATIONS: Partial>> = { "投稿数順に、観測された{total}名の元投稿者のうち上位{shown}名を表示しています。", "Interactive questions are unavailable right now; saved evidence remains available.": "対話形式の質問は現在利用できません。保存された証拠は確認できます。", + "Inspect ontology neighborhood": "オントロジー近傍を調べる", + "Ontology neighborhood": "オントロジー近傍", + "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": "有効終了", }, vi: { "Unknown": "Không rõ", @@ -1370,6 +1543,64 @@ 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ể", + "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", }, }; diff --git a/frontend/src/ontologyLayout.test.ts b/frontend/src/ontologyLayout.test.ts new file mode 100644 index 000000000..b4537bc3c --- /dev/null +++ b/frontend/src/ontologyLayout.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import type { OntologyNeighborhoodPayload } from "./api"; +import { 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: "Priya Nair", + 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_id: POST_ID, + 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_id: PERSON_ID, + 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: "Priya Nair", + 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("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"'); + }); +}); diff --git a/frontend/src/ontologyLayout.ts b/frontend/src/ontologyLayout.ts new file mode 100644 index 000000000..bb4606b0b --- /dev/null +++ b/frontend/src/ontologyLayout.ts @@ -0,0 +1,144 @@ +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; + +/** + * 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 byId = new Map(payload.nodes.map((node) => [node.node_id, node])); + const adjacency = new Map(); + for (const node of payload.nodes) { + adjacency.set(node.node_id, []); + } + for (const edge of payload.edges) { + if (!byId.has(edge.source_node_id) || !byId.has(edge.target_node_id)) { + continue; + } + adjacency.get(edge.source_node_id)?.push(edge.target_node_id); + adjacency.get(edge.target_node_id)?.push(edge.source_node_id); + } + for (const [nodeId, neighbors] of adjacency) { + adjacency.set(nodeId, [...new Set(neighbors)].sort()); + } + + const depth = new Map(); + const queue = [payload.focus_node_id]; + depth.set(payload.focus_node_id, 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(node.node_id) ?? 0; + const bucket = columns.get(column) ?? []; + bucket.push(node.node_id); + columns.set(column, bucket); + } + for (const [column, ids] of columns) { + columns.set( + column, + [...ids].sort((left, right) => { + const leftLabel = byId.get(left)?.display_label ?? left; + const rightLabel = byId.get(right)?.display_label ?? right; + return leftLabel.localeCompare(rightLabel) || left.localeCompare(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 = byId.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(node.node_id)).filter((node): node is LaidOutOntologyNode => Boolean(node)); + const edges: LaidOutOntologyEdge[] = payload.edges.flatMap((edge) => { + const from = positioned.get(edge.source_node_id); + const to = positioned.get(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 { + if (/[",\n]/.test(value)) { + return `"${value.replaceAll('"', '""')}"`; + } + return value; +} diff --git a/lineageweave/ontology_neighborhood.py b/lineageweave/ontology_neighborhood.py new file mode 100644 index 000000000..091f670ef --- /dev/null +++ b/lineageweave/ontology_neighborhood.py @@ -0,0 +1,648 @@ +"""Bounded ontology/provenance neighborhood (ADR 0119 / 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 +from datetime import datetime, timezone +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" +OWL_SUBCLASS_IRI = "http://www.w3.org/2002/07/owl#subClassOf" +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 + + +@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 + valid_from: datetime | None + valid_to: datetime | None + recorded_at: datetime + evidence_count: int + shape_code: str + + +@dataclass(frozen=True) +class OntologyGraphEdge: + """One typed ontology/KG edge in the bounded neighborhood.""" + + edge_id: str + source_node_id: 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_id: node.display_label for node in self.nodes} + types = {node.node_id: node.node_type_code 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": labels[edge.source_node_id], + "source_type_code": types[edge.source_node_id], + "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": labels[edge.target_node_id], + "target_type_code": types[edge.target_node_id], + "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:truthStatus": node.truth_status_code, + "lw:nodeType": node.node_type_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_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_id)}/{edge.source_node_id}" + }, + } + graph.append(item) + return {"@context": JSONLD_CONTEXT, "@graph": graph} + + +def _node_type_for(nodes: Sequence[OntologyGraphNode], node_id: str) -> str: + for node in nodes: + if node.node_id == node_id: + return node.node_type_code + raise OntologyNeighborhoodError("dangling_endpoint", "visible edge references a missing node") + + +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], + 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, +) -> 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. + """ + 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("unknown_node_type", "focus node id is empty") + 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: + raise OntologyNeighborhoodError("dangling_endpoint", "source endpoint is unlabeled") + if (fact.target_node_type_code, fact.target_node_id) not in labels: + raise OntologyNeighborhoodError("dangling_endpoint", "target endpoint is unlabeled") + 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, + ) + ) + + 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} + queue: deque[str] = deque([focus_key]) + collected: list[NeighborhoodFact] = [] + seen_edges: set[str] = set() + 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) + + collected.sort(key=_fact_sort_key) + 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 = (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])}" + + node_meta: dict[str, tuple[str, str, datetime, int, str]] = {} + focus_label = labels[(focus_node_type_code, focus_node_id)] + fallback_recorded = knowledge_cutoff or datetime(1970, 1, 1, tzinfo=timezone.utc) + node_meta[focus_key] = ( + focus_node_type_code, + focus_label, + fallback_recorded, + 0, + TRUTH_OBSERVED, + ) + for fact in page_edges: + for node_type, node_id, truth in ( + (fact.source_node_type_code, fact.source_node_id, fact.truth_status_code), + (fact.target_node_type_code, fact.target_node_id, fact.truth_status_code), + ): + key = _node_key(node_type, node_id) + label = labels[(node_type, node_id)] + current = node_meta.get(key) + evidence = len(fact.evidence_references) + recorded = fact.recorded_at + if current is None: + node_meta[key] = (node_type, label, recorded, evidence, truth) + else: + _, _, prior_recorded, prior_evidence, prior_truth = current + node_meta[key] = ( + node_type, + label, + min(prior_recorded, recorded), + prior_evidence + evidence, + prior_truth if prior_truth == TRUTH_AUTHORITATIVE else truth, + ) + + ordered_keys = [focus_key] + sorted(key for key in node_meta if key != focus_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 + + 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][4], + valid_from=None, + valid_to=None, + recorded_at=node_meta[key][2], + evidence_count=node_meta[key][3], + shape_code=NODE_SHAPE[node_meta[key][0]], + ) + for key in ordered_keys + ) + edges = tuple( + OntologyGraphEdge( + edge_id=_edge_id(fact), + source_node_id=fact.source_node_id, + 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", + "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/migrations/0104_ontology_truth_status.sql b/migrations/0104_ontology_truth_status.sql new file mode 100644 index 000000000..e17bb50b3 --- /dev/null +++ b/migrations/0104_ontology_truth_status.sql @@ -0,0 +1,13 @@ +-- Ontology neighborhood truth-status vocabulary (ADR 0119 / 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 cb4be2916..56bb19866 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.6" +version = "2.13.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" } diff --git a/tests/test_ontology_neighborhood.py b/tests/test_ontology_neighborhood.py new file mode 100644 index 000000000..192790226 --- /dev/null +++ b/tests/test_ontology_neighborhood.py @@ -0,0 +1,655 @@ +"""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, + 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): "Priya Nair", + (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 + 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_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,), + ) + neighborhood = assemble_ontology_neighborhood( + focus_node_type_code=NODE_PERSON, + focus_node_id=PERSON_ID, + facts=[inferred], + labels=_labels(), + ) + assert neighborhood.edges[0].truth_status_code == TRUTH_INFERRED + assert neighborhood.edges[0].truth_status_code != TRUTH_AUTHORITATIVE + document = neighborhood.jsonld_document() + statuses = [item["lw:truthStatus"] for item in document["@graph"] if "lw:truthStatus" in item] + assert TRUTH_INFERRED in statuses + assert TRUTH_AUTHORITATIVE not 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_dangling_endpoint_fail_closed() -> None: + with pytest.raises(OntologyNeighborhoodError) as unknown: + canonicalize_property_code("edge_invented") + assert unknown.value.code == "unknown_property" + with pytest.raises(OntologyNeighborhoodError) as dangling: + 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): "Priya Nair"}, + ) + assert dangling.value.code == "dangling_endpoint" + + +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_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 == "unknown_node_type" + + +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] + 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_id=POST_ID, + 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" + rows = neighborhood.exact_value_rows() + assert rows == () + + +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_and_unknown_fact_node_type_fail_closed() -> None: + with pytest.raises(OntologyNeighborhoodError) as source: + 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 source.value.code == "dangling_endpoint" + 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_ingestion.py b/tests/test_ontology_neighborhood_ingestion.py new file mode 100644 index 000000000..7f7883c52 --- /dev/null +++ b/tests/test_ontology_neighborhood_ingestion.py @@ -0,0 +1,564 @@ +"""Authorization-gated ontology neighborhood loader (ADR 0119).""" + +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_skos_facts, + focus_catalog_exists, + 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, + 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 + + 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]: + 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: + 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")) == 403 + assert neighborhood_error_http_status(OntologyNeighborhoodError("focus_not_visible", "x")) == 403 + 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("unbounded_request", "x")) == 422 + + +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 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": "Priya Nair"}], + "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)] == "Priya Nair" + 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_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, + }, + "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": "Priya Nair"}], + "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["jsonld"]["@graph"] + assert payload["exact_value_rows"] + assert all(node["shape_code"] for node in payload["nodes"]) + + +def test_visible_neighborhood_focus_variants_and_fail_closed() -> None: + 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": "Priya Nair", + } + ), + 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_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": "Priya Nair"}], + "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, + "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): "Priya Nair", + (NODE_CORPORATE_ENTITY, CORP_ID): "Demo Corp", + }, + ) + 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/uv.lock b/uv.lock index 10bcf9ff1..a24b3ee73 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.6" +version = "2.13.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 5b2fc2ed3d2afa8c2140cd6c816a96650ff22ea0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:16:23 +0900 Subject: [PATCH 02/74] fix: keep ontology JSON-LD exports filtered --- .../src/components/OntologyExplorer.test.tsx | 28 ++++++++++++++++++- frontend/src/components/OntologyExplorer.tsx | 21 ++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/OntologyExplorer.test.tsx b/frontend/src/components/OntologyExplorer.test.tsx index 95af9648e..127613f2e 100644 --- a/frontend/src/components/OntologyExplorer.test.tsx +++ b/frontend/src/components/OntologyExplorer.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { OntologyNeighborhoodPayload } from "../api"; -import { OntologyExplorer } from "./OntologyExplorer"; +import { filterNeighborhood, jsonldForNeighborhood, OntologyExplorer } from "./OntologyExplorer"; const POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"; const PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1"; @@ -205,4 +205,30 @@ describe("OntologyExplorer", () => { await userEvent.click(screen.getByRole("button", { name: "Reset focus" })); expect(screen.getByLabelText("Search within this neighborhood")).toHaveValue(""); }); + + it("exports only the graph that remains visible after search", () => { + const payload = neighborhood({ + jsonld: { + "@context": { lw: "https://example.test/" }, + "@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" }, + { "@id": "lw:node/node_person/hidden" }, + ], + }, + }); + const visible = filterNeighborhood(payload, "Priya"); + expect(visible).not.toBeNull(); + const graph = jsonldForNeighborhood(visible!)["@graph"] as Array>; + expect(graph.map((item) => item["@id"])).toEqual([ + `lw:node/node_post/${POST_ID}`, + `lw:node/node_person/${PERSON_ID}`, + `lw:node/node_corporate_entity/${CORP_ID}`, + "lw:edge/mentions:post-person", + "lw:edge/affiliated:person-corp", + ]); + }); }); diff --git a/frontend/src/components/OntologyExplorer.tsx b/frontend/src/components/OntologyExplorer.tsx index b007f4f31..bed39f9f3 100644 --- a/frontend/src/components/OntologyExplorer.tsx +++ b/frontend/src/components/OntologyExplorer.tsx @@ -132,7 +132,7 @@ export function OntologyExplorer({ if (!visible) return; downloadFile( "ontology-neighborhood.jsonld", - `${JSON.stringify(visible.jsonld, null, 2)}\n`, + `${JSON.stringify(jsonldForNeighborhood(visible), null, 2)}\n`, "application/ld+json", ); } @@ -520,7 +520,7 @@ function OntologyEdgeDrawer({ ); } -function filterNeighborhood( +export function filterNeighborhood( payload: OntologyNeighborhoodPayload | null, query: string, ): OntologyNeighborhoodPayload | null { @@ -552,6 +552,23 @@ function filterNeighborhood( return { ...payload, nodes, edges, exact_value_rows }; } +export function jsonldForNeighborhood(payload: OntologyNeighborhoodPayload): Record { + const graph = payload.jsonld["@graph"]; + if (!Array.isArray(graph)) return { ...payload.jsonld, "@graph": [] }; + const visibleIds = new Set([ + ...payload.nodes.map((node) => `lw:node/${node.node_type_code}/${node.node_id}`), + ...payload.edges.map((edge) => `lw:edge/${edge.edge_id}`), + ]); + return { + ...payload.jsonld, + "@graph": graph.filter((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return false; + const id = (item as Record)["@id"]; + return typeof id === "string" && visibleIds.has(id); + }), + }; +} + function downloadFile(name: string, body: string, type: string) { const blob = new Blob([body], { type }); const url = URL.createObjectURL(blob); From 2e7ed085e5952e103d651f9fe0b01f877dfa0b21 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:19:30 +0900 Subject: [PATCH 03/74] fix: validate ontology neighborhood request bounds --- backend/app/main.py | 12 ++++++++---- backend/app/ontology_neighborhood_ingestion.py | 2 ++ lineageweave/ontology_neighborhood.py | 2 +- tests/test_ontology_neighborhood.py | 2 +- tests/test_ontology_neighborhood_ingestion.py | 1 + 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 664f9cda7..3ea028fad 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -176,6 +176,9 @@ 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 ( @@ -1943,9 +1946,9 @@ async def read_related_team( 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), - maximum_nodes: int = Query(DEFAULT_MAXIMUM_NODES), - maximum_edges: int = Query(DEFAULT_MAXIMUM_EDGES), + 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), @@ -1974,9 +1977,10 @@ async def read_ontology_neighborhood( knowledge_cutoff=cutoff_clock, cursor=cursor, ) + payload = neighborhood_to_payload(neighborhood) except OntologyNeighborhoodError as exc: raise HTTPException(neighborhood_error_http_status(exc), str(exc)) from exc - return neighborhood_to_payload(neighborhood) + return payload @app.get("/api/posts/{post_id}/counterparties") diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py index 6fc4db577..974b3fb0b 100644 --- a/backend/app/ontology_neighborhood_ingestion.py +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -303,6 +303,8 @@ async def visible_ontology_neighborhood( 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 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( diff --git a/lineageweave/ontology_neighborhood.py b/lineageweave/ontology_neighborhood.py index 091f670ef..6f777f009 100644 --- a/lineageweave/ontology_neighborhood.py +++ b/lineageweave/ontology_neighborhood.py @@ -385,7 +385,7 @@ def assemble_ontology_neighborhood( 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("unknown_node_type", "focus node id is empty") + raise OntologyNeighborhoodError("invalid_focus_id", "focus node id is empty or malformed") 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: diff --git a/tests/test_ontology_neighborhood.py b/tests/test_ontology_neighborhood.py index 192790226..d270b4fd5 100644 --- a/tests/test_ontology_neighborhood.py +++ b/tests/test_ontology_neighborhood.py @@ -423,7 +423,7 @@ def test_unbounded_request_and_empty_focus_fail_closed() -> None: facts=[], labels=_labels(), ) - assert empty.value.code == "unknown_node_type" + assert empty.value.code == "invalid_focus_id" def test_team_and_organization_mention_edges_round_trip() -> None: diff --git a/tests/test_ontology_neighborhood_ingestion.py b/tests/test_ontology_neighborhood_ingestion.py index 7f7883c52..e6b964c99 100644 --- a/tests/test_ontology_neighborhood_ingestion.py +++ b/tests/test_ontology_neighborhood_ingestion.py @@ -95,6 +95,7 @@ def test_neighborhood_error_http_status_is_fail_closed() -> None: assert neighborhood_error_http_status(OntologyNeighborhoodError("focus_not_visible", "x")) == 403 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 From bf588b71964fdcdd7c80c3305d16d866611d667d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:20:29 +0900 Subject: [PATCH 04/74] fix: escape ontology CSV formulas --- frontend/src/ontologyLayout.test.ts | 5 +++++ frontend/src/ontologyLayout.ts | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/ontologyLayout.test.ts b/frontend/src/ontologyLayout.test.ts index b4537bc3c..5a705cc5a 100644 --- a/frontend/src/ontologyLayout.test.ts +++ b/frontend/src/ontologyLayout.test.ts @@ -128,5 +128,10 @@ describe("ontologyLayout", () => { ], }); expect(quoted).toContain('"Demo, ""quoted"" post"'); + const formulaSafe = neighborhoodCsv({ + ...payload(), + exact_value_rows: [{ ...payload().exact_value_rows[0], source_label: "=1+1" }], + }); + expect(formulaSafe).toContain("'=1+1"); }); }); diff --git a/frontend/src/ontologyLayout.ts b/frontend/src/ontologyLayout.ts index bb4606b0b..bb945ebff 100644 --- a/frontend/src/ontologyLayout.ts +++ b/frontend/src/ontologyLayout.ts @@ -137,8 +137,9 @@ export function neighborhoodCsv(payload: OntologyNeighborhoodPayload): string { } function csvCell(value: string): string { - if (/[",\n]/.test(value)) { - return `"${value.replaceAll('"', '""')}"`; + const safeValue = /^[=+\-@]/.test(value) ? `'${value}` : value; + if (/[",\n]/.test(safeValue)) { + return `"${safeValue.replaceAll('"', '""')}"`; } - return value; + return safeValue; } From c5babaa0c689f7aad4cd584e1286fd12f711f9fd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:21:35 +0900 Subject: [PATCH 05/74] fix: make ontology layout ordering deterministic --- frontend/src/ontologyLayout.test.ts | 21 +++++++++++++++++++++ frontend/src/ontologyLayout.ts | 6 +++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/frontend/src/ontologyLayout.test.ts b/frontend/src/ontologyLayout.test.ts index 5a705cc5a..4b006f570 100644 --- a/frontend/src/ontologyLayout.test.ts +++ b/frontend/src/ontologyLayout.test.ts @@ -134,4 +134,25 @@ describe("ontologyLayout", () => { }); expect(formulaSafe).toContain("'=1+1"); }); + + it("uses locale-independent code-unit ordering for node placement", () => { + const ordered = layoutOntologyNeighborhood({ + ...payload(), + nodes: payload().nodes.map((node) => + node.node_id === PERSON_ID ? { ...node, display_label: "ä" } : + node.node_id === CORP_ID ? { ...node, display_label: "z" } : node, + ), + edges: payload().edges.map((edge) => + edge.edge_id === "affiliated:person-corp" + ? { ...edge, source_node_id: POST_ID } + : edge, + ), + }); + expect( + ordered.nodes + .filter((node) => node.depth === 1) + .sort((left, right) => left.y - right.y) + .map((node) => node.node_id), + ).toEqual([CORP_ID, PERSON_ID]); + }); }); diff --git a/frontend/src/ontologyLayout.ts b/frontend/src/ontologyLayout.ts index bb945ebff..068d4cef4 100644 --- a/frontend/src/ontologyLayout.ts +++ b/frontend/src/ontologyLayout.ts @@ -76,7 +76,11 @@ export function layoutOntologyNeighborhood(payload: OntologyNeighborhoodPayload) [...ids].sort((left, right) => { const leftLabel = byId.get(left)?.display_label ?? left; const rightLabel = byId.get(right)?.display_label ?? right; - return leftLabel.localeCompare(rightLabel) || left.localeCompare(right); + if (leftLabel < rightLabel) return -1; + if (leftLabel > rightLabel) return 1; + if (left < right) return -1; + if (left > right) return 1; + return 0; }), ); } From fe82d1f2d55a2194aa8d3010d2d14f81b94d0326 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:23:57 +0900 Subject: [PATCH 06/74] fix: make ontology explorer controls accessible --- frontend/src/App.css | 6 ++++++ frontend/src/App.tsx | 3 ++- frontend/src/components/OntologyExplorer.tsx | 6 +++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 89bcbacea..1c54cfc7c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -921,6 +921,12 @@ cursor: pointer; } +.ontology-node:focus-visible, +.ontology-edge-hit:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + .ontology-exact-values { margin-top: 1rem; overflow-x: auto; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa02f57ca..4839a84b1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1191,7 +1191,8 @@ function KeymanPanel({ diff --git a/frontend/src/components/OntologyExplorer.tsx b/frontend/src/components/OntologyExplorer.tsx index bed39f9f3..64cc75fae 100644 --- a/frontend/src/components/OntologyExplorer.tsx +++ b/frontend/src/components/OntologyExplorer.tsx @@ -575,6 +575,10 @@ function downloadFile(name: string, body: string, type: string) { const link = document.createElement("a"); link.href = url; link.download = name; + document.body.appendChild(link); link.click(); - URL.revokeObjectURL(url); + window.setTimeout(() => { + URL.revokeObjectURL(url); + link.remove(); + }, 0); } From d8971b2a3bc4763e88236f2437ec5713aac4786e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:24:49 +0900 Subject: [PATCH 07/74] fix: translate ontology post node labels --- frontend/src/i18n.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 652b9c430..c96193122 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -77,6 +77,7 @@ const TRANSLATIONS: Partial>> = { Team: "팀", Person: "사람", Organization: "조직", + Post: "글", Project: "프로젝트", "Body this run knew": "이 실행이 알고 있던 본문", written: "작성일", @@ -474,6 +475,7 @@ const TRANSLATIONS: Partial>> = { Team: "团队", Person: "人员", Organization: "组织", + Post: "文章", Project: "项目", "Body this run knew": "此运行已知的正文", written: "撰写于", @@ -893,6 +895,7 @@ const TRANSLATIONS: Partial>> = { Team: "チーム", Person: "人物", Organization: "組織", + Post: "投稿", Project: "プロジェクト", "Body this run knew": "この実行が把握していた本文", written: "作成日", @@ -1289,6 +1292,7 @@ const TRANSLATIONS: Partial>> = { Team: "Nhóm", Person: "Người", Organization: "Tổ chức", + Post: "Bài viết", Project: "Dự án", "Body this run knew": "Nội dung mà lần chạy này đã biết", written: "được viết", From f83269a79775cbf0f1f872071d059587d28b051f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:26:15 +0900 Subject: [PATCH 08/74] fix: deduplicate ontology node evidence --- lineageweave/ontology_neighborhood.py | 10 +++++++--- tests/test_ontology_neighborhood.py | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lineageweave/ontology_neighborhood.py b/lineageweave/ontology_neighborhood.py index 6f777f009..ddbd53260 100644 --- a/lineageweave/ontology_neighborhood.py +++ b/lineageweave/ontology_neighborhood.py @@ -490,6 +490,7 @@ def assemble_ontology_neighborhood( next_cursor = f"after:{_edge_id(page_edges[-1])}" node_meta: dict[str, tuple[str, str, datetime, int, str]] = {} + node_evidence: dict[str, set[str]] = defaultdict(set) focus_label = labels[(focus_node_type_code, focus_node_id)] fallback_recorded = knowledge_cutoff or datetime(1970, 1, 1, tzinfo=timezone.utc) node_meta[focus_key] = ( @@ -507,20 +508,23 @@ def assemble_ontology_neighborhood( key = _node_key(node_type, node_id) label = labels[(node_type, node_id)] current = node_meta.get(key) - evidence = len(fact.evidence_references) + node_evidence[key].update(fact.evidence_references) recorded = fact.recorded_at if current is None: - node_meta[key] = (node_type, label, recorded, evidence, truth) + node_meta[key] = (node_type, label, recorded, 0, truth) else: _, _, prior_recorded, prior_evidence, prior_truth = current node_meta[key] = ( node_type, label, min(prior_recorded, recorded), - prior_evidence + evidence, + prior_evidence, prior_truth if prior_truth == TRUTH_AUTHORITATIVE else truth, ) + for key, (node_type, label, recorded, _, truth) in node_meta.items(): + node_meta[key] = (node_type, label, recorded, len(node_evidence[key]), truth) + ordered_keys = [focus_key] + sorted(key for key in node_meta if key != focus_key) if len(ordered_keys) > maximum_nodes: keep = set(ordered_keys[:maximum_nodes]) diff --git a/tests/test_ontology_neighborhood.py b/tests/test_ontology_neighborhood.py index d270b4fd5..ab7d362ba 100644 --- a/tests/test_ontology_neighborhood.py +++ b/tests/test_ontology_neighborhood.py @@ -104,6 +104,8 @@ def test_post_mentions_person_affiliated_with_corporate_entity_round_trips() -> 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} From b369d308b8b89947e5e7e59f5246c48039acc11e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:27:23 +0900 Subject: [PATCH 09/74] fix: drop unlabeled ontology edges --- .../app/ontology_neighborhood_ingestion.py | 6 +++ tests/test_ontology_neighborhood_ingestion.py | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py index 974b3fb0b..9cb062892 100644 --- a/backend/app/ontology_neighborhood_ingestion.py +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -345,6 +345,12 @@ async def visible_ontology_neighborhood( ) if name: labels[(NODE_TEAM, focus_node_id)] = name + 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 + ] return assemble_ontology_neighborhood( focus_node_type_code=focus_node_type_code, focus_node_id=focus_node_id, diff --git a/tests/test_ontology_neighborhood_ingestion.py b/tests/test_ontology_neighborhood_ingestion.py index e6b964c99..9745c294c 100644 --- a/tests/test_ontology_neighborhood_ingestion.py +++ b/tests/test_ontology_neighborhood_ingestion.py @@ -326,6 +326,45 @@ def test_visible_ontology_neighborhood_round_trips_and_payload() -> None: 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": [], + "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 unknown: asyncio.run( From e0b3bd2908ccdb9113c300874a5f6780e6afa12f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 13:29:27 +0900 Subject: [PATCH 10/74] fix: harden typed ontology neighborhood boundaries --- CHANGELOG.d/2.13.0-ontology-explorer.md | 6 +- CHANGELOG.md | 4 + backend/app/main.py | 11 +- .../app/ontology_neighborhood_ingestion.py | 219 ++++++++++++++---- docs/adr/0119-ontology-provenance-explorer.md | 9 +- frontend/src/App.css | 10 + frontend/src/App.tsx | 5 +- frontend/src/api.ts | 6 +- .../components/OntologyExplorer.stories.tsx | 50 +++- .../src/components/OntologyExplorer.test.tsx | 44 +++- frontend/src/components/OntologyExplorer.tsx | 91 +++----- frontend/src/i18n.test.ts | 11 + frontend/src/i18n.ts | 4 + frontend/src/ontologyLayout.test.ts | 56 ++++- frontend/src/ontologyLayout.ts | 105 +++++++-- lineageweave/ontology_neighborhood.py | 90 ++++--- tests/test_ontology_neighborhood.py | 141 +++++++++-- tests/test_ontology_neighborhood_ingestion.py | 155 ++++++++++++- 18 files changed, 803 insertions(+), 214 deletions(-) diff --git a/CHANGELOG.d/2.13.0-ontology-explorer.md b/CHANGELOG.d/2.13.0-ontology-explorer.md index 93d090f48..f493b6dbb 100644 --- a/CHANGELOG.d/2.13.0-ontology-explorer.md +++ b/CHANGELOG.d/2.13.0-ontology-explorer.md @@ -6,5 +6,9 @@ - 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. -- Buyer surface: Keyman **Inspect ontology neighborhood**, exact-value table, +- 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1680322f1..89b6ff46e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ 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. ## [2.12.6] - 2026-08-20 diff --git a/backend/app/main.py b/backend/app/main.py index 664f9cda7..0f0873ecb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -176,6 +176,9 @@ 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 ( @@ -1943,9 +1946,9 @@ async def read_related_team( 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), - maximum_nodes: int = Query(DEFAULT_MAXIMUM_NODES), - maximum_edges: int = Query(DEFAULT_MAXIMUM_EDGES), + 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), @@ -1974,9 +1977,9 @@ async def read_ontology_neighborhood( knowledge_cutoff=cutoff_clock, cursor=cursor, ) + return neighborhood_to_payload(neighborhood) except OntologyNeighborhoodError as exc: raise HTTPException(neighborhood_error_http_status(exc), str(exc)) from exc - return neighborhood_to_payload(neighborhood) @app.get("/api/posts/{post_id}/counterparties") diff --git a/backend/app/ontology_neighborhood_ingestion.py b/backend/app/ontology_neighborhood_ingestion.py index 6fc4db577..3749be803 100644 --- a/backend/app/ontology_neighborhood_ingestion.py +++ b/backend/app/ontology_neighborhood_ingestion.py @@ -27,8 +27,10 @@ DEFAULT_MAXIMUM_DEPTH, DEFAULT_MAXIMUM_EDGES, DEFAULT_MAXIMUM_NODES, + HARD_MAXIMUM_EDGES, KNOWN_NODE_TYPES, NeighborhoodFact, + OntologyNodeMetadata, OntologyNeighborhood, OntologyNeighborhoodError, assemble_ontology_neighborhood, @@ -123,31 +125,82 @@ async def focus_catalog_exists( async def _load_facts( - conn: asyncpg.Connection, visible_post_ids: list[str] + 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, ) -> list[NeighborhoodFact]: if not visible_post_ids: return [] rows = await conn.fetch( """ - select edge.source_node_type_code, - edge.source_node_id, - edge.target_node_type_code, - edge.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[]) - group by edge.source_node_type_code, edge.source_node_id, - edge.target_node_type_code, edge.target_node_id, - edge.edge_type_code + 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[]) + 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 + ) + select distinct 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 + 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 + order by candidate.edge_type_code, + candidate.source_node_type_code, + candidate.source_node_id, + candidate.target_node_type_code, + candidate.target_node_id + limit $5::integer """, visible_post_ids, + focus_node_type_code, + focus_node_id, + maximum_depth, + min(HARD_MAXIMUM_EDGES, maximum_edges * (maximum_depth + 1) + 1), ) facts: list[NeighborhoodFact] = [] for row in rows: @@ -194,52 +247,96 @@ async def _load_skos_facts( async def _load_labels( conn: asyncpg.Connection, facts: list[NeighborhoodFact] ) -> dict[tuple[str, str], str]: - person_ids: list[str] = [] - post_ids: list[str] = [] - corp_ids: list[str] = [] - team_ids: list[str] = [] - for fact in facts: - for node_type, node_id in ( - (fact.source_node_type_code, fact.source_node_id), - (fact.target_node_type_code, fact.target_node_id), - ): - if node_type == NODE_PERSON: - person_ids.append(node_id) - elif node_type == NODE_POST: - post_ids.append(node_id) - elif node_type == NODE_CORPORATE_ENTITY: - corp_ids.append(node_id) - elif node_type == NODE_TEAM: - team_ids.append(node_id) + 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, ): - labels[(NODE_PERSON, str(row["person_id"]))] = row["person_name"] + 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, ): - labels[(NODE_POST, str(row["post_id"]))] = row["post_title"] + 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, ): - labels[(NODE_CORPORATE_ENTITY, str(row["corporate_entity_id"]))] = row["entity_name"] + 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, ): - labels[(NODE_TEAM, str(row["team_id"]))] = row["team_name"] + 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 { @@ -257,7 +354,7 @@ def neighborhood_to_payload(neighborhood: OntologyNeighborhood) -> dict[str, Any "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(), + "recorded_at": node.recorded_at.isoformat() if node.recorded_at else None, "evidence_count": node.evidence_count, "shape_code": node.shape_code, } @@ -266,7 +363,9 @@ def neighborhood_to_payload(neighborhood: OntologyNeighborhood) -> dict[str, Any "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, @@ -310,7 +409,14 @@ async def visible_ontology_neighborhood( ) if not visible_post_ids: raise OntologyNeighborhoodError("focus_not_visible", "focus node is not visible") - facts = await _load_facts(conn, visible_post_ids) + facts = 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=maximum_edges, + ) corp_ids = [ fact.source_node_id if fact.source_node_type_code == NODE_CORPORATE_ENTITY else fact.target_node_id for fact in facts @@ -319,6 +425,29 @@ async def visible_ontology_neighborhood( if focus_node_type_code == NODE_CORPORATE_ENTITY: corp_ids.append(focus_node_id) facts.extend(await _load_skos_facts(conn, list(dict.fromkeys(corp_ids)))) + 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, + } + 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] = bool( + await visible_post_ids_for_focus(conn, node_type, node_id, can_see_post) + ) + 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 focus_node_type_code == NODE_POST: title = await conn.fetchval("select post_title from source_post where post_id = $1", focus_node_id) @@ -343,11 +472,19 @@ async def visible_ontology_neighborhood( ) if name: labels[(NODE_TEAM, focus_node_id)] = name + node_metadata = await _load_node_metadata( + conn, + facts, + focus_node_type_code=focus_node_type_code, + focus_node_id=focus_node_id, + ) return 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, diff --git a/docs/adr/0119-ontology-provenance-explorer.md b/docs/adr/0119-ontology-provenance-explorer.md index d18165bc8..f98e76460 100644 --- a/docs/adr/0119-ontology-provenance-explorer.md +++ b/docs/adr/0119-ontology-provenance-explorer.md @@ -5,7 +5,7 @@ **Figma:** File ID `1Su3lDRmiZdcUs47t1QwIX` **Issue:** [#341](https://github.com/ContextualWisdomLab/LineageWeave/issues/341) -**Context:** The Buyer 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. +**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:** @@ -13,10 +13,13 @@ 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. 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 Buyer 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). 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. +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:** diff --git a/frontend/src/App.css b/frontend/src/App.css index 89bcbacea..a3c2da046 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -899,6 +899,11 @@ 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); @@ -921,6 +926,11 @@ cursor: pointer; } +.ontology-edge-hit:focus-visible { + stroke: var(--color-primary); + stroke-width: 3; +} + .ontology-exact-values { margin-top: 1rem; overflow-x: auto; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa02f57ca..cb141f131 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -102,7 +102,6 @@ import { tf, useLocale, } from "./i18n"; -import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -1191,7 +1190,8 @@ function KeymanPanel({ @@ -4666,7 +4666,6 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean Enterprise SSO Authentication
- {destination === "admin" ? : null}